@switchbot/openapi-cli 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,6 +63,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
63
63
  - [Scripting examples](#scripting-examples)
64
64
  - [Development](#development)
65
65
  - [Contributing](#contributing)
66
+ - [Roadmap](#roadmap)
66
67
  - [License](#license)
67
68
  - [References](#references)
68
69
 
@@ -263,18 +264,18 @@ switchbot devices commands curtain # Case-insensitive, substring match
263
264
 
264
265
  #### Filter expressions — per-command reference
265
266
 
266
- Three commands accept `--filter`. They share one three-operator grammar,
267
+ Three commands accept `--filter`. They share one four-operator grammar,
267
268
  but each exposes its own key set:
268
269
 
269
270
  | Command | Operators | Supported keys |
270
271
  |-------------------------------------|-----------------------------------------------------------------------------------------------|---------------------------------------|
271
- | `devices list` | `=` (substring; **exact** for `category`), `~` (substring), `=/regex/` (case-insensitive regex) | `type`, `name`, `category`, `room` |
272
+ | `devices list` | `=` (substring; **exact** for `category`), `!=` (negated), `~` (substring), `=/regex/` (case-insensitive regex) | `type`, `name`, `category`, `room` |
272
273
  | `devices batch` | same | `type`, `family`, `room`, `category` |
273
274
  | `events tail` / `events mqtt-tail` | same (tail only; mqtt-tail uses `--topic` instead) | `deviceId`, `type` |
274
275
 
275
276
  Clauses are comma-separated and AND-ed. No OR across clauses — use regex
276
277
  alternation (`=/A|B/`) for that. `category` is the one key that stays exact
277
- under `=` to preserve `category=physical` / `category=ir` semantics.
278
+ under `=` / `!=` to preserve `category=physical` / `category!=ir` semantics.
278
279
 
279
280
  #### Parameter formats
280
281
 
@@ -299,7 +300,9 @@ Generic parameter shapes (which one applies is decided by the device — see the
299
300
  | `<json object>` | `'{"action":"sweep","param":{"fanLevel":2,"times":1}}'` |
300
301
  | Custom IR button | `devices command <id> MyButton --type customize` |
301
302
 
302
- Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), and `setMode` (Relay Switch) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list.
303
+ Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), `setMode` (Relay Switch), `setBrightness` (dimmable lights), and `setColor` (Color Bulb / Strip Light / Ceiling Light) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. `setColor` accepts `R:G:B`, `R,G,B`, `#RRGGBB`, `#RGB`, and CSS named colors (`red`, `blue`, …); all normalize to `R:G:B` before hitting the API. Pass `--skip-param-validation` to bypass (escape hatch — prefer fixing the argument). Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list.
304
+
305
+ Unknown deviceIds (not in the local cache) exit 2 by default so `--dry-run` is a reliable pre-flight gate. Run `switchbot devices list` first, or pass `--allow-unknown-device` for scripted pass-through.
303
306
 
304
307
  Negative numeric parameters (e.g. `setBrightness -1` for a probe) are passed through to the command validator instead of being swallowed by the flag parser as an unknown option.
305
308
 
@@ -325,7 +328,7 @@ switchbot devices expand <blindId> setPosition --direction up --angle 50
325
328
  switchbot devices expand <relayId> setMode --channel 1 --mode edge
326
329
  ```
327
330
 
328
- Run `switchbot devices expand <id> <command> --help` to see the available flags for any device command.
331
+ Run `switchbot devices expand <id> <command> --help` to see the available flags for any device command. `expand` is only meaningful for multi-parameter commands (the four above); single-parameter commands like `setBrightness 50` or `setColor "#FF0000"` are already flag-free at the CLI level.
329
332
 
330
333
  #### `devices explain` — one-shot device summary
331
334
 
@@ -602,7 +605,9 @@ Reads the JSONL audit log (`~/.switchbot/audit.log` by default; override with `-
602
605
 
603
606
  ```bash
604
607
  switchbot catalog show # all 42 built-in types
608
+ switchbot catalog list # alias for `show`
605
609
  switchbot catalog show Bot # one type
610
+ switchbot catalog search Hub # fuzzy match across type / aliases / commands
606
611
  switchbot catalog diff # what a local overlay changes vs built-in
607
612
  switchbot catalog path # location of the local overlay file
608
613
  switchbot catalog refresh # reload local overlay (clears in-process cache)
@@ -624,9 +629,10 @@ Exports the effective catalog in a machine-readable format. Pipe the output into
624
629
 
625
630
  ```bash
626
631
  switchbot capabilities --json
632
+ switchbot capabilities --used --json # only types seen in the local cache
627
633
  ```
628
634
 
629
- Prints a versioned JSON manifest describing available surfaces (CLI, MCP, MQTT, plan runner), commands, and environment variables. Designed for agents and tooling that need to discover the CLI's capabilities programmatically.
635
+ Prints a versioned JSON manifest describing available surfaces (CLI, MCP, MQTT, plan runner), commands, and environment variables. Every subcommand leaf now carries a `{mutating, consumesQuota, idempotencySupported, agentSafetyTier, verifiability, typicalLatencyMs}` block, and the top-level payload publishes a flat `commandMeta` path-keyed lookup so agents don't have to walk the tree. `--used` filters the per-type summary to devices actually present in the local cache (same semantics as `schema export --used`).
630
636
 
631
637
  ### `cache` — inspect and clear local cache
632
638
 
@@ -822,6 +828,21 @@ Bug reports, feature requests, and PRs are welcome.
822
828
  3. Run `npm test` and `npm run build` locally — both must pass.
823
829
  4. Open a pull request against `main`. CI runs on Node 18/20/22; all three must stay green.
824
830
 
831
+ ## Roadmap
832
+
833
+ Tracked for a future v3.x line (OpenClaw B-17 / B-18 / B-19 / B-21) — each is a
834
+ standalone track rather than a bug fix:
835
+
836
+ - **Daemon mode** — long-running local process with a Unix/named-pipe socket so
837
+ repeated MCP or plan invocations don't pay fresh-process startup every call.
838
+ - **`npx @switchbot/mcp-server`** — split the MCP server into its own tiny
839
+ published package so non-CLI users can `npx` it directly without installing
840
+ the full CLI.
841
+ - **`switchbot self-test`** — scripted end-to-end harness that checks a live
842
+ token + a representative device and prints a go/no-go report.
843
+ - **Record / replay** — capture raw request/response pairs into a fixture file
844
+ and replay them offline for deterministic testing and CI.
845
+
825
846
  ## License
826
847
 
827
848
  [MIT](./LICENSE) © chenliuyun
@@ -2,7 +2,7 @@ import axios from 'axios';
2
2
  import chalk from 'chalk';
3
3
  import { buildAuthHeaders } from '../auth.js';
4
4
  import { loadConfig } from '../config.js';
5
- import { isVerbose, isDryRun, getTimeout, getRetryOn429, getBackoffStrategy, isQuotaDisabled, } from '../utils/flags.js';
5
+ import { isVerbose, isDryRun, getTimeout, getRetryOn429, getRetryOn5xx, getBackoffStrategy, isQuotaDisabled, } from '../utils/flags.js';
6
6
  import { nextRetryDelayMs, sleep } from '../utils/retry.js';
7
7
  import { recordRequest, checkDailyCap } from '../utils/quota.js';
8
8
  import { readProfileMeta } from '../config.js';
@@ -45,6 +45,7 @@ export function createClient() {
45
45
  const verbose = isVerbose();
46
46
  const dryRun = isDryRun();
47
47
  const maxRetries = getRetryOn429();
48
+ const max5xxRetries = getRetryOn5xx();
48
49
  const backoff = getBackoffStrategy();
49
50
  const quotaEnabled = !isQuotaDisabled();
50
51
  const profile = getActiveProfile();
@@ -110,11 +111,25 @@ export function createClient() {
110
111
  if (error instanceof DryRunSignal)
111
112
  throw error;
112
113
  if (axios.isAxiosError(error)) {
114
+ const config = error.config;
115
+ const method = (config?.method ?? 'get').toUpperCase();
116
+ const isIdempotentRead = method === 'GET';
113
117
  if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
114
- throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0, { transient: true, retryable: false });
118
+ // Retry idempotent GETs on timeout up to `max5xxRetries` times.
119
+ if (isIdempotentRead && config && max5xxRetries > 0) {
120
+ const attempt = config.__retryCount ?? 0;
121
+ if (attempt < max5xxRetries) {
122
+ config.__retryCount = attempt + 1;
123
+ const delay = nextRetryDelayMs(attempt, backoff, undefined);
124
+ if (verbose) {
125
+ process.stderr.write(chalk.grey(`[verbose] timeout — retry ${attempt + 1}/${max5xxRetries} in ${delay}ms\n`));
126
+ }
127
+ return sleep(delay).then(() => client.request(config));
128
+ }
129
+ }
130
+ throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0, { transient: true, retryable: isIdempotentRead });
115
131
  }
116
132
  const status = error.response?.status;
117
- const config = error.config;
118
133
  // 429 → transparent retry with Retry-After / exponential backoff.
119
134
  // Skipped when: no config (shouldn't happen for real axios errors),
120
135
  // retries disabled, or we've already used our budget.
@@ -129,6 +144,23 @@ export function createClient() {
129
144
  return sleep(delay).then(() => client.request(config));
130
145
  }
131
146
  }
147
+ // 502/503/504 on idempotent GETs → transparent retry. Mutating calls
148
+ // never auto-retry; use --idempotency-key for safe POST retries.
149
+ if (isIdempotentRead &&
150
+ status !== undefined &&
151
+ (status === 502 || status === 503 || status === 504) &&
152
+ config &&
153
+ max5xxRetries > 0) {
154
+ const attempt = config.__retryCount ?? 0;
155
+ if (attempt < max5xxRetries) {
156
+ config.__retryCount = attempt + 1;
157
+ const delay = nextRetryDelayMs(attempt, backoff, error.response?.headers?.['retry-after']);
158
+ if (verbose) {
159
+ process.stderr.write(chalk.grey(`[verbose] ${status} received — retry ${attempt + 1}/${max5xxRetries} in ${delay}ms\n`));
160
+ }
161
+ return sleep(delay).then(() => client.request(config));
162
+ }
163
+ }
132
164
  // Record exhausted/non-retryable HTTP responses too — they count
133
165
  // against the daily quota.
134
166
  if (quotaEnabled && error.response && config) {
@@ -1,4 +1,5 @@
1
1
  import { getEffectiveCatalog } from '../devices/catalog.js';
2
+ import { loadCache } from '../devices/cache.js';
2
3
  import { printJson } from '../utils/output.js';
3
4
  import { enumArg, stringArg } from '../utils/arg-parsers.js';
4
5
  const AGENT_GUIDE = {
@@ -149,6 +150,7 @@ export function registerCapabilitiesCommand(program) {
149
150
  .description('Print a machine-readable manifest of CLI capabilities (for agent bootstrap)')
150
151
  .option('--minimal', 'Omit per-subcommand flag details to reduce output size (alias of --compact)')
151
152
  .option('--compact', 'Emit a compact summary: identity + leaf command list with safety metadata only')
153
+ .option('--used', 'Restrict the catalog summary to device types present in the local cache. Mirrors `schema export --used`.')
152
154
  .option('--surface <s>', 'Restrict surfaces block to one of: cli, mcp, plan, mqtt, all (default: all)', enumArg('--surface', SURFACES))
153
155
  .option('--project <csv>', 'Project top-level fields (e.g. --project identity,commands,agentGuide)', stringArg('--project'))
154
156
  .action((opts) => {
@@ -238,6 +240,11 @@ export function registerCapabilitiesCommand(program) {
238
240
  identity: IDENTITY,
239
241
  surfaces: filteredSurfaces,
240
242
  commands: compact ? leaves : fullCommands,
243
+ // Flat command → meta map keyed by full command path. Published in
244
+ // addition to the tree (where every leaf `subcommands[*]` already
245
+ // carries the same fields via spread) so agents can do O(1) lookup
246
+ // without walking the tree.
247
+ commandMeta: COMMAND_META,
241
248
  ...(globalFlags ? { globalFlags } : {}),
242
249
  catalog: {
243
250
  typeCount: catalog.length,
@@ -248,6 +255,30 @@ export function registerCapabilitiesCommand(program) {
248
255
  };
249
256
  if (!compact)
250
257
  payload.generatedAt = new Date().toISOString();
258
+ if (opts.used) {
259
+ const cache = loadCache();
260
+ if (!cache || Object.keys(cache.devices).length === 0) {
261
+ // No cache → return the payload unchanged but add a `usedFilter` note
262
+ // so agents know the filter was requested but noop'd.
263
+ payload.usedFilter = { applied: false, reason: 'no local cache — run `switchbot devices list` first' };
264
+ }
265
+ else {
266
+ const seen = new Set();
267
+ for (const id of Object.keys(cache.devices)) {
268
+ const t = cache.devices[id].type;
269
+ if (t)
270
+ seen.add(t);
271
+ }
272
+ const filteredCatalog = catalog.filter((e) => seen.has(e.type) || (e.aliases ?? []).some((a) => seen.has(a)));
273
+ payload.catalog = {
274
+ typeCount: filteredCatalog.length,
275
+ roles: [...new Set(filteredCatalog.map((e) => e.role ?? 'other'))].sort(),
276
+ destructiveCommandCount: filteredCatalog.reduce((n, e) => n + e.commands.filter((c) => c.destructive).length, 0),
277
+ readOnlyTypeCount: filteredCatalog.filter((e) => e.readOnly).length,
278
+ };
279
+ payload.usedFilter = { applied: true, typesInCache: [...seen].sort() };
280
+ }
281
+ }
251
282
  const projected = opts.project
252
283
  ? projectObject(payload, opts.project.split(',').map((s) => s.trim()).filter(Boolean))
253
284
  : payload;
@@ -23,14 +23,18 @@ Overlay rules (applied in order):
23
23
 
24
24
  Subcommands:
25
25
  path Print the overlay file path and whether it exists
26
- show Show the effective catalog (or one entry)
26
+ show Show the effective catalog (or one entry). Alias: list
27
+ list Alias of show (matches the muscle-memory spelling)
28
+ search Fuzzy search types/aliases/roles/commands for a keyword
27
29
  diff Show what the overlay changes vs the built-in catalog
28
30
  refresh Re-read the overlay file (clears in-process cache)
29
31
 
30
32
  Examples:
31
33
  $ switchbot catalog path
34
+ $ switchbot catalog list
32
35
  $ switchbot catalog show
33
36
  $ switchbot catalog show "Smart Lock"
37
+ $ switchbot catalog search Hub
34
38
  $ switchbot catalog show --source built-in
35
39
  $ switchbot catalog diff
36
40
  $ switchbot catalog refresh
@@ -66,7 +70,8 @@ Examples:
66
70
  });
67
71
  catalog
68
72
  .command('show')
69
- .description("Show the effective catalog (or one entry). Defaults to 'effective' source.")
73
+ .alias('list')
74
+ .description("Show the effective catalog (or one entry). Alias: 'list'. Defaults to 'effective' source.")
70
75
  .argument('[type...]', 'Optional device type/alias (case-insensitive, partial match)')
71
76
  .option('--source <source>', 'Which catalog to show: built-in | overlay | effective (default)', enumArg('--source', SOURCES), 'effective')
72
77
  .action((typeParts, options) => {
@@ -139,6 +144,59 @@ Examples:
139
144
  handleError(error);
140
145
  }
141
146
  });
147
+ catalog
148
+ .command('search')
149
+ .description('Fuzzy search the effective catalog by type name, alias, role, or command name')
150
+ .argument('<keyword>', 'Substring to match (case-insensitive) against type, alias, role, or command')
151
+ .action((keyword) => {
152
+ try {
153
+ const q = keyword.toLowerCase();
154
+ const entries = getEffectiveCatalog();
155
+ const hits = entries.filter((e) => {
156
+ if (e.type.toLowerCase().includes(q))
157
+ return true;
158
+ if ((e.role ?? '').toLowerCase().includes(q))
159
+ return true;
160
+ if ((e.aliases ?? []).some((a) => a.toLowerCase().includes(q)))
161
+ return true;
162
+ if (e.commands.some((c) => c.command.toLowerCase().includes(q)))
163
+ return true;
164
+ return false;
165
+ });
166
+ if (isJsonMode()) {
167
+ printJson({ query: keyword, matches: hits });
168
+ return;
169
+ }
170
+ if (hits.length === 0) {
171
+ console.log(`No catalog entries match "${keyword}".`);
172
+ return;
173
+ }
174
+ const fmt = resolveFormat();
175
+ const headers = ['type', 'category', 'role', 'matched'];
176
+ const rows = hits.map((e) => {
177
+ const matched = [];
178
+ if (e.type.toLowerCase().includes(q))
179
+ matched.push('type');
180
+ if ((e.aliases ?? []).some((a) => a.toLowerCase().includes(q)))
181
+ matched.push('alias');
182
+ if ((e.role ?? '').toLowerCase().includes(q))
183
+ matched.push('role');
184
+ const cmdMatches = e.commands
185
+ .filter((c) => c.command.toLowerCase().includes(q))
186
+ .map((c) => c.command);
187
+ if (cmdMatches.length > 0)
188
+ matched.push(`commands[${cmdMatches.join(',')}]`);
189
+ return [e.type, e.category, e.role ?? '—', matched.join(', ') || '—'];
190
+ });
191
+ renderRows(headers, rows, fmt, resolveFields());
192
+ if (fmt === 'table') {
193
+ console.log(`\n${hits.length} match${hits.length === 1 ? '' : 'es'} for "${keyword}"`);
194
+ }
195
+ }
196
+ catch (error) {
197
+ handleError(error);
198
+ }
199
+ });
142
200
  catalog
143
201
  .command('diff')
144
202
  .description('Show what the overlay replaces, adds, or removes vs the built-in catalog')
@@ -1,11 +1,12 @@
1
1
  import { enumArg, stringArg } from '../utils/arg-parsers.js';
2
- import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, emitJsonError } from '../utils/output.js';
2
+ import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, emitJsonError } from '../utils/output.js';
3
3
  import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
4
4
  import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js';
5
- import { getCachedDevice } from '../devices/cache.js';
5
+ import { getCachedDevice, loadCache } from '../devices/cache.js';
6
6
  import { loadDeviceMeta } from '../devices/device-meta.js';
7
7
  import { resolveDeviceId, ALL_STRATEGIES } from '../utils/name-resolver.js';
8
8
  import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, buildHubLocationMap, DeviceNotFoundError, } from '../lib/devices.js';
9
+ import { parseFilterExpr, matchClause, FilterSyntaxError } from '../utils/filter.js';
9
10
  import { validateParameter } from '../devices/param-validator.js';
10
11
  import { registerBatchCommand } from './batch.js';
11
12
  import { registerWatchCommand } from './watch.js';
@@ -79,7 +80,7 @@ Examples:
79
80
  `)
80
81
  .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
81
82
  .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
82
- .option('--filter <expr>', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: type, name, category, room.', stringArg('--filter'))
83
+ .option('--filter <expr>', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: type, name, category, room.', stringArg('--filter'))
83
84
  .action(async (options) => {
84
85
  try {
85
86
  const body = await fetchDeviceList();
@@ -87,49 +88,18 @@ Examples:
87
88
  const fmt = resolveFormat();
88
89
  const deviceMeta = loadDeviceMeta();
89
90
  const hubLocation = buildHubLocationMap(deviceList);
90
- const SUPPORTED_KEYS = ['type', 'name', 'category', 'room'];
91
+ // Parse --filter into a list of clauses. Shared grammar across
92
+ // `devices list`, `devices batch`, and `events tail` / `mqtt-tail`.
93
+ const LIST_KEYS = ['type', 'name', 'category', 'room'];
91
94
  let listClauses = null;
92
95
  if (options.filter) {
93
- listClauses = [];
94
- for (const pair of options.filter.split(',')) {
95
- const trimmed = pair.trim();
96
- if (!trimmed)
97
- continue;
98
- const regexMatch = /^([^=~]+)=\/(.*)\/$/.exec(trimmed);
99
- const tildeIdx = trimmed.indexOf('~');
100
- const eqIdx = trimmed.indexOf('=');
101
- let key;
102
- let op;
103
- let raw;
104
- let regex;
105
- if (regexMatch) {
106
- key = regexMatch[1].trim();
107
- op = 'regex';
108
- raw = regexMatch[2];
109
- try {
110
- regex = new RegExp(raw, 'i');
111
- }
112
- catch (err) {
113
- throw new UsageError(`Invalid regex in --filter "${trimmed}": ${err.message}`);
114
- }
115
- }
116
- else if (tildeIdx !== -1 && (eqIdx === -1 || tildeIdx < eqIdx)) {
117
- key = trimmed.slice(0, tildeIdx).trim();
118
- op = 'sub';
119
- raw = trimmed.slice(tildeIdx + 1).trim().toLowerCase();
120
- }
121
- else if (eqIdx !== -1) {
122
- key = trimmed.slice(0, eqIdx).trim();
123
- op = 'eq';
124
- raw = trimmed.slice(eqIdx + 1).trim().toLowerCase();
125
- }
126
- else {
127
- throw new UsageError(`Invalid --filter pair "${trimmed}". Expected key=value, key~value, or key=/regex/.`);
128
- }
129
- if (!SUPPORTED_KEYS.includes(key)) {
130
- throw new UsageError(`Unknown --filter key "${key}". Supported: ${SUPPORTED_KEYS.join(', ')}.`);
131
- }
132
- listClauses.push({ key: key, op, raw, regex });
96
+ try {
97
+ listClauses = parseFilterExpr(options.filter, LIST_KEYS);
98
+ }
99
+ catch (err) {
100
+ if (err instanceof FilterSyntaxError)
101
+ throw new UsageError(err.message);
102
+ throw err;
133
103
  }
134
104
  }
135
105
  const matchesFilter = (entry) => {
@@ -137,21 +107,7 @@ Examples:
137
107
  return true;
138
108
  for (const c of listClauses) {
139
109
  const fieldVal = entry[c.key] ?? '';
140
- const lower = fieldVal.toLowerCase();
141
- let ok;
142
- if (c.op === 'regex') {
143
- ok = c.regex.test(fieldVal);
144
- }
145
- else if (c.op === 'sub') {
146
- ok = lower.includes(c.raw);
147
- }
148
- else if (c.key === 'category') {
149
- ok = lower === c.raw;
150
- }
151
- else {
152
- ok = lower.includes(c.raw);
153
- }
154
- if (!ok)
110
+ if (!matchClause(fieldVal, c))
155
111
  return false;
156
112
  }
157
113
  return true;
@@ -352,6 +308,8 @@ Examples:
352
308
  .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
353
309
  .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
354
310
  .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
311
+ .option('--allow-unknown-device', 'Allow targeting a deviceId that is not in the local cache. By default unknown IDs exit 2 so --dry-run is a reliable pre-flight gate; use this flag for scripted pass-through.')
312
+ .option('--skip-param-validation', 'Skip client-side parameter validation (escape hatch — prefer fixing the argument over using this).')
355
313
  .option('--idempotency-key <key>', 'Client-supplied key to dedupe retries. process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key'))
356
314
  .addHelpText('after', `
357
315
  ────────────────────────────────────────────────────────────────────────
@@ -438,7 +396,26 @@ Examples:
438
396
  });
439
397
  _deviceId = deviceId;
440
398
  if (!getCachedDevice(deviceId)) {
441
- console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`);
399
+ if (options.allowUnknownDevice) {
400
+ console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation. (--allow-unknown-device is set, continuing.)`);
401
+ }
402
+ else {
403
+ const cache = loadCache();
404
+ const allIds = cache ? Object.keys(cache.devices) : [];
405
+ const candidates = allIds
406
+ .filter((id) => id.toLowerCase().includes(deviceId.toLowerCase()) || id.startsWith(deviceId.slice(0, 4)))
407
+ .slice(0, 5)
408
+ .map((id) => {
409
+ const dev = cache.devices[id];
410
+ return { deviceId: id, name: dev.name, type: dev.type };
411
+ });
412
+ throw new StructuredUsageError(`Unknown deviceId "${deviceId}" — not in local cache. Run 'switchbot devices list' first, or pass --allow-unknown-device to bypass this check.`, {
413
+ error: 'unknown_device_id',
414
+ deviceId,
415
+ candidates,
416
+ hint: `Pass --allow-unknown-device to skip this check (and rely on the API for validation).`,
417
+ });
418
+ }
442
419
  }
443
420
  const validation = validateCommand(deviceId, cmd, parameter, options.type);
444
421
  if (!validation.ok) {
@@ -474,7 +451,7 @@ Examples:
474
451
  }
475
452
  // Raw-parameter validation (runs for known (deviceType, command) pairs only).
476
453
  const cachedForParam = getCachedDevice(deviceId);
477
- if (cachedForParam && options.type === 'command') {
454
+ if (cachedForParam && options.type === 'command' && !options.skipParamValidation) {
478
455
  const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
479
456
  if (!paramCheck.ok) {
480
457
  if (isJsonMode()) {
@@ -482,7 +459,7 @@ Examples:
482
459
  code: 2,
483
460
  kind: 'usage',
484
461
  message: paramCheck.error,
485
- context: { command: cmd, deviceType: cachedForParam.type, deviceId },
462
+ context: { command: cmd, deviceType: cachedForParam.type, deviceId, humanHint: paramCheck.error },
486
463
  });
487
464
  }
488
465
  else {
@@ -644,21 +621,52 @@ Examples:
644
621
  $ switchbot devices commands Robot --json
645
622
  `)
646
623
  .action((typeParts) => {
647
- const type = typeParts.join(' ');
648
624
  try {
649
- const match = findCatalogEntry(type);
650
- if (!match) {
651
- throw new UsageError(`No device type matches "${type}". Try 'switchbot devices types' to see the full list.`);
625
+ // First try the joined form so legacy multi-word unquoted input still
626
+ // works (`devices commands Air Conditioner` → "Air Conditioner"). If
627
+ // that doesn't match and every individual token resolves on its own,
628
+ // treat it as variadic and emit a section per type.
629
+ const joined = typeParts.join(' ');
630
+ const joinedMatch = findCatalogEntry(joined);
631
+ if (joinedMatch && !Array.isArray(joinedMatch)) {
632
+ if (isJsonMode()) {
633
+ printJson(joinedMatch);
634
+ }
635
+ else {
636
+ renderCatalogEntry(joinedMatch);
637
+ }
638
+ return;
652
639
  }
653
- if (Array.isArray(match)) {
654
- const types = match.map((m) => m.type).join(', ');
655
- throw new UsageError(`"${type}" matches multiple types: ${types}. Be more specific.`);
640
+ if (typeParts.length > 1) {
641
+ const individualMatches = [];
642
+ for (const t of typeParts) {
643
+ const m = findCatalogEntry(t);
644
+ if (!m || Array.isArray(m)) {
645
+ individualMatches.length = 0;
646
+ break;
647
+ }
648
+ individualMatches.push(m);
649
+ }
650
+ if (individualMatches.length === typeParts.length) {
651
+ if (isJsonMode()) {
652
+ printJson(individualMatches);
653
+ }
654
+ else {
655
+ individualMatches.forEach((entry, i) => {
656
+ if (i > 0)
657
+ console.log('');
658
+ renderCatalogEntry(entry);
659
+ });
660
+ }
661
+ return;
662
+ }
656
663
  }
657
- if (isJsonMode()) {
658
- printJson(match);
659
- return;
664
+ if (!joinedMatch) {
665
+ throw new UsageError(`No device type matches "${joined}". Try 'switchbot devices types' to see the full list.`);
660
666
  }
661
- renderCatalogEntry(match);
667
+ // joinedMatch is an ambiguous-match array here
668
+ const types = joinedMatch.map((m) => m.type).join(', ');
669
+ throw new UsageError(`"${joined}" matches multiple types: ${types}. Be more specific.`);
662
670
  }
663
671
  catch (error) {
664
672
  handleError(error);
@@ -100,8 +100,178 @@ export function validateParameter(deviceType, command, raw) {
100
100
  if (deviceType.startsWith('Relay Switch') && command === 'setMode') {
101
101
  return validateRelaySetMode(raw);
102
102
  }
103
+ if (command === 'setBrightness' && isBrightnessDevice(deviceType)) {
104
+ return validateSetBrightness(raw);
105
+ }
106
+ if (command === 'setColor' && isColorDevice(deviceType)) {
107
+ return validateSetColor(raw);
108
+ }
109
+ if (command === 'setColorTemperature' && isColorDevice(deviceType)) {
110
+ return validateSetColorTemperature(raw);
111
+ }
103
112
  return { ok: true };
104
113
  }
114
+ function isBrightnessDevice(deviceType) {
115
+ return (deviceType === 'Color Bulb' ||
116
+ deviceType === 'Strip Light' ||
117
+ deviceType === 'Strip Light 3' ||
118
+ deviceType === 'Ceiling Light' ||
119
+ deviceType === 'Ceiling Light Pro' ||
120
+ deviceType === 'Floor Lamp' ||
121
+ deviceType === 'Light Strip' ||
122
+ deviceType === 'Dimmer' ||
123
+ deviceType === 'Fill Light');
124
+ }
125
+ function isColorDevice(deviceType) {
126
+ return (deviceType === 'Color Bulb' ||
127
+ deviceType === 'Strip Light' ||
128
+ deviceType === 'Strip Light 3' ||
129
+ deviceType === 'Ceiling Light' ||
130
+ deviceType === 'Ceiling Light Pro' ||
131
+ deviceType === 'Floor Lamp' ||
132
+ deviceType === 'Light Strip' ||
133
+ deviceType === 'Fill Light');
134
+ }
135
+ function validateSetBrightness(raw) {
136
+ if (raw === undefined || raw === '' || raw === 'default') {
137
+ return {
138
+ ok: false,
139
+ error: `setBrightness requires an integer 1-100 (percent). Example: "50".`,
140
+ };
141
+ }
142
+ const trimmed = raw.trim();
143
+ if (!/^-?\d+$/.test(trimmed)) {
144
+ return {
145
+ ok: false,
146
+ error: `setBrightness must be an integer 1-100, got ${JSON.stringify(raw)}. ${hintBrightnessRetry()}`,
147
+ };
148
+ }
149
+ const n = Number(trimmed);
150
+ if (!Number.isInteger(n) || n < 1 || n > 100) {
151
+ return {
152
+ ok: false,
153
+ error: `setBrightness must be an integer 1-100, got "${raw}". ${hintBrightnessRetry()}`,
154
+ };
155
+ }
156
+ return { ok: true, normalized: String(n) };
157
+ }
158
+ function hintBrightnessRetry() {
159
+ return `Ask the user whether they meant a percentage (1-100). Example: "50".`;
160
+ }
161
+ // B-12: setColor accepts R:G:B, R,G,B, #RRGGBB, #RGB, or a small CSS named color
162
+ // palette. All forms are normalized to `R:G:B` (the only wire shape SwitchBot
163
+ // accepts) so the caller can POST the result unchanged.
164
+ const NAMED_COLORS = {
165
+ red: [255, 0, 0],
166
+ green: [0, 128, 0],
167
+ lime: [0, 255, 0],
168
+ blue: [0, 0, 255],
169
+ yellow: [255, 255, 0],
170
+ cyan: [0, 255, 255],
171
+ magenta: [255, 0, 255],
172
+ white: [255, 255, 255],
173
+ black: [0, 0, 0],
174
+ orange: [255, 165, 0],
175
+ purple: [128, 0, 128],
176
+ pink: [255, 192, 203],
177
+ brown: [165, 42, 42],
178
+ grey: [128, 128, 128],
179
+ gray: [128, 128, 128],
180
+ warm: [255, 180, 100],
181
+ };
182
+ function validateSetColor(raw) {
183
+ if (raw === undefined || raw === '' || raw === 'default') {
184
+ return {
185
+ ok: false,
186
+ error: `setColor requires a color. Expected one of: "R:G:B" (e.g. "255:0:0"), "#RRGGBB" (e.g. "#FF0000"), "#RGB", "R,G,B", or a named color (${Object.keys(NAMED_COLORS).slice(0, 8).join(', ')}, ...).`,
187
+ };
188
+ }
189
+ const trimmed = raw.trim();
190
+ // Named color.
191
+ const named = NAMED_COLORS[trimmed.toLowerCase()];
192
+ if (named) {
193
+ return { ok: true, normalized: `${named[0]}:${named[1]}:${named[2]}` };
194
+ }
195
+ // Hex #RRGGBB or #RGB.
196
+ if (trimmed.startsWith('#')) {
197
+ const hex = trimmed.slice(1);
198
+ if (/^[0-9a-fA-F]{6}$/.test(hex)) {
199
+ const r = parseInt(hex.slice(0, 2), 16);
200
+ const g = parseInt(hex.slice(2, 4), 16);
201
+ const b = parseInt(hex.slice(4, 6), 16);
202
+ return { ok: true, normalized: `${r}:${g}:${b}` };
203
+ }
204
+ if (/^[0-9a-fA-F]{3}$/.test(hex)) {
205
+ const r = parseInt(hex[0] + hex[0], 16);
206
+ const g = parseInt(hex[1] + hex[1], 16);
207
+ const b = parseInt(hex[2] + hex[2], 16);
208
+ return { ok: true, normalized: `${r}:${g}:${b}` };
209
+ }
210
+ return {
211
+ ok: false,
212
+ error: `setColor "${raw}" is not valid hex. ${hintColorRetry()}`,
213
+ };
214
+ }
215
+ // R:G:B or R,G,B — pick whichever separator appears.
216
+ const sep = trimmed.includes(':') ? ':' : trimmed.includes(',') ? ',' : null;
217
+ if (!sep) {
218
+ return {
219
+ ok: false,
220
+ error: `setColor "${raw}" is not a recognized format. ${hintColorRetry()}`,
221
+ };
222
+ }
223
+ const parts = trimmed.split(sep).map((s) => s.trim());
224
+ if (parts.length !== 3) {
225
+ return {
226
+ ok: false,
227
+ error: `setColor expects 3 components (R${sep}G${sep}B), got ${parts.length} (${JSON.stringify(raw)}). ${hintColorRetry()}`,
228
+ };
229
+ }
230
+ const nums = [];
231
+ for (const p of parts) {
232
+ if (!/^-?\d+$/.test(p)) {
233
+ return {
234
+ ok: false,
235
+ error: `setColor component "${p}" is not an integer. ${hintColorRetry()}`,
236
+ };
237
+ }
238
+ const n = Number(p);
239
+ if (!Number.isInteger(n) || n < 0 || n > 255) {
240
+ return {
241
+ ok: false,
242
+ error: `setColor components must be integers 0-255, got "${p}". ${hintColorRetry()}`,
243
+ };
244
+ }
245
+ nums.push(n);
246
+ }
247
+ return { ok: true, normalized: `${nums[0]}:${nums[1]}:${nums[2]}` };
248
+ }
249
+ function hintColorRetry() {
250
+ return `Expected "R:G:B" (e.g. "255:0:0"), "#RRGGBB", "#RGB", "R,G,B", or a named color.`;
251
+ }
252
+ function validateSetColorTemperature(raw) {
253
+ if (raw === undefined || raw === '' || raw === 'default') {
254
+ return {
255
+ ok: false,
256
+ error: `setColorTemperature requires an integer Kelvin value 2700-6500. Example: "4000".`,
257
+ };
258
+ }
259
+ const trimmed = raw.trim();
260
+ if (!/^-?\d+$/.test(trimmed)) {
261
+ return {
262
+ ok: false,
263
+ error: `setColorTemperature must be an integer 2700-6500, got ${JSON.stringify(raw)}.`,
264
+ };
265
+ }
266
+ const n = Number(trimmed);
267
+ if (!Number.isInteger(n) || n < 2700 || n > 6500) {
268
+ return {
269
+ ok: false,
270
+ error: `setColorTemperature must be an integer 2700-6500, got "${raw}".`,
271
+ };
272
+ }
273
+ return { ok: true, normalized: String(n) };
274
+ }
105
275
  function validateAcSetAll(raw) {
106
276
  if (raw === undefined || raw === '' || raw === 'default') {
107
277
  return {
@@ -9,8 +9,10 @@ export class FilterSyntaxError extends Error {
9
9
  *
10
10
  * Grammar (per clause, recognition order):
11
11
  * 1. key=/pattern/ → regex (case-insensitive); invalid regex throws.
12
- * 2. key~value → substring (case-insensitive).
13
- * 3. key=value → 'eq' op (substring; caller decides whether to treat
12
+ * 2. key!=value 'neq' op (negated substring; exact-negated for keys
13
+ * listed in matchClause's `exactKeys` option).
14
+ * 3. key~value → substring (case-insensitive).
15
+ * 4. key=value → 'eq' op (substring; caller decides whether to treat
14
16
  * as exact for specific keys via matchClause's
15
17
  * `exactKeys` option).
16
18
  *
@@ -24,7 +26,8 @@ export function parseFilterExpr(expr, allowedKeys) {
24
26
  const parts = expr.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
25
27
  const clauses = [];
26
28
  for (const part of parts) {
27
- const regexMatch = /^([^=~]+)=\/(.*)\/$/.exec(part);
29
+ const regexMatch = /^([^=~!]+)=\/(.*)\/$/.exec(part);
30
+ const neqIdx = part.indexOf('!=');
28
31
  const tildeIdx = part.indexOf('~');
29
32
  const eqIdx = part.indexOf('=');
30
33
  let key;
@@ -42,6 +45,11 @@ export function parseFilterExpr(expr, allowedKeys) {
42
45
  throw new FilterSyntaxError(`Invalid regex in --filter "${part}": ${err.message}`);
43
46
  }
44
47
  }
48
+ else if (neqIdx !== -1 && (tildeIdx === -1 || neqIdx < tildeIdx)) {
49
+ key = part.slice(0, neqIdx).trim();
50
+ op = 'neq';
51
+ raw = part.slice(neqIdx + 2).trim();
52
+ }
45
53
  else if (tildeIdx !== -1 && (eqIdx === -1 || tildeIdx < eqIdx)) {
46
54
  key = part.slice(0, tildeIdx).trim();
47
55
  op = 'sub';
@@ -56,7 +64,7 @@ export function parseFilterExpr(expr, allowedKeys) {
56
64
  raw = part.slice(eqIdx + 1).trim();
57
65
  }
58
66
  else {
59
- throw new FilterSyntaxError(`Invalid filter clause "${part}" — expected "<key>=<value>", "<key>~<value>", or "<key>=/<regex>/"`);
67
+ throw new FilterSyntaxError(`Invalid filter clause "${part}" — expected "<key>=<value>", "<key>!=<value>", "<key>~<value>", or "<key>=/<regex>/"`);
60
68
  }
61
69
  if (!key) {
62
70
  throw new FilterSyntaxError(`Empty key in filter clause "${part}"`);
@@ -80,10 +88,16 @@ export function parseFilterExpr(expr, allowedKeys) {
80
88
  * `exactKeys`, which get case-insensitive exact comparison.
81
89
  * Default `exactKeys` is `['category']` to preserve the existing
82
90
  * list/batch behavior for that key.
91
+ * - `neq` → logical inverse of `eq` (negated substring; exact-negated for
92
+ * `exactKeys`). `undefined` candidates remain non-matching so a
93
+ * `neq` clause does NOT accidentally match missing data.
83
94
  */
84
95
  export function matchClause(candidate, clause, options) {
85
- if (candidate === undefined)
86
- return false;
96
+ if (candidate === undefined) {
97
+ // Missing field: `neq` treats absence as "definitely not X"; everything
98
+ // else treats it as "no evidence — don't match".
99
+ return clause.op === 'neq';
100
+ }
87
101
  if (clause.op === 'regex') {
88
102
  return clause.regex.test(candidate);
89
103
  }
@@ -93,7 +107,11 @@ export function matchClause(candidate, clause, options) {
93
107
  return cLower.includes(vLower);
94
108
  }
95
109
  const exactKeys = options?.exactKeys ?? ['category'];
96
- if (exactKeys.includes(clause.key)) {
110
+ const exact = exactKeys.includes(clause.key);
111
+ if (clause.op === 'neq') {
112
+ return exact ? cLower !== vLower : !cLower.includes(vLower);
113
+ }
114
+ if (exact) {
97
115
  return cLower === vLower;
98
116
  }
99
117
  return cLower.includes(vLower);
@@ -8,6 +8,14 @@ function getFlagValue(...flagNames) {
8
8
  if (idx !== -1 && idx + 1 < process.argv.length) {
9
9
  return process.argv[idx + 1];
10
10
  }
11
+ // Also accept the `--flag=value` token form. Commander.js recognizes it at
12
+ // the option layer but global-flag scans like this one used to miss it,
13
+ // so `--format=json` silently fell back to the default (table).
14
+ const prefix = `${flag}=`;
15
+ const combined = process.argv.find((arg) => arg.startsWith(prefix));
16
+ if (combined !== undefined) {
17
+ return combined.slice(prefix.length);
18
+ }
11
19
  }
12
20
  return undefined;
13
21
  }
@@ -82,6 +90,22 @@ export function getBackoffStrategy() {
82
90
  return 'linear';
83
91
  return 'exponential';
84
92
  }
93
+ /**
94
+ * Max retries on 5xx / gateway-timeout responses for idempotent (GET) reads.
95
+ * Default 2. `--no-retry` disables retries entirely. POSTs are not retried
96
+ * automatically — use --idempotency-key and let the server dedupe.
97
+ */
98
+ export function getRetryOn5xx() {
99
+ if (process.argv.includes('--no-retry'))
100
+ return 0;
101
+ const v = getFlagValue('--retry-on-5xx');
102
+ if (v === undefined)
103
+ return 2;
104
+ const n = Number(v);
105
+ if (!Number.isFinite(n) || n < 0)
106
+ return 2;
107
+ return Math.floor(n);
108
+ }
85
109
  /**
86
110
  * Whether local quota counting is disabled. Quota counting is best-effort
87
111
  * (see src/utils/quota.ts) — this lets scripts opt out entirely when even
@@ -117,9 +117,12 @@ export function resolveDeviceId(deviceId, nameQuery, opts = {}) {
117
117
  narrow.push('--category');
118
118
  if (!opts.room)
119
119
  narrow.push('--room');
120
+ const strategyHint = opts.strategy === 'fuzzy'
121
+ ? `pass --name-strategy=first to pick the best match`
122
+ : `pass --name-strategy=fuzzy or --name-strategy=first to pick the best match`;
120
123
  const hint = narrow.length > 0
121
- ? `Narrow with ${narrow.join(' / ')} or use the deviceId directly, or pass --name-strategy first to pick the best match.`
122
- : `Use the deviceId directly, or pass --name-strategy first to pick the best match.`;
124
+ ? `Narrow with ${narrow.join(' / ')}, refine the name, use the deviceId directly, or ${strategyHint}.`
125
+ : `Refine the name, use the deviceId directly, or ${strategyHint}.`;
123
126
  throw new StructuredUsageError(`"${nameQuery}" is ambiguous — ${candidates.length} devices match.`, {
124
127
  error: 'ambiguous_name_match',
125
128
  query: nameQuery,
@@ -44,6 +44,9 @@ function formatCell(cell, style) {
44
44
  return String(cell);
45
45
  }
46
46
  function renderMarkdownTable(headers, rows) {
47
+ if (rows.length === 0) {
48
+ return '_(empty)_';
49
+ }
47
50
  const head = `| ${headers.map(escapeMarkdownCell).join(' | ')} |`;
48
51
  const sep = `| ${headers.map(() => '---').join(' | ')} |`;
49
52
  const body = rows.map((r) => `| ${r
@@ -246,6 +249,32 @@ export function handleError(error) {
246
249
  }
247
250
  if (payload.kind === 'usage') {
248
251
  console.error(payload.message);
252
+ const ctx = payload.context;
253
+ if (ctx && Array.isArray(ctx.candidates) && ctx.candidates.length > 0) {
254
+ const names = ctx.candidates
255
+ .map((c) => {
256
+ if (typeof c === 'string')
257
+ return c;
258
+ if (c && typeof c === 'object') {
259
+ const o = c;
260
+ const name = typeof o.name === 'string'
261
+ ? o.name
262
+ : typeof o.sceneName === 'string' ? o.sceneName : undefined;
263
+ const id = typeof o.deviceId === 'string'
264
+ ? o.deviceId
265
+ : typeof o.sceneId === 'string' ? o.sceneId : typeof o.id === 'string' ? o.id : undefined;
266
+ if (name && id)
267
+ return `${name} (${id})`;
268
+ return name ?? id ?? JSON.stringify(c);
269
+ }
270
+ return String(c);
271
+ })
272
+ .slice(0, 6);
273
+ console.error(`Did you mean: ${names.join(', ')}?`);
274
+ }
275
+ if (ctx && typeof ctx.hint === 'string') {
276
+ console.error(ctx.hint);
277
+ }
249
278
  process.exit(2);
250
279
  }
251
280
  if (payload.kind === 'guard') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchbot/openapi-cli",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
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",