diff --git a/README.md b/README.md index 572c58b..35cd438 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,8 @@ switchbot --help switchbot devices command --help ``` +> **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`. + ### `--dry-run` 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 # Default columns (4): deviceId, deviceName, type, category # Pass --wide for the full 10-column operator view switchbot devices list +switchbot devices ls # short alias for 'list' switchbot devices list --wide switchbot devices list --json | jq '.deviceList[].deviceId' diff --git a/package-lock.json b/package-lock.json index 2926e3e..c441b5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "2.2.0", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "2.2.0", + "version": "2.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index fa3211b..1f36f3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "2.2.0", + "version": "2.2.1", "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot", diff --git a/src/commands/batch.ts b/src/commands/batch.ts index 218d76b..5c53784 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import type { AxiosInstance } from 'axios'; +import { intArg, enumArg, stringArg } from '../utils/arg-parsers.js'; import { printJson, isJsonMode, handleError, buildErrorPayload, type ErrorPayload } from '../utils/output.js'; import { fetchDeviceList, @@ -28,6 +29,7 @@ interface BatchResult { } const DEFAULT_CONCURRENCY = 5; +const COMMAND_TYPES = ['command', 'customize'] as const; /** Run `task(x)` for every element with at most `concurrency` running at once. */ async function runPool( @@ -120,13 +122,13 @@ export function registerBatchCommand(devices: Command): void { .description('Send the same command to many devices in one run (filter- or stdin-driven)') .argument('', 'Command name, e.g. turnOn, turnOff, setBrightness') .argument('[parameter]', 'Command parameter (same rules as `devices command`; omit for no-arg)') - .option('--filter ', 'Target devices matching a filter, e.g. type=Bot,family=Home') - .option('--ids ', 'Explicit comma-separated list of deviceIds') - .option('--concurrency ', 'Max parallel in-flight requests (default 5)', '5') + .option('--filter ', 'Target devices matching a filter, e.g. type=Bot,family=Home', stringArg('--filter')) + .option('--ids ', 'Explicit comma-separated list of deviceIds', stringArg('--ids')) + .option('--concurrency ', 'Max parallel in-flight requests (default 5)', intArg('--concurrency', { min: 1 }), '5') .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)') - .option('--type ', '"command" (default) or "customize" for user-defined IR buttons', 'command') + .option('--type ', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command') .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")') - .option('--idempotency-key-prefix ', 'Prefix for idempotency keys (key per device: -)') + .option('--idempotency-key-prefix ', 'Prefix for idempotency keys (key per device: -)', stringArg('--idempotency-key-prefix')) .addHelpText('after', ` Targets are resolved in this priority order: 1. --ids when present (explicit deviceIds) diff --git a/src/commands/cache.ts b/src/commands/cache.ts index a355d3c..40f2661 100644 --- a/src/commands/cache.ts +++ b/src/commands/cache.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { enumArg } from '../utils/arg-parsers.js'; import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import { clearCache, @@ -19,6 +20,7 @@ function formatAge(ms?: number): string { } export function registerCacheCommand(program: Command): void { + const CACHE_KEYS = ['list', 'status', 'all'] as const; const cache = program .command('cache') .description('Inspect and manage the local SwitchBot CLI caches') @@ -88,7 +90,7 @@ Examples: cache .command('clear') .description('Delete cache files') - .option('--key ', 'Which cache to clear: "list" | "status" | "all" (default)', 'all') + .option('--key ', 'Which cache to clear: "list" | "status" | "all" (default)', enumArg('--key', CACHE_KEYS), 'all') .action((options: { key: string }) => { try { const key = options.key; diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 7b32a7b..eeb07da 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { enumArg } from '../utils/arg-parsers.js'; import { printTable, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; import { @@ -12,6 +13,7 @@ import { } from '../devices/catalog.js'; export function registerCatalogCommand(program: Command): void { + const SOURCES = ['built-in', 'overlay', 'effective'] as const; const catalog = program .command('catalog') .description('Inspect the built-in device catalog and any local overlay') @@ -76,7 +78,7 @@ Examples: .command('show') .description("Show the effective catalog (or one entry). Defaults to 'effective' source.") .argument('[type...]', 'Optional device type/alias (case-insensitive, partial match)') - .option('--source ', 'Which catalog to show: built-in | overlay | effective (default)', 'effective') + .option('--source ', 'Which catalog to show: built-in | overlay | effective (default)', enumArg('--source', SOURCES), 'effective') .action((typeParts: string[], options: { source: string }) => { try { const source = options.source; diff --git a/src/commands/completion.ts b/src/commands/completion.ts index adb7a69..9aacfed 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -14,13 +14,19 @@ _switchbot_completion() { cword="\${COMP_CWORD}" } - local top_cmds="config devices scenes webhook completion help" - local config_sub="set-token show" - local devices_sub="list status command types commands" + local top_cmds="config devices scenes webhook completion mcp quota catalog cache events doctor schema history plan capabilities help" + local config_sub="set-token show list-profiles" + local devices_sub="list ls status command types commands describe batch watch explain expand meta" local scenes_sub="list execute" local webhook_sub="setup query update delete" + local events_sub="tail mqtt-tail" + local quota_sub="status reset" + local catalog_sub="path show diff refresh" + local cache_sub="show clear" + local history_sub="show replay" + local plan_sub="schema validate run" local completion_shells="bash zsh fish powershell" - local global_opts="--json --verbose -v --dry-run --timeout --config --help -h --version -V" + 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" if [[ \${cword} -eq 1 ]]; then COMPREPLY=( $(compgen -W "\${top_cmds} \${global_opts}" -- "\${cur}") ) @@ -45,6 +51,36 @@ _switchbot_completion() { COMPREPLY=( $(compgen -W "\${scenes_sub}" -- "\${cur}") ) fi ;; + events) + if [[ \${cword} -eq 2 ]]; then + COMPREPLY=( $(compgen -W "\${events_sub}" -- "\${cur}") ) + fi + ;; + quota) + if [[ \${cword} -eq 2 ]]; then + COMPREPLY=( $(compgen -W "\${quota_sub}" -- "\${cur}") ) + fi + ;; + catalog) + if [[ \${cword} -eq 2 ]]; then + COMPREPLY=( $(compgen -W "\${catalog_sub}" -- "\${cur}") ) + fi + ;; + cache) + if [[ \${cword} -eq 2 ]]; then + COMPREPLY=( $(compgen -W "\${cache_sub}" -- "\${cur}") ) + fi + ;; + history) + if [[ \${cword} -eq 2 ]]; then + COMPREPLY=( $(compgen -W "\${history_sub}" -- "\${cur}") ) + fi + ;; + plan) + if [[ \${cword} -eq 2 ]]; then + COMPREPLY=( $(compgen -W "\${plan_sub}" -- "\${cur}") ) + fi + ;; webhook) if [[ \${cword} -eq 2 ]]; then COMPREPLY=( $(compgen -W "\${webhook_sub}" -- "\${cur}") ) @@ -72,22 +108,39 @@ const ZSH_SCRIPT = `# switchbot zsh completion # source <(switchbot completion zsh) _switchbot() { - local -a top_cmds config_sub devices_sub scenes_sub webhook_sub completion_shells + 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 top_cmds=( 'config:Manage API credentials' 'devices:List and control devices' 'scenes:List and execute scenes' 'webhook:Manage webhook configuration' 'completion:Print a shell completion script' + 'mcp:Run the MCP server' + 'quota:Inspect local request quota' + 'catalog:Inspect the built-in device catalog' + 'cache:Inspect local caches' + 'events:Receive webhook or MQTT events' + 'doctor:Run self-checks' + 'schema:Export the device catalog as JSON' + 'history:View and replay audited commands' + 'plan:Validate and run batch plans' + 'capabilities:Print a machine-readable manifest' 'help:Show help for a command' ) - config_sub=('set-token:Save token + secret' 'show:Show current credential source') + config_sub=('set-token:Save token + secret' 'show:Show current credential source' 'list-profiles:List named credential profiles') devices_sub=( 'list:List all devices' + 'ls:Alias for list' 'status:Query device status' 'command:Send a control command' 'types:List known device types (offline)' 'commands:Show commands for a device type (offline)' + 'describe:Show metadata + supported commands for one device' + 'batch:Send one command to many devices' + 'watch:Poll device status and emit changes' + 'explain:One-shot device summary' + 'expand:Build wire-format params from semantic flags' + 'meta:Manage local device metadata' ) scenes_sub=('list:List manual scenes' 'execute:Run a scene') webhook_sub=( @@ -96,15 +149,32 @@ _switchbot() { 'update:Enable/disable a webhook' 'delete:Delete a webhook' ) + events_sub=('tail:Run a local webhook receiver' 'mqtt-tail:Stream MQTT shadow events') + quota_sub=('status:Show today and recent quota usage' 'reset:Delete the local quota counter') + catalog_sub=('path:Show overlay path' 'show:Show built-in/overlay/effective catalog' 'diff:Show overlay changes' 'refresh:Clear overlay cache') + cache_sub=('show:Summarize cache files' 'clear:Delete cache files') + history_sub=('show:Print recent audit entries' 'replay:Re-run one audited command') + plan_sub=('schema:Print the plan schema' 'validate:Validate a plan file' 'run:Validate and execute a plan') completion_shells=('bash' 'zsh' 'fish' 'powershell') local global_opts global_opts=( '--json[Raw JSON output]' + '--format[Output format]:type:(table json jsonl tsv yaml id)' + '--fields[Comma-separated output columns]:csv:' '(-v --verbose)'{-v,--verbose}'[Log HTTP details to stderr]' '--dry-run[Print mutating requests without sending]' '--timeout[HTTP timeout in ms]:ms:' + '--retry-on-429[Max 429 retries]:n:' + '--backoff[Retry backoff strategy]:strategy:(linear exponential)' + '--no-retry[Disable 429 retries]' + '--no-quota[Disable the local quota counter]' + '--cache[Cache mode]:mode:' + '--no-cache[Disable cache reads]' '--config[Override credential file path]:path:_files' + '--profile[Use a named credential profile]:name:' + '--audit-log[Append mutating commands to ~/.switchbot/audit.log]' + '--audit-log-path[Custom audit log file path]:path:_files' '(-h --help)'{-h,--help}'[Show help]' '(-V --version)'{-V,--version}'[Show version]' ) @@ -125,6 +195,12 @@ _switchbot() { devices) _describe 'devices' devices_sub ;; scenes) _describe 'scenes' scenes_sub ;; webhook) _describe 'webhook' webhook_sub ;; + events) _describe 'events' events_sub ;; + quota) _describe 'quota' quota_sub ;; + catalog) _describe 'catalog' catalog_sub ;; + cache) _describe 'cache' cache_sub ;; + history) _describe 'history' history_sub ;; + plan) _describe 'plan' plan_sub ;; completion) _values 'shell' $completion_shells ;; esac ;; @@ -147,10 +223,21 @@ complete -c switchbot -f # Global options complete -c switchbot -l json -d 'Raw JSON output' +complete -c switchbot -l format -r -d 'Output format' +complete -c switchbot -l fields -r -d 'Comma-separated output columns' complete -c switchbot -s v -l verbose -d 'Log HTTP details to stderr' complete -c switchbot -l dry-run -d 'Print mutating requests without sending' complete -c switchbot -l timeout -r -d 'HTTP timeout in ms' +complete -c switchbot -l retry-on-429 -r -d 'Max 429 retries' +complete -c switchbot -l backoff -r -d 'Retry backoff strategy' +complete -c switchbot -l no-retry -d 'Disable 429 retries' +complete -c switchbot -l no-quota -d 'Disable the local quota counter' +complete -c switchbot -l cache -r -d 'Cache mode' +complete -c switchbot -l no-cache -d 'Disable cache reads' complete -c switchbot -l config -r -d 'Credential file path' +complete -c switchbot -l profile -r -d 'Named credential profile' +complete -c switchbot -l audit-log -d 'Append mutating commands to audit log' +complete -c switchbot -l audit-log-path -r -d 'Custom audit log file path' complete -c switchbot -s h -l help -d 'Show help' complete -c switchbot -s V -l version -d 'Show version' @@ -160,13 +247,23 @@ complete -c switchbot -n '__fish_use_subcommand' -a 'devices' -d 'List and co complete -c switchbot -n '__fish_use_subcommand' -a 'scenes' -d 'List and execute scenes' complete -c switchbot -n '__fish_use_subcommand' -a 'webhook' -d 'Manage webhook configuration' complete -c switchbot -n '__fish_use_subcommand' -a 'completion' -d 'Print a shell completion script' +complete -c switchbot -n '__fish_use_subcommand' -a 'mcp' -d 'Run the MCP server' +complete -c switchbot -n '__fish_use_subcommand' -a 'quota' -d 'Inspect local request quota' +complete -c switchbot -n '__fish_use_subcommand' -a 'catalog' -d 'Inspect the built-in device catalog' +complete -c switchbot -n '__fish_use_subcommand' -a 'cache' -d 'Inspect local caches' +complete -c switchbot -n '__fish_use_subcommand' -a 'events' -d 'Receive webhook or MQTT events' +complete -c switchbot -n '__fish_use_subcommand' -a 'doctor' -d 'Run self-checks' +complete -c switchbot -n '__fish_use_subcommand' -a 'schema' -d 'Export the device catalog as JSON' +complete -c switchbot -n '__fish_use_subcommand' -a 'history' -d 'View and replay audited commands' +complete -c switchbot -n '__fish_use_subcommand' -a 'plan' -d 'Validate and run batch plans' +complete -c switchbot -n '__fish_use_subcommand' -a 'capabilities' -d 'Print a machine-readable manifest' complete -c switchbot -n '__fish_use_subcommand' -a 'help' -d 'Show help' # config -complete -c switchbot -n '__fish_seen_subcommand_from config' -a 'set-token show' +complete -c switchbot -n '__fish_seen_subcommand_from config' -a 'set-token show list-profiles' # devices -complete -c switchbot -n '__fish_seen_subcommand_from devices' -a 'list status command types commands' +complete -c switchbot -n '__fish_seen_subcommand_from devices' -a 'list ls status command types commands describe batch watch explain expand meta' # scenes complete -c switchbot -n '__fish_seen_subcommand_from scenes' -a 'list execute' @@ -176,6 +273,24 @@ complete -c switchbot -n '__fish_seen_subcommand_from webhook' -a 'setup query u complete -c switchbot -n '__fish_seen_subcommand_from webhook; and __fish_seen_subcommand_from update' -l enable -d 'Enable the webhook' complete -c switchbot -n '__fish_seen_subcommand_from webhook; and __fish_seen_subcommand_from update' -l disable -d 'Disable the webhook' +# events +complete -c switchbot -n '__fish_seen_subcommand_from events' -a 'tail mqtt-tail' + +# quota +complete -c switchbot -n '__fish_seen_subcommand_from quota' -a 'status reset' + +# catalog +complete -c switchbot -n '__fish_seen_subcommand_from catalog' -a 'path show diff refresh' + +# cache +complete -c switchbot -n '__fish_seen_subcommand_from cache' -a 'show clear' + +# history +complete -c switchbot -n '__fish_seen_subcommand_from history' -a 'show replay' + +# plan +complete -c switchbot -n '__fish_seen_subcommand_from plan' -a 'schema validate run' + # completion complete -c switchbot -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish powershell' `; @@ -191,13 +306,19 @@ Register-ArgumentCompleter -Native -CommandName switchbot -ScriptBlock { $tokens = $commandAst.CommandElements | ForEach-Object { $_.ToString() } $count = $tokens.Count - $top = 'config','devices','scenes','webhook','completion','help' - $configSub = 'set-token','show' - $devicesSub = 'list','status','command','types','commands' + $top = 'config','devices','scenes','webhook','completion','mcp','quota','catalog','cache','events','doctor','schema','history','plan','capabilities','help' + $configSub = 'set-token','show','list-profiles' + $devicesSub = 'list','ls','status','command','types','commands','describe','batch','watch','explain','expand','meta' $scenesSub = 'list','execute' $webhookSub = 'setup','query','update','delete' + $eventsSub = 'tail','mqtt-tail' + $quotaSub = 'status','reset' + $catalogSub = 'path','show','diff','refresh' + $cacheSub = 'show','clear' + $historySub = 'show','replay' + $planSub = 'schema','validate','run' $shells = 'bash','zsh','fish','powershell' - $globalOpts = '--json','--verbose','-v','--dry-run','--timeout','--config','--help','-h','--version','-V' + $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' function _emit($values) { $values | @@ -211,6 +332,12 @@ Register-ArgumentCompleter -Native -CommandName switchbot -ScriptBlock { 'config' { if ($count -eq 3) { return _emit $configSub } } 'devices' { if ($count -eq 3) { return _emit $devicesSub } } 'scenes' { if ($count -eq 3) { return _emit $scenesSub } } + 'events' { if ($count -eq 3) { return _emit $eventsSub } } + 'quota' { if ($count -eq 3) { return _emit $quotaSub } } + 'catalog' { if ($count -eq 3) { return _emit $catalogSub } } + 'cache' { if ($count -eq 3) { return _emit $cacheSub } } + 'history' { if ($count -eq 3) { return _emit $historySub } } + 'plan' { if ($count -eq 3) { return _emit $planSub } } 'webhook' { if ($count -eq 3) { return _emit $webhookSub } if ($tokens[2] -eq 'update') { return _emit ('--enable','--disable' + $globalOpts) } diff --git a/src/commands/config.ts b/src/commands/config.ts index 5ef76c5..312603e 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import fs from 'node:fs'; import { execFileSync } from 'node:child_process'; +import { stringArg } from '../utils/arg-parsers.js'; import { saveConfig, showConfig, listProfiles } from '../config.js'; import { isJsonMode, printJson } from '../utils/output.js'; import chalk from 'chalk'; @@ -50,9 +51,9 @@ Obtain your token/secret from the SwitchBot mobile app: .description('Save token and secret (mode 0600). Use --profile to target a named profile.') .argument('[token]', 'API token; omit when using --from-env-file / --from-op') .argument('[secret]', 'API client secret; omit when using --from-env-file / --from-op') - .option('--from-env-file ', 'Read SWITCHBOT_TOKEN and SWITCHBOT_SECRET from a dotenv file') - .option('--from-op ', 'Read token via 1Password CLI (op read). Pair with --op-secret ') - .option('--op-secret ', '1Password reference for the secret, used with --from-op') + .option('--from-env-file ', 'Read SWITCHBOT_TOKEN and SWITCHBOT_SECRET from a dotenv file', stringArg('--from-env-file')) + .option('--from-op ', 'Read token via 1Password CLI (op read). Pair with --op-secret ', stringArg('--from-op')) + .option('--op-secret ', '1Password reference for the secret, used with --from-op', stringArg('--op-secret')) .addHelpText('after', ` Examples: $ switchbot config set-token diff --git a/src/commands/device-meta.ts b/src/commands/device-meta.ts index b885235..a8989f4 100644 --- a/src/commands/device-meta.ts +++ b/src/commands/device-meta.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { stringArg } from '../utils/arg-parsers.js'; import { handleError, isJsonMode, printJson, printTable, UsageError } from '../utils/output.js'; import { loadDeviceMeta, @@ -18,10 +19,10 @@ export function registerDevicesMetaCommand(devices: Command): void { .command('set') .description('Set local metadata for a device (alias, hide/show, notes)') .argument('', 'Target device ID') - .option('--alias ', 'Local alias for the device (used with --name flag)') + .option('--alias ', 'Local alias for the device (used with --name flag)', stringArg('--alias')) .option('--hide', 'Hide this device from "devices list"') .option('--show', 'Un-hide this device') - .option('--notes ', 'Freeform notes shown in "devices describe"') + .option('--notes ', 'Freeform notes shown in "devices describe"', stringArg('--notes')) .action((deviceId: string, options: { alias?: string; hide?: boolean; show?: boolean; notes?: string }) => { try { if (options.hide && options.show) { diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 3329150..0e5ec7b 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { enumArg, stringArg } from '../utils/arg-parsers.js'; import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; import { findCatalogEntry, getEffectiveCatalog, DeviceCatalogEntry } from '../devices/catalog.js'; @@ -25,6 +26,7 @@ import { registerDevicesMetaCommand } from './device-meta.js'; import { isDryRun } from '../utils/flags.js'; export function registerDevicesCommand(program: Command): void { + const COMMAND_TYPES = ['command', 'customize'] as const; const devices = program .command('devices') .description('Manage and control SwitchBot devices') @@ -52,6 +54,7 @@ Run any subcommand with --help for its own flags and examples. // switchbot devices list devices .command('list') + .alias('ls') .description('List all physical devices and IR remote devices on the account') .addHelpText('after', ` Default columns: deviceId, deviceName, type, category @@ -86,7 +89,7 @@ Examples: `) .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)') .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"') - .option('--filter ', 'Filter devices: "type=X", "name=X", "category=physical|ir", "room=X" (comma-separated key=value pairs)') + .option('--filter ', 'Filter devices: "type=X", "name=X", "category=physical|ir", "room=X" (comma-separated key=value pairs)', stringArg('--filter')) .action(async (options: { wide?: boolean; showHidden?: boolean; filter?: string }) => { try { const body = await fetchDeviceList(); @@ -211,8 +214,8 @@ Examples: .command('status') .description('Query the real-time status of a specific device') .argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)') - .option('--name ', 'Resolve device by fuzzy name instead of deviceId') - .option('--ids ', 'Comma-separated device IDs for batch status (incompatible with --name)') + .option('--name ', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name')) + .option('--ids ', 'Comma-separated device IDs for batch status (incompatible with --name)', stringArg('--ids')) .addHelpText('after', ` Status fields vary by device type. To discover them without a live call: @@ -304,10 +307,10 @@ Examples: .argument('[deviceId]', 'Target device ID (or use --name)') .argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean') .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).') - .option('--name ', 'Resolve device by fuzzy name instead of deviceId') - .option('--type ', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', 'command') + .option('--name ', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name')) + .option('--type ', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command') .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.') - .option('--idempotency-key ', 'Idempotency key for deduplication (60s window; same key replays cached result)') + .option('--idempotency-key ', 'Idempotency key for deduplication (60s window; same key replays cached result)', stringArg('--idempotency-key')) .addHelpText('after', ` ──────────────────────────────────────────────────────────────────────── For the full list of commands a specific device supports — and their @@ -378,7 +381,12 @@ Examples: } const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name); - const validation = validateCommand(deviceId, cmd, parameter, options.type); + if (!getCachedDevice(deviceId)) { + console.error( + `Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`, + ); + } + const validation = validateCommand(deviceId, cmd, parameter, options.type); if (!validation.ok) { const err = validation.error; if (isJsonMode()) { @@ -573,7 +581,7 @@ Examples: .command('describe') .description('Describe a device by ID: metadata + supported commands + status fields (1 API call)') .argument('[deviceId]', 'Target device ID (or use --name)') - .option('--name ', 'Resolve device by fuzzy name instead of deviceId') + .option('--name ', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name')) .option('--live', 'Also fetch live status values and merge them into capabilities (costs 1 extra API call)') .addHelpText('after', ` Makes a GET /v1.1/devices call to look up the device's type, then prints its diff --git a/src/commands/events.ts b/src/commands/events.ts index be83125..c363ad1 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import http from 'node:http'; import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; +import { intArg, stringArg } from '../utils/arg-parsers.js'; import { SwitchBotMqttClient } from '../mqtt/client.js'; import { fetchMqttCredential } from '../mqtt/credential.js'; import { tryLoadConfig } from '../config.js'; @@ -141,10 +142,10 @@ export function registerEventsCommand(program: Command): void { events .command('tail') .description('Run a local HTTP receiver and print incoming webhook events as JSONL') - .option('--port ', `Local port to listen on (default ${DEFAULT_PORT})`, String(DEFAULT_PORT)) - .option('--path

', `HTTP path to match (default "${DEFAULT_PATH}"; use "*" for all paths)`, DEFAULT_PATH) - .option('--filter ', 'Filter events, e.g. "deviceId=ABC123" or "type=Bot" (comma-separated)') - .option('--max ', 'Stop after N matching events (default: run until Ctrl-C)') + .option('--port ', `Local port to listen on (default ${DEFAULT_PORT})`, intArg('--port', { min: 1, max: 65535 }), String(DEFAULT_PORT)) + .option('--path

', `HTTP path to match (default "${DEFAULT_PATH}"; use "*" for all paths)`, stringArg('--path'), DEFAULT_PATH) + .option('--filter ', 'Filter events, e.g. "deviceId=ABC123" or "type=Bot" (comma-separated)', stringArg('--filter')) + .option('--max ', 'Stop after N matching events (default: run until Ctrl-C)', intArg('--max', { min: 1 })) .addHelpText( 'after', ` @@ -224,25 +225,25 @@ Examples: events .command('mqtt-tail') .description('Subscribe to SwitchBot MQTT shadow events and stream them as JSONL') - .option('--topic ', 'MQTT topic filter (default: SwitchBot shadow topic from credential)') - .option('--max ', 'Stop after N events (default: run until Ctrl-C)') + .option('--topic ', 'MQTT topic filter (default: SwitchBot shadow topic from credential)', stringArg('--topic')) + .option('--max ', 'Stop after N events (default: run until Ctrl-C)', intArg('--max', { min: 1 })) .option( '--sink ', 'Output sink: stdout (default), file, webhook, openclaw, telegram, homeassistant (repeatable)', (val: string, prev: string[]) => [...prev, val], [] as string[], ) - .option('--sink-file ', 'File path for file sink') - .option('--webhook-url ', 'Webhook URL for webhook sink') - .option('--openclaw-url ', 'OpenClaw gateway URL (default: http://localhost:18789)') - .option('--openclaw-token ', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)') - .option('--openclaw-model ', 'OpenClaw agent model ID to route events to') - .option('--telegram-token ', 'Telegram bot token (or env TELEGRAM_TOKEN)') - .option('--telegram-chat ', 'Telegram chat/channel ID to send messages to') - .option('--ha-url ', 'Home Assistant base URL (e.g. http://homeassistant.local:8123)') - .option('--ha-token ', 'HA long-lived access token (for REST event API)') - .option('--ha-webhook-id ', 'HA webhook ID (no auth; takes priority over --ha-token)') - .option('--ha-event-type ', 'HA event type for REST API (default: switchbot_event)') + .option('--sink-file ', 'File path for file sink', stringArg('--sink-file')) + .option('--webhook-url ', 'Webhook URL for webhook sink', stringArg('--webhook-url')) + .option('--openclaw-url ', 'OpenClaw gateway URL (default: http://localhost:18789)', stringArg('--openclaw-url')) + .option('--openclaw-token ', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)', stringArg('--openclaw-token')) + .option('--openclaw-model ', 'OpenClaw agent model ID to route events to', stringArg('--openclaw-model')) + .option('--telegram-token ', 'Telegram bot token (or env TELEGRAM_TOKEN)', stringArg('--telegram-token')) + .option('--telegram-chat ', 'Telegram chat/channel ID to send messages to', stringArg('--telegram-chat')) + .option('--ha-url ', 'Home Assistant base URL (e.g. http://homeassistant.local:8123)', stringArg('--ha-url')) + .option('--ha-token ', 'HA long-lived access token (for REST event API)', stringArg('--ha-token')) + .option('--ha-webhook-id ', 'HA webhook ID (no auth; takes priority over --ha-token)', stringArg('--ha-webhook-id')) + .option('--ha-event-type ', 'HA event type for REST API (default: switchbot_event)', stringArg('--ha-event-type')) .addHelpText( 'after', ` @@ -396,10 +397,20 @@ Examples: } }); + let mqttFailed = false; const unsubState = client.onStateChange((state) => { if (!isJsonMode()) { console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`); } + if (state === 'failed') { + mqttFailed = true; + if (!isJsonMode()) { + console.error( + 'MQTT connection failed permanently (credential expired or reconnect exhausted) — exiting.', + ); + } + ac.abort(); + } }); await client.connect(); @@ -422,6 +433,11 @@ Examples: process.once('SIGTERM', cleanup); ac.signal.addEventListener('abort', cleanup, { once: true }); }); + + if (mqttFailed) { + // Surface as a runtime error so supervisors (pm2, systemd) can restart. + process.exit(1); + } } catch (error) { handleError(error); } diff --git a/src/commands/expand.ts b/src/commands/expand.ts index e042af9..3814178 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { intArg, stringArg } from '../utils/arg-parsers.js'; import { handleError, isJsonMode, printJson, UsageError } from '../utils/output.js'; import { getCachedDevice } from '../devices/cache.js'; import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js'; @@ -98,15 +99,15 @@ export function registerExpandCommand(devices: Command): void { .description('Send a command with semantic flags instead of raw positional parameters') .argument('[deviceId]', 'Target device ID from "devices list" (or use --name)') .argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)') - .option('--name ', 'Resolve device by fuzzy name instead of deviceId') - .option('--temp ', 'AC setAll: temperature in Celsius (16-30)') - .option('--mode ', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary') - .option('--fan ', 'AC setAll: fan speed auto|low|mid|high') - .option('--power ', 'AC setAll: on|off') - .option('--position ', 'Curtain setPosition: 0-100 (0=open, 100=closed)') - .option('--direction

', 'Blind Tilt setPosition: up|down') - .option('--angle ', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)') - .option('--channel ', 'Relay Switch 2 setMode: channel 1 or 2') + .option('--name ', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name')) + .option('--temp ', 'AC setAll: temperature in Celsius (16-30)', intArg('--temp', { min: 16, max: 30 })) + .option('--mode ', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary', stringArg('--mode')) + .option('--fan ', 'AC setAll: fan speed auto|low|mid|high', stringArg('--fan')) + .option('--power ', 'AC setAll: on|off', stringArg('--power')) + .option('--position ', 'Curtain setPosition: 0-100 (0=open, 100=closed)', intArg('--position', { min: 0, max: 100 })) + .option('--direction ', 'Blind Tilt setPosition: up|down', stringArg('--direction')) + .option('--angle ', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)', intArg('--angle', { min: 0, max: 100 })) + .option('--channel ', 'Relay Switch 2 setMode: channel 1 or 2', intArg('--channel', { min: 1, max: 2 })) .option('--yes', 'Confirm destructive commands') .addHelpText('after', ` Translates semantic flags into the wire parameter format, then sends the command. diff --git a/src/commands/history.ts b/src/commands/history.ts index cc4ca58..ab59cb6 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import path from 'node:path'; import os from 'node:os'; +import { intArg, stringArg } from '../utils/arg-parsers.js'; import { printJson, isJsonMode, handleError } from '../utils/output.js'; import { readAudit, type AuditEntry } from '../utils/audit.js'; import { executeCommand } from '../lib/devices.js'; @@ -25,8 +26,8 @@ Examples: history .command('show') .description('Print recent audit entries') - .option('--file ', `Path to the audit log (default ${DEFAULT_AUDIT})`) - .option('--limit ', 'Show only the last N entries') + .option('--file ', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file')) + .option('--limit ', 'Show only the last N entries', intArg('--limit', { min: 1 })) .action((options: { file?: string; limit?: string }) => { const file = options.file ?? DEFAULT_AUDIT; const entries = readAudit(file); @@ -59,7 +60,7 @@ Examples: .command('replay') .description('Re-run a recorded command by its 1-indexed position') .argument('', 'Entry index (1 = oldest; as shown by "history show")') - .option('--file ', `Path to the audit log (default ${DEFAULT_AUDIT})`) + .option('--file ', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file')) .addHelpText('after', ` Dry-run-honouring: pass --dry-run on the parent command to preview without sending the actual call. Errors from the recorded entry are NOT replayed — diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index c47aea4..b5ebade 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; +import { intArg, stringArg } from '../utils/arg-parsers.js'; import { handleError, isJsonMode } from '../utils/output.js'; import { fetchDeviceList, @@ -595,11 +596,11 @@ Inspect locally: mcp .command('serve') .description('Start the MCP server on stdio (default) or HTTP (--port)') - .option('--port ', 'Listen on HTTP instead of stdio (Streamable HTTP transport)') - .option('--bind ', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', '127.0.0.1') - .option('--auth-token ', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)') - .option('--cors-origin ', 'Allowed CORS origin(s) for HTTP (repeatable)') - .option('--rate-limit ', 'Max requests per minute per profile (default 60)', '60') + .option('--port ', 'Listen on HTTP instead of stdio (Streamable HTTP transport)', intArg('--port', { min: 1, max: 65535 })) + .option('--bind ', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', stringArg('--bind'), '127.0.0.1') + .option('--auth-token ', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)', stringArg('--auth-token')) + .option('--cors-origin ', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin')) + .option('--rate-limit ', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60') .action(async (options: { port?: string; bind?: string; authToken?: string; corsOrigin?: string | string[]; rateLimit?: string }) => { try { if (options.port) { diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 75aecf7..eb0674f 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { enumArg, stringArg } from '../utils/arg-parsers.js'; import { printJson } from '../utils/output.js'; import { getEffectiveCatalog, type CommandSpec, type DeviceCatalogEntry } from '../devices/catalog.js'; @@ -47,6 +48,8 @@ function toSchemaCommand(c: CommandSpec) { } export function registerSchemaCommand(program: Command): void { + const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'] as const; + const CATEGORIES = ['physical', 'ir'] as const; const schema = program .command('schema') .description('Export the device catalog as structured JSON (for agent prompts / tooling)'); @@ -54,9 +57,9 @@ export function registerSchemaCommand(program: Command): void { schema .command('export') .description('Print the full catalog as structured JSON (one object per type)') - .option('--type ', 'Restrict to a single device type (e.g. "Strip Light")') - .option('--role ', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other') - .option('--category ', 'Restrict to "physical" or "ir"') + .option('--type ', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type')) + .option('--role ', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES)) + .option('--category ', 'Restrict to "physical" or "ir"', enumArg('--category', CATEGORIES)) .addHelpText('after', ` Output is always JSON (this command ignores --format). The output is a catalog export — not a formal JSON Schema standard document — suitable for diff --git a/src/commands/watch.ts b/src/commands/watch.ts index 91879df..1192dd8 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -3,6 +3,7 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output. import { fetchDeviceStatus } from '../lib/devices.js'; import { getCachedDevice } from '../devices/cache.js'; import { parseDurationToMs, getFields } from '../utils/flags.js'; +import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js'; import { createClient } from '../api/client.js'; import { resolveDeviceId } from '../utils/name-resolver.js'; @@ -72,13 +73,14 @@ export function registerWatchCommand(devices: Command): void { .command('watch') .description('Poll device status on an interval and emit field-level changes (JSONL)') .argument('[deviceId...]', 'One or more deviceIds to watch (or use --name for one device)') - .option('--name ', 'Resolve one device by fuzzy name (combined with any positional IDs)') + .option('--name ', 'Resolve one device by fuzzy name (combined with any positional IDs)', stringArg('--name')) .option( '--interval ', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, + durationArg('--interval'), '30s', ) - .option('--max ', 'Stop after N ticks (default: run until Ctrl-C)') + .option('--max ', 'Stop after N ticks (default: run until Ctrl-C)', intArg('--max', { min: 1 })) .option('--include-unchanged', 'Emit a tick even when no field changed') .addHelpText( 'after', diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts index e49b4e3..78c8d17 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { stringArg } from '../utils/arg-parsers.js'; import { createClient } from '../api/client.js'; import { printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import chalk from 'chalk'; @@ -68,7 +69,7 @@ Example: webhook .command('query') .description('Query webhook configuration') - .option('--details ', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL') + .option('--details ', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL', stringArg('--details')) .addHelpText('after', ` Without --details, lists all configured webhook URLs. With --details, prints enable/deviceList/createTime/lastUpdateTime for the given URL. diff --git a/src/config.ts b/src/config.ts index d9355f8..596b8a0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -108,7 +108,7 @@ export function showConfig(): void { if (envToken && envSecret) { console.log('Credential source: environment variables'); - console.log(`token : ${envToken}`); + console.log(`token : ${maskCredential(envToken)}`); console.log(`secret: ${maskSecret(envSecret)}`); return; } @@ -123,13 +123,18 @@ export function showConfig(): void { const raw = fs.readFileSync(file, 'utf-8'); const cfg = JSON.parse(raw) as SwitchBotConfig; console.log(`Credential source: ${file}`); - console.log(`token : ${cfg.token}`); + console.log(`token : ${maskCredential(cfg.token)}`); console.log(`secret: ${maskSecret(cfg.secret)}`); } catch { console.error('Failed to read config file'); } } +function maskCredential(token: string): string { + if (token.length <= 8) return '*'.repeat(Math.max(4, token.length)); + return token.slice(0, 4) + '*'.repeat(token.length - 8) + token.slice(-4); +} + function maskSecret(secret: string): string { if (secret.length <= 4) return '****'; return secret.slice(0, 2) + '*'.repeat(secret.length - 4) + secret.slice(-2); diff --git a/src/index.ts b/src/index.ts index 6291f14..f1e8a45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node -import { Command, CommanderError } from 'commander'; +import { Command, CommanderError, InvalidArgumentError } from 'commander'; import { createRequire } from 'node:module'; +import { intArg, stringArg, enumArg } from './utils/arg-parsers.js'; +import { parseDurationToMs } from './utils/flags.js'; import { registerConfigCommand } from './commands/config.js'; import { registerDevicesCommand } from './commands/devices.js'; import { registerScenesCommand } from './commands/scenes.js'; @@ -22,26 +24,48 @@ const { version: pkgVersion } = require('../package.json') as { version: string const program = new Command(); +// Top-level subcommand names. Used by stringArg to produce clearer errors when +// a value is omitted and the next argv token turns out to be a subcommand name. +const TOP_LEVEL_COMMANDS = [ + 'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp', + 'quota', 'catalog', 'cache', 'events', 'doctor', 'schema', + 'history', 'plan', 'capabilities', +] as const; + +const cacheModeArg = (value: string): string => { + if (value.startsWith('-')) { + throw new InvalidArgumentError( + `--cache requires a mode value, got "${value}". ` + + `Valid: "off", "auto", or a duration like "5m", "1h". Use --cache= if needed.`, + ); + } + if (value === 'off' || value === 'auto') return value; + if (parseDurationToMs(value) !== null) return value; + throw new InvalidArgumentError( + `--cache must be "off", "auto", or a duration like "30s"/"5m"/"1h" (got "${value}")`, + ); +}; + program .name('switchbot') .description('Command-line tool for SwitchBot API v1.1') .version(pkgVersion) .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)') - .option('--format ', 'Output format: table (default), json, jsonl, tsv, yaml, id') - .option('--fields ', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)') + .option('--format ', 'Output format: table (default), json, jsonl, tsv, yaml, id', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id'])) + .option('--fields ', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS })) .option('-v, --verbose', 'Log HTTP request/response details to stderr') .option('--dry-run', 'Print mutating requests without sending them (GETs still execute)') - .option('--timeout ', 'HTTP request timeout in milliseconds (default: 30000)') - .option('--retry-on-429 ', 'Max 429 retries before surfacing the error (default: 3)') - .option('--backoff ', 'Backoff strategy for retries: "linear" or "exponential" (default)') + .option('--timeout ', 'HTTP request timeout in milliseconds (default: 30000)', intArg('--timeout', { min: 1 })) + .option('--retry-on-429 ', 'Max 429 retries before surfacing the error (default: 3)', intArg('--retry-on-429', { min: 0 })) + .option('--backoff ', 'Backoff strategy for retries: "linear" or "exponential" (default)', enumArg('--backoff', ['linear', 'exponential'])) .option('--no-retry', 'Disable 429 retries entirely (equivalent to --retry-on-429 0)') .option('--no-quota', 'Disable the local ~/.switchbot/quota.json counter for this run') - .option('--cache ', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)') + .option('--cache ', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)', cacheModeArg) .option('--no-cache', 'Disable cache reads (equivalent to --cache off)') - .option('--config ', 'Override credential file location (default: ~/.switchbot/config.json)') - .option('--profile ', 'Use a named profile: ~/.switchbot/profiles/.json') + .option('--config ', 'Override credential file location (default: ~/.switchbot/config.json)', stringArg('--config', { disallow: TOP_LEVEL_COMMANDS })) + .option('--profile ', 'Use a named profile: ~/.switchbot/profiles/.json', stringArg('--profile', { disallow: TOP_LEVEL_COMMANDS })) .option('--audit-log', 'Append every mutating command to JSONL audit log (default path: ~/.switchbot/audit.log)') - .option('--audit-log-path ', 'Custom audit log file path; use together with --audit-log') + .option('--audit-log-path ', 'Custom audit log file path; use together with --audit-log', stringArg('--audit-log-path', { disallow: TOP_LEVEL_COMMANDS })) .showHelpAfterError('(run with --help to see usage)') .showSuggestionAfterError(); @@ -101,24 +125,34 @@ Discovery: Docs: https://github.com/OpenWonderLabs/SwitchBotAPI `); -// Map commander usage errors (unknown option, missing argument, etc.) to exit code 2. -program.exitOverride((err: CommanderError) => { - // --help and --version print to stdout and exit 0 +// Map commander usage errors (unknown option, missing argument, argParser +// InvalidArgumentError, etc.) to exit code 2. Commander's exitOverride is +// per-command: subcommand errors won't bubble to the root override, so walk +// every registered command and apply the same handler. +const usageExitHandler = (err: CommanderError): never => { if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') { process.exit(0); } - // Everything else from commander (unknown option, missing argument, - // invalid choice, conflicting options, unknown command) is a usage error. process.exit(2); -}); +}; + +function applyExitOverride(cmd: Command): void { + cmd.exitOverride(usageExitHandler); + cmd.commands.forEach(applyExitOverride); +} +applyExitOverride(program); try { await program.parseAsync(); } catch (err) { - // exitOverride already handled CommanderErrors; anything that escapes is a - // runtime error (should be rare since actions use handleError). + // Subcommand-level CommanderErrors (e.g. InvalidArgumentError from an + // argParser on a subcommand option) don't always hit the root exitOverride. + // Mirror the root mapping so all usage errors surface as exit 2. if (err instanceof CommanderError) { - process.exit(err.exitCode ?? 2); + if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') { + process.exit(0); + } + process.exit(2); } throw err; } diff --git a/src/utils/arg-parsers.ts b/src/utils/arg-parsers.ts new file mode 100644 index 0000000..e0af82a --- /dev/null +++ b/src/utils/arg-parsers.ts @@ -0,0 +1,88 @@ +import { InvalidArgumentError } from 'commander'; +import { parseDurationToMs } from './flags.js'; + +/** + * Commander argParser callbacks that fail fast when a required-value flag + * swallows the next token (another flag, a subcommand name, etc.) — the + * default Commander behavior is to take the next argv token verbatim. + * + * Use `--flag=` form to pass values that legitimately start with `--`. + */ + +export function intArg( + flagName: string, + opts?: { min?: number; max?: number }, +): (value: string) => string { + return (value: string) => { + if (value.startsWith('-')) { + throw new InvalidArgumentError( + `${flagName} requires a numeric value, got "${value}". ` + + `Did you forget a value? Use ${flagName}= if the value really starts with "-".`, + ); + } + const n = Number(value); + if (!Number.isInteger(n)) { + throw new InvalidArgumentError(`${flagName} must be an integer (got "${value}")`); + } + if (opts?.min !== undefined && n < opts.min) { + throw new InvalidArgumentError(`${flagName} must be >= ${opts.min} (got "${value}")`); + } + if (opts?.max !== undefined && n > opts.max) { + throw new InvalidArgumentError(`${flagName} must be <= ${opts.max} (got "${value}")`); + } + return String(n); + }; +} + +export function durationArg(flagName: string): (value: string) => string { + return (value: string) => { + if (value.startsWith('-')) { + throw new InvalidArgumentError( + `${flagName} requires a duration value, got "${value}". ` + + `Use ${flagName}= if the value really starts with "-".`, + ); + } + const ms = parseDurationToMs(value); + if (ms === null) { + throw new InvalidArgumentError( + `${flagName} must look like "30s", "1m", "500ms", "1h" (got "${value}")`, + ); + } + return value; + }; +} + +export function stringArg( + flagName: string, + opts?: { disallow?: readonly string[] }, +): (value: string) => string { + return (value: string) => { + if (value.startsWith('--')) { + throw new InvalidArgumentError( + `${flagName} requires a value. "${value}" looks like another option — ` + + `did you forget the value? Use ${flagName}= if your value really starts with "--".`, + ); + } + if (opts?.disallow?.includes(value)) { + throw new InvalidArgumentError( + `${flagName} requires a value but got "${value}", which is a subcommand name. ` + + `Did you forget the value? Use ${flagName}= or put ${flagName} after the subcommand.`, + ); + } + return value; + }; +} + +export function enumArg( + flagName: string, + allowed: readonly string[], +): (value: string) => string { + return (value: string) => { + if (!allowed.includes(value)) { + throw new InvalidArgumentError( + `${flagName} must be one of: ${allowed.join(', ')} (got "${value}")`, + ); + } + return value; + }; +} diff --git a/tests/commands/cache.test.ts b/tests/commands/cache.test.ts index c13b8a8..87ee7b0 100644 --- a/tests/commands/cache.test.ts +++ b/tests/commands/cache.test.ts @@ -137,7 +137,7 @@ describe('cache clear', () => { it('rejects unknown --key with exit 2', async () => { const result = await runCli(registerCacheCommand, ['cache', 'clear', '--key', 'bogus']); expect(result.exitCode).toBe(2); - expect(result.stderr.join('\n')).toMatch(/Unknown --key/); + expect(result.stderr.join('\n')).toMatch(/--key.*must be one of/i); }); it('--json reports which caches were cleared', async () => { diff --git a/tests/commands/catalog.test.ts b/tests/commands/catalog.test.ts index d795628..d6478cb 100644 --- a/tests/commands/catalog.test.ts +++ b/tests/commands/catalog.test.ts @@ -125,7 +125,7 @@ describe('catalog show', () => { 'bogus', ]); expect(exitCode).toBe(2); - expect(stderr.join('\n')).toContain('Unknown --source'); + expect(stderr.join('\n')).toMatch(/--source.*must be one of/i); }); it('exits 2 when the type does not exist', async () => { diff --git a/tests/commands/completion.test.ts b/tests/commands/completion.test.ts index 855c309..462c086 100644 --- a/tests/commands/completion.test.ts +++ b/tests/commands/completion.test.ts @@ -24,6 +24,9 @@ describe('completion command', () => { const out = written.join(''); expect(out).toContain('_switchbot_completion'); expect(out).toContain('complete -F _switchbot_completion switchbot'); + expect(out).toContain('mcp quota catalog cache events doctor schema history plan capabilities'); + expect(out).toContain('--profile'); + expect(out).toContain('--audit-log-path'); }); it('prints a zsh completion script', async () => { @@ -40,6 +43,9 @@ describe('completion command', () => { const out = written.join(''); expect(out).toContain('complete -c switchbot'); expect(out).toContain('__fish_use_subcommand'); + expect(out).toContain("-a 'events'"); + expect(out).toContain('-l profile'); + expect(out).toContain('-l audit-log-path'); }); it('prints a powershell completion script', async () => { @@ -48,6 +54,9 @@ describe('completion command', () => { const out = written.join(''); expect(out).toContain('Register-ArgumentCompleter'); expect(out).toContain('switchbot'); + expect(out).toContain("'events'"); + expect(out).toContain("'--profile'"); + expect(out).toContain("'--audit-log-path'"); }); it('accepts "pwsh" as an alias for powershell', async () => { diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index d19f02b..4d521b3 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -145,6 +145,13 @@ describe('devices command', () => { expect(out).toContain('1 IR remote'); }); + it('accepts the "ls" alias and behaves like "list"', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'ls']); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/devices'); + expect(res.stdout.join('\n')).toContain('ABC123'); + }); + it('shows family and room columns with --wide', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); const res = await runCli(registerDevicesCommand, ['devices', 'list', '--wide']); @@ -626,6 +633,24 @@ describe('devices command', () => { expect(res.exitCode).toBe(1); expect(res.stderr.join('\n')).toContain('Device offline'); }); + + it('prints a soft cache-miss hint when the device is not in the local cache', async () => { + const res = await runCmd('turnOn'); + expect(res.stderr.join('\n')).toMatch( + /not in the local cache.*switchbot devices list/i, + ); + }); + + it('does not print the cache-miss hint when the device is in the local cache', async () => { + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: DID, deviceName: 'Cached Bot', deviceType: 'Bot', hubDeviceId: 'HUB-1', enableCloudService: true }, + ], + infraredRemoteList: [], + }); + const res = await runCmd('turnOn'); + expect(res.stderr.join('\n')).not.toMatch(/not in the local cache/i); + }); }); // ===================================================================== diff --git a/tests/commands/events.test.ts b/tests/commands/events.test.ts index cb877fd..bb14a3b 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -10,7 +10,9 @@ import { runCli } from '../helpers/cli.js'; // --------------------------------------------------------------------------- const mqttMock = vi.hoisted(() => ({ messageHandler: null as ((topic: string, payload: Buffer) => void) | null, + stateHandler: null as ((state: string) => void) | null, connectShouldFireMessage: false, + connectShouldFireState: null as string | null, instance: null as { connect: ReturnType; disconnect: ReturnType; @@ -30,6 +32,12 @@ vi.mock('../../src/mqtt/client.js', () => { } }, 0); } + if (mqttMock.connectShouldFireState) { + const state = mqttMock.connectShouldFireState; + setTimeout(() => { + if (mqttMock.stateHandler) mqttMock.stateHandler(state); + }, 0); + } }), disconnect: vi.fn().mockResolvedValue(undefined), subscribe: vi.fn(), @@ -37,7 +45,10 @@ vi.mock('../../src/mqtt/client.js', () => { mqttMock.messageHandler = handler; return () => { mqttMock.messageHandler = null; }; }), - onStateChange: vi.fn(() => () => {}), + onStateChange: vi.fn((handler: (state: string) => void) => { + mqttMock.stateHandler = handler; + return () => { mqttMock.stateHandler = null; }; + }), }; mqttMock.instance = inst; return inst; @@ -241,7 +252,9 @@ const mockCredential = { describe('events mqtt-tail', () => { beforeEach(() => { mqttMock.messageHandler = null; + mqttMock.stateHandler = null; mqttMock.connectShouldFireMessage = false; + mqttMock.connectShouldFireState = null; vi.mocked(fetchMqttCredential).mockResolvedValue(mockCredential); vi.mocked(tryLoadConfig).mockReturnValue({ token: 'test-token', secret: 'test-secret' }); }); @@ -286,4 +299,20 @@ describe('events mqtt-tail', () => { const res = await runCli(registerEventsCommand, ['events', 'mqtt-tail', '--max', '0']); expect(res.exitCode).toBe(2); }); + + it('exits 2 when --max swallows "--help" (token-swallow regression)', async () => { + const res = await runCli(registerEventsCommand, ['events', 'mqtt-tail', '--max', '--help']); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/--max.*numeric value/i); + }); + + it('exits 1 and stops cleanly when MQTT state transitions to "failed"', async () => { + // Simulates the real-world scenario where the MQTT credential expires and + // reconnect exhausts — the loop previously hung until Node killed it with + // exit code 13 (unsettled top-level await). Now it aborts and exits 1. + mqttMock.connectShouldFireState = 'failed'; + const res = await runCli(registerEventsCommand, ['events', 'mqtt-tail']); + expect(res.exitCode).toBe(1); + expect(res.stderr.join('\n')).toMatch(/failed permanently|credential expired|reconnect exhausted/i); + }); }); diff --git a/tests/commands/expand.test.ts b/tests/commands/expand.test.ts index e99a10d..28f36af 100644 --- a/tests/commands/expand.test.ts +++ b/tests/commands/expand.test.ts @@ -51,7 +51,21 @@ describe('devices expand', () => { apiMock.__instance.get.mockReset(); apiMock.__instance.post.mockReset(); apiMock.createClient.mockReset(); - apiMock.createClient.mockImplementation(() => apiMock.__instance); + apiMock.createClient.mockImplementation(() => { + // Mirror the production client's dry-run interceptor: when --dry-run is + // in argv, mutating calls throw DryRunSignal without touching the + // underlying spy (matching the test's expectation that post is never + // *called through* to the transport in dry-run mode). + if (process.argv.includes('--dry-run')) { + return { + get: apiMock.__instance.get, + post: async (url: string) => { + throw new apiMock.DryRunSignal('POST', url); + }, + }; + } + return apiMock.__instance; + }); apiMock.__instance.post.mockResolvedValue({ data: { body: {} } }); updateCacheFromDeviceList(sampleBody); }); @@ -94,7 +108,7 @@ describe('devices expand', () => { '--temp', '99', '--mode', 'cool', '--fan', 'low', '--power', 'on', ]); expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toContain('16 and 30'); + expect(res.stderr.join('\n')).toMatch(/--temp.*(<= 30|between 16 and 30)/i); }); it('AC: rejects unknown mode', async () => { @@ -167,7 +181,7 @@ describe('devices expand', () => { '--channel', '3', '--mode', 'edge', ]); expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toContain('1 or 2'); + expect(res.stderr.join('\n')).toMatch(/--channel.*(<= 2|1 or 2)/i); }); it('dry-run does not send the command', async () => { diff --git a/tests/commands/history.test.ts b/tests/commands/history.test.ts index 741a8f9..2909549 100644 --- a/tests/commands/history.test.ts +++ b/tests/commands/history.test.ts @@ -50,6 +50,14 @@ describe('history command', () => { } describe('show', () => { + it('exits 2 when --file swallows "--help"', async () => { + const res = await runCli(registerHistoryCommand, [ + 'history', 'show', '--file', '--help', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/--file .* looks like another option/i); + }); + it('prints (no entries) when the log does not exist', async () => { const res = await runCli(registerHistoryCommand, [ 'history', 'show', '--file', auditFile, diff --git a/tests/commands/schema.test.ts b/tests/commands/schema.test.ts index 8611454..e22692a 100644 --- a/tests/commands/schema.test.ts +++ b/tests/commands/schema.test.ts @@ -66,10 +66,16 @@ describe('schema export', () => { } }); - it('--role returns empty types[] for an unknown role', async () => { + it('--role rejects an unknown role with exit 2', async () => { const res = await runCli(registerSchemaCommand, ['schema', 'export', '--role', 'nonexistent']); - const parsed = JSON.parse(res.stdout.join('')).data; - expect(parsed.types).toEqual([]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/--role .* must be one of/i); + }); + + it('exits 2 when --role swallows "--help"', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export', '--role', '--help']); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/--role .* must be one of/i); }); it('schema export includes description on every type', async () => { diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index 0b97199..094272c 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -103,7 +103,7 @@ describe('devices watch', () => { 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '0', ]); expect(res.exitCode).toBe(2); - expect(res.stderr.join('\n')).toMatch(/Invalid --max/); + expect(res.stderr.join('\n')).toMatch(/--max/); }); it('emits one JSONL event per device on first tick with from:null (--max=1)', async () => { @@ -237,4 +237,12 @@ describe('devices watch', () => { expect(res.exitCode).toBe(2); expect(res.stderr.join('\n')).toMatch(/deviceId.*--name|--name.*deviceId/i); }); + + it('exits 2 when --interval swallows a subcommand name (token-swallow regression)', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'watch', 'BOT1', '--interval', 'devices', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/--interval.*(duration|look like)/i); + }); }); diff --git a/tests/commands/webhook.test.ts b/tests/commands/webhook.test.ts index f8abdf1..3e78e8d 100644 --- a/tests/commands/webhook.test.ts +++ b/tests/commands/webhook.test.ts @@ -109,6 +109,12 @@ describe('webhook command', () => { }); describe('query --details', () => { + it('exits 2 when --details swallows "--help"', async () => { + const res = await runCli(registerWebhookCommand, ['webhook', 'query', '--details', '--help']); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/--details .* looks like another option/i); + }); + it('POSTs queryDetails with the URL and prints key-value rows', async () => { apiMock.__instance.post.mockResolvedValue({ data: { diff --git a/tests/config.test.ts b/tests/config.test.ts index 180a1e1..8cd8746 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -151,7 +151,8 @@ describe('config', () => { showConfig(); const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); expect(output).toContain('Credential source: environment variables'); - expect(output).toContain('token : env-token'); + expect(output).toMatch(/token : env-\*+oken/); + expect(output).not.toContain('env-token'); expect(output).toContain('ab****gh'); expect(output).not.toContain('abcdefgh'); }); @@ -164,7 +165,8 @@ describe('config', () => { showConfig(); const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); expect(output).toContain(`Credential source: ${CONFIG_FILE}`); - expect(output).toContain('token : file-token'); + expect(output).toMatch(/token : file\*+oken/); + expect(output).not.toContain('file-token'); expect(output).toMatch(/secret: lo\*+ue/); }); diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index 13bf65f..98faa73 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -20,9 +20,26 @@ export async function runCli( ): Promise { const program = new Command(); program.exitOverride(); + // Mirror the global options declared on the real root program so subcommand + // tests can use `--dry-run`, `--verbose`, etc. without Commander rejecting + // them as unknown-option before the action runs. Values here do not need + // argParser validation — the tests don't exercise those paths. program.option('--json', 'Output results in JSON format'); program.option('--format ', 'Output format'); program.option('--fields ', 'Column filter'); + program.option('-v, --verbose'); + program.option('--dry-run'); + program.option('--timeout '); + program.option('--retry-on-429 '); + program.option('--backoff '); + program.option('--no-retry'); + program.option('--no-quota'); + program.option('--cache '); + program.option('--no-cache'); + program.option('--config '); + program.option('--profile '); + program.option('--audit-log'); + program.option('--audit-log-path '); program.configureOutput({ writeOut: (str) => stdout.push(stripTrailingNewline(str)), writeErr: (str) => stderr.push(stripTrailingNewline(str)), @@ -61,7 +78,16 @@ export async function runCli( (typeof errAsCommander.exitCode === 'number' && typeof errAsCommander.code === 'string'); if (!isInternalExit && !isCommanderExit) throw err; if (isCommanderExit && exitCode === null) { - exitCode = errAsCommander.exitCode ?? 1; + // Mirror production exitOverride in src/index.ts: non-help/version + // Commander errors surface as usage errors (exit 2). + if ( + errAsCommander.code === 'commander.helpDisplayed' || + errAsCommander.code === 'commander.version' + ) { + exitCode = 0; + } else { + exitCode = 2; + } } } finally { process.argv = originalArgv; diff --git a/tests/utils/arg-parsers.test.ts b/tests/utils/arg-parsers.test.ts new file mode 100644 index 0000000..3632b1a --- /dev/null +++ b/tests/utils/arg-parsers.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { InvalidArgumentError } from 'commander'; +import { intArg, durationArg, stringArg, enumArg } from '../../src/utils/arg-parsers.js'; + +describe('intArg', () => { + const parse = intArg('--max'); + + it('accepts a plain positive integer', () => { + expect(parse('5')).toBe('5'); + expect(parse('30000')).toBe('30000'); + expect(parse('0')).toBe('0'); + }); + + it('rejects values starting with "-" (tokens that look like flags)', () => { + expect(() => parse('-5')).toThrow(InvalidArgumentError); + expect(() => parse('-5')).toThrow(/requires a numeric value/); + expect(() => parse('--help')).toThrow(InvalidArgumentError); + expect(() => parse('--help')).toThrow(/requires a numeric value/); + }); + + it('rejects non-numeric strings', () => { + expect(() => parse('abc')).toThrow(/must be an integer/); + expect(() => parse('5.5')).toThrow(/must be an integer/); + expect(() => parse('1e2abc')).toThrow(/must be an integer/); + }); + + it('enforces min / max bounds when provided', () => { + const bounded = intArg('--port', { min: 1, max: 65535 }); + expect(bounded('8080')).toBe('8080'); + expect(() => bounded('0')).toThrow(/>= 1/); + expect(() => bounded('70000')).toThrow(/<= 65535/); + }); + + it('rejects values that look like subcommand names', () => { + // `switchbot --timeout devices list` — Commander would normally swallow + // "devices" as the --timeout value; argParser must catch it. + expect(() => parse('devices')).toThrow(/must be an integer/); + }); +}); + +describe('durationArg', () => { + const parse = durationArg('--interval'); + + it('accepts valid durations', () => { + expect(parse('30s')).toBe('30s'); + expect(parse('500ms')).toBe('500ms'); + expect(parse('1m')).toBe('1m'); + expect(parse('1h')).toBe('1h'); + expect(parse('1000')).toBe('1000'); // bare ms + }); + + it('rejects values starting with "-"', () => { + expect(() => parse('--help')).toThrow(/requires a duration value/); + expect(() => parse('-5s')).toThrow(/requires a duration value/); + }); + + it('rejects malformed durations', () => { + expect(() => parse('abc')).toThrow(/must look like/); + expect(() => parse('devices')).toThrow(/must look like/); + }); +}); + +describe('stringArg', () => { + const parse = stringArg('--profile'); + + it('accepts normal strings', () => { + expect(parse('home')).toBe('home'); + expect(parse('my-profile_2')).toBe('my-profile_2'); + }); + + it('allows a single-dash value (ambiguous, leave to caller)', () => { + expect(parse('-single-dash')).toBe('-single-dash'); + }); + + it('rejects values that start with "--"', () => { + expect(() => parse('--help')).toThrow(InvalidArgumentError); + expect(() => parse('--help')).toThrow(/looks like another option/); + expect(() => parse('--whatever')).toThrow(/looks like another option/); + }); + + it('rejects values in the disallow list (e.g. subcommand names)', () => { + // `switchbot --profile devices list` — "devices" is a subcommand name; we + // want a clearer error than the default "unknown command 'list'". + const parseWithDisallow = stringArg('--profile', { disallow: ['devices', 'scenes'] }); + expect(() => parseWithDisallow('devices')).toThrow(/subcommand name/); + expect(() => parseWithDisallow('scenes')).toThrow(/subcommand name/); + expect(parseWithDisallow('home')).toBe('home'); + }); +}); + +describe('enumArg', () => { + const parse = enumArg('--format', ['table', 'json', 'jsonl']); + + it('accepts values in the allowed set', () => { + expect(parse('table')).toBe('table'); + expect(parse('json')).toBe('json'); + }); + + it('rejects values not in the allowed set', () => { + expect(() => parse('xml')).toThrow(/must be one of: table, json, jsonl/); + expect(() => parse('--help')).toThrow(/must be one of/); + }); +});