@switchbot/openapi-cli 2.5.1 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
 
@@ -237,7 +238,7 @@ switchbot devices status <deviceId>
237
238
  switchbot devices status <deviceId> --json
238
239
 
239
240
  # Resolve device by fuzzy name instead of ID (status, command, describe, expand, watch)
240
- switchbot devices status --name "客厅空调"
241
+ switchbot devices status --name "Living Room AC"
241
242
  switchbot devices command --name "Office Light" turnOn
242
243
  switchbot devices describe --name "Kitchen Bot"
243
244
 
@@ -263,18 +264,21 @@ 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.
279
+ A clause with an empty value (e.g. `name~`, `type=`) is rejected with exit 2 —
280
+ the parser refuses to guess whether an empty value means "no constraint" or
281
+ "match empty string". Drop the clause outright to remove the constraint.
278
282
 
279
283
  #### Parameter formats
280
284
 
@@ -299,7 +303,9 @@ Generic parameter shapes (which one applies is decided by the device — see the
299
303
  | `<json object>` | `'{"action":"sweep","param":{"fanLevel":2,"times":1}}'` |
300
304
  | Custom IR button | `devices command <id> MyButton --type customize` |
301
305
 
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.
306
+ 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.
307
+
308
+ 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
309
 
304
310
  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
311
 
@@ -313,7 +319,7 @@ Some commands require a packed string like `"26,2,2,on"`. `devices expand` build
313
319
  # Air Conditioner — setAll
314
320
  switchbot devices expand <acId> setAll --temp 26 --mode cool --fan low --power on
315
321
  # Resolve by name
316
- switchbot devices expand --name "客厅空调" setAll --temp 26 --mode cool --fan low --power on
322
+ switchbot devices expand --name "Living Room AC" setAll --temp 26 --mode cool --fan low --power on
317
323
 
318
324
  # Curtain / Roller Shade — setPosition
319
325
  switchbot devices expand <curtainId> setPosition --position 50 --mode silent
@@ -325,7 +331,7 @@ switchbot devices expand <blindId> setPosition --direction up --angle 50
325
331
  switchbot devices expand <relayId> setMode --channel 1 --mode edge
326
332
  ```
327
333
 
328
- Run `switchbot devices expand <id> <command> --help` to see the available flags for any device command.
334
+ 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
335
 
330
336
  #### `devices explain` — one-shot device summary
331
337
 
@@ -602,7 +608,9 @@ Reads the JSONL audit log (`~/.switchbot/audit.log` by default; override with `-
602
608
 
603
609
  ```bash
604
610
  switchbot catalog show # all 42 built-in types
611
+ switchbot catalog list # alias for `show`
605
612
  switchbot catalog show Bot # one type
613
+ switchbot catalog search Hub # fuzzy match across type / aliases / commands
606
614
  switchbot catalog diff # what a local overlay changes vs built-in
607
615
  switchbot catalog path # location of the local overlay file
608
616
  switchbot catalog refresh # reload local overlay (clears in-process cache)
@@ -624,9 +632,10 @@ Exports the effective catalog in a machine-readable format. Pipe the output into
624
632
 
625
633
  ```bash
626
634
  switchbot capabilities --json
635
+ switchbot capabilities --used --json # only types seen in the local cache
627
636
  ```
628
637
 
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.
638
+ 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
639
 
631
640
  ### `cache` — inspect and clear local cache
632
641
 
@@ -822,6 +831,21 @@ Bug reports, feature requests, and PRs are welcome.
822
831
  3. Run `npm test` and `npm run build` locally — both must pass.
823
832
  4. Open a pull request against `main`. CI runs on Node 18/20/22; all three must stay green.
824
833
 
834
+ ## Roadmap
835
+
836
+ Tracked for a future v3.x line (OpenClaw B-17 / B-18 / B-19 / B-21) — each is a
837
+ standalone track rather than a bug fix:
838
+
839
+ - **Daemon mode** — long-running local process with a Unix/named-pipe socket so
840
+ repeated MCP or plan invocations don't pay fresh-process startup every call.
841
+ - **`npx @switchbot/mcp-server`** — split the MCP server into its own tiny
842
+ published package so non-CLI users can `npx` it directly without installing
843
+ the full CLI.
844
+ - **`switchbot self-test`** — scripted end-to-end harness that checks a live
845
+ token + a representative device and prints a go/no-go report.
846
+ - **Record / replay** — capture raw request/response pairs into a fixture file
847
+ and replay them offline for deterministic testing and CI.
848
+
825
849
  ## License
826
850
 
827
851
  [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) {
@@ -137,7 +137,7 @@ Safety:
137
137
  hitting the API.
138
138
 
139
139
  Examples:
140
- $ switchbot devices batch turnOff --filter 'type~=Light,family=家里'
140
+ $ switchbot devices batch turnOff --filter 'type~=Light,family=home'
141
141
  $ switchbot devices batch turnOn --ids ID1,ID2,ID3
142
142
  $ switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle -
143
143
  $ switchbot devices batch unlock --filter 'type=Smart Lock' --yes
@@ -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';
@@ -69,7 +70,7 @@ Examples:
69
70
  $ switchbot devices list
70
71
  $ switchbot devices list --wide
71
72
  $ switchbot devices list --format tsv --fields deviceId,deviceName,type,category
72
- $ switchbot devices list --json | jq '.deviceList[] | select(.familyName == "家里")'
73
+ $ switchbot devices list --json | jq '.deviceList[] | select(.familyName == "home")'
73
74
  $ switchbot devices list --json | jq '[.deviceList[], .infraredRemoteList[]] | group_by(.familyName)'
74
75
  $ switchbot devices list --filter type="Air Conditioner"
75
76
  $ switchbot devices list --filter category=ir
@@ -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;
@@ -259,7 +215,7 @@ all field names returned by your specific device, then narrow with --fields.
259
215
 
260
216
  Examples:
261
217
  $ switchbot devices status ABC123DEF456
262
- $ switchbot devices status --name "客厅空调"
218
+ $ switchbot devices status --name "Living Room AC"
263
219
  $ switchbot devices status ABC123DEF456 --json
264
220
  $ switchbot devices status ABC123DEF456 --format yaml
265
221
  $ switchbot devices status ABC123DEF456 --format tsv --fields power,battery
@@ -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);
@@ -50,7 +50,7 @@ Examples:
50
50
  $ switchbot devices expand <blindId> setPosition --direction up --angle 50
51
51
  $ switchbot devices expand <relayId> setMode --channel 1 --mode edge
52
52
  $ switchbot devices expand <acId> setAll --temp 22 --mode heat --fan auto --power on --dry-run
53
- $ switchbot devices expand --name "客厅空调" setAll --temp 26 --mode cool --fan low --power on
53
+ $ switchbot devices expand --name "Living Room AC" setAll --temp 26 --mode cool --fan low --power on
54
54
  `)
55
55
  .action(async (deviceIdArg, commandArg, options) => {
56
56
  let deviceId = '';
@@ -9,6 +9,7 @@ import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, val
9
9
  import { fetchScenes, executeScene } from '../lib/scenes.js';
10
10
  import { findCatalogEntry } from '../devices/catalog.js';
11
11
  import { getCachedDevice } from '../devices/cache.js';
12
+ import { validateParameter } from '../devices/param-validator.js';
12
13
  import { EventSubscriptionManager } from '../mcp/events-subscription.js';
13
14
  import { deviceHistoryStore } from '../mcp/device-history.js';
14
15
  import { queryDeviceHistory } from '../devices/history-query.js';
@@ -273,6 +274,10 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
273
274
  },
274
275
  }, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
275
276
  const effectiveType = commandType ?? 'command';
277
+ let effectiveParameter = parameter;
278
+ // stringifiedParam mirrors the CLI form that validateCommand /
279
+ // validateParameter expect — B-1 runs on the string representation.
280
+ const stringifiedParam = parameter === undefined ? undefined : typeof parameter === 'string' ? parameter : JSON.stringify(parameter);
276
281
  // dryRun early-return — no API call. We still preflight the deviceId
277
282
  // against the local cache so fabricated IDs don't silently pass
278
283
  // validation (bug #SYS-3). Dry-run is meant to catch bad inputs; a
@@ -286,10 +291,24 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
286
291
  context: { deviceId },
287
292
  });
288
293
  }
294
+ // R-2: run B-1 param validation in dry-run too, so dry-run doesn't
295
+ // falsely accept inputs the live API would reject.
296
+ if (effectiveType !== 'customize') {
297
+ const pv = validateParameter(cached.type, command, stringifiedParam);
298
+ if (!pv.ok) {
299
+ return mcpError('usage', 2, pv.error, {
300
+ hint: 'Dry-run rejected the parameter client-side; the API would reject it too.',
301
+ context: { deviceType: cached.type, command, parameter: stringifiedParam },
302
+ });
303
+ }
304
+ if (pv.normalized !== undefined) {
305
+ effectiveParameter = pv.normalized;
306
+ }
307
+ }
289
308
  const wouldSend = {
290
309
  deviceId,
291
310
  command,
292
- parameter: parameter ?? 'default',
311
+ parameter: effectiveParameter ?? 'default',
293
312
  commandType: effectiveType,
294
313
  };
295
314
  const structured = { ok: true, dryRun: true, wouldSend };
@@ -332,16 +351,30 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
332
351
  },
333
352
  });
334
353
  }
335
- // stringifiedParam is what validateCommand expects to decide
336
- // "no-parameter" conflicts mirror the CLI behavior.
337
- const stringifiedParam = parameter === undefined ? undefined : typeof parameter === 'string' ? parameter : JSON.stringify(parameter);
354
+ // validateCommand covers command existence + required/unexpected-parameter.
355
+ // stringifiedParam was computed once at the top of the handler so dry-run
356
+ // and live paths share the same shape.
338
357
  const validation = validateCommand(deviceId, command, stringifiedParam, effectiveType);
339
358
  if (!validation.ok) {
340
359
  return mcpError('usage', 2, validation.error.message, { hint: validation.error.hint, context: { validationKind: validation.error.kind } });
341
360
  }
361
+ // R-2: run B-1 client-side parameter validator (range/format checks).
362
+ // Customize commands (user-defined IR buttons) opt out — the catalog
363
+ // cannot know their expected shape.
364
+ if (effectiveType !== 'customize') {
365
+ const pv = validateParameter(typeName, command, stringifiedParam);
366
+ if (!pv.ok) {
367
+ return mcpError('usage', 2, pv.error, {
368
+ context: { deviceType: typeName, command, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
369
+ });
370
+ }
371
+ if (pv.normalized !== undefined) {
372
+ effectiveParameter = pv.normalized;
373
+ }
374
+ }
342
375
  let result;
343
376
  try {
344
- result = await executeCommand(deviceId, command, parameter, effectiveType, undefined, {
377
+ result = await executeCommand(deviceId, command, effectiveParameter, effectiveType, undefined, {
345
378
  idempotencyKey,
346
379
  });
347
380
  }
@@ -75,7 +75,7 @@ Examples:
75
75
  $ switchbot devices watch ABC123 --fields battery,power --interval 1m
76
76
  $ switchbot devices watch ABC123 DEF456 --interval 30s --max 10
77
77
  $ switchbot devices watch ABC123 --json | jq 'select(.changed.power)'
78
- $ switchbot devices watch --name "客厅空调" --interval 10s
78
+ $ switchbot devices watch --name "Living Room AC" --interval 10s
79
79
  `)
80
80
  .action(async (deviceIds, options) => {
81
81
  try {
@@ -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.1",
4
4
  "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
5
5
  "keywords": [
6
6
  "switchbot",