@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 +27 -6
- package/dist/api/client.js +35 -3
- package/dist/commands/capabilities.js +31 -0
- package/dist/commands/catalog.js +60 -2
- package/dist/commands/devices.js +81 -73
- package/dist/devices/param-validator.js +170 -0
- package/dist/utils/filter.js +25 -7
- package/dist/utils/flags.js +24 -0
- package/dist/utils/name-resolver.js +5 -2
- package/dist/utils/output.js +29 -0
- package/package.json +1 -1
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
|
|
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
|
|
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),
|
|
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.
|
|
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
|
package/dist/api/client.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/commands/catalog.js
CHANGED
|
@@ -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
|
-
.
|
|
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')
|
package/dist/commands/devices.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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 (
|
|
654
|
-
const
|
|
655
|
-
|
|
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 (
|
|
658
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/utils/filter.js
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
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 = /^([
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/utils/flags.js
CHANGED
|
@@ -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(' / ')}
|
|
122
|
-
: `
|
|
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,
|
package/dist/utils/output.js
CHANGED
|
@@ -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.
|
|
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",
|