@switchbot/openapi-cli 2.4.0 → 2.5.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 +45 -4
- package/dist/commands/agent-bootstrap.js +6 -0
- package/dist/commands/batch.js +65 -25
- package/dist/commands/cache.js +18 -1
- package/dist/commands/capabilities.js +8 -0
- package/dist/commands/config.js +6 -6
- package/dist/commands/device-meta.js +18 -1
- package/dist/commands/devices.js +125 -53
- package/dist/commands/events.js +70 -32
- package/dist/commands/expand.js +7 -5
- package/dist/commands/history.js +124 -24
- package/dist/commands/mcp.js +178 -43
- package/dist/commands/plan.js +12 -2
- package/dist/commands/scenes.js +52 -1
- package/dist/commands/schema.js +6 -0
- package/dist/commands/watch.js +7 -0
- package/dist/devices/cache.js +61 -26
- package/dist/devices/history-agg.js +138 -0
- package/dist/devices/history-query.js +1 -1
- package/dist/index.js +7 -0
- package/dist/mcp/device-history.js +20 -9
- package/dist/utils/arg-parsers.js +2 -1
- package/dist/utils/audit.js +1 -1
- package/dist/utils/filter.js +102 -39
- package/dist/utils/flags.js +3 -1
- package/dist/utils/format.js +11 -3
- package/dist/utils/name-resolver.js +11 -3
- package/dist/utils/output.js +37 -6
- package/dist/version.js +4 -0
- package/package.json +1 -1
package/dist/utils/filter.js
CHANGED
|
@@ -4,42 +4,109 @@ export class FilterSyntaxError extends Error {
|
|
|
4
4
|
this.name = 'FilterSyntaxError';
|
|
5
5
|
}
|
|
6
6
|
}
|
|
7
|
-
const VALID_KEYS = ['type', 'family', 'room', 'category'];
|
|
8
7
|
/**
|
|
9
|
-
* Parse a filter expression
|
|
8
|
+
* Parse a comma-separated filter expression into discrete clauses.
|
|
10
9
|
*
|
|
11
|
-
* Grammar:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* Grammar (per clause, recognition order):
|
|
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
|
|
14
|
+
* as exact for specific keys via matchClause's
|
|
15
|
+
* `exactKeys` option).
|
|
17
16
|
*
|
|
18
|
-
*
|
|
17
|
+
* `allowedKeys` is command-specific: `devices list` uses
|
|
18
|
+
* {type,name,category,room}; `devices batch` uses {type,family,room,category};
|
|
19
|
+
* `events tail` uses {deviceId,type}.
|
|
19
20
|
*/
|
|
20
|
-
export function
|
|
21
|
+
export function parseFilterExpr(expr, allowedKeys) {
|
|
21
22
|
if (!expr)
|
|
22
23
|
return [];
|
|
23
24
|
const parts = expr.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
|
|
24
25
|
const clauses = [];
|
|
25
26
|
for (const part of parts) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
const regexMatch = /^([^=~]+)=\/(.*)\/$/.exec(part);
|
|
28
|
+
const tildeIdx = part.indexOf('~');
|
|
29
|
+
const eqIdx = part.indexOf('=');
|
|
30
|
+
let key;
|
|
31
|
+
let op;
|
|
32
|
+
let raw;
|
|
33
|
+
let regex;
|
|
34
|
+
if (regexMatch) {
|
|
35
|
+
key = regexMatch[1].trim();
|
|
36
|
+
op = 'regex';
|
|
37
|
+
raw = regexMatch[2];
|
|
38
|
+
try {
|
|
39
|
+
regex = new RegExp(raw, 'i');
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw new FilterSyntaxError(`Invalid regex in --filter "${part}": ${err.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (tildeIdx !== -1 && (eqIdx === -1 || tildeIdx < eqIdx)) {
|
|
46
|
+
key = part.slice(0, tildeIdx).trim();
|
|
47
|
+
op = 'sub';
|
|
48
|
+
raw = part.slice(tildeIdx + 1).trim();
|
|
49
|
+
if (raw.startsWith('=')) {
|
|
50
|
+
throw new FilterSyntaxError(`Invalid filter clause "${part}" — "~=" is no longer supported. Use "${key}~${raw.slice(1)}" instead.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (eqIdx !== -1) {
|
|
54
|
+
key = part.slice(0, eqIdx).trim();
|
|
55
|
+
op = 'eq';
|
|
56
|
+
raw = part.slice(eqIdx + 1).trim();
|
|
29
57
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const value = m[3].trim();
|
|
33
|
-
if (!VALID_KEYS.includes(key)) {
|
|
34
|
-
throw new FilterSyntaxError(`Unknown filter key "${key}" — supported: ${VALID_KEYS.join(', ')}`);
|
|
58
|
+
else {
|
|
59
|
+
throw new FilterSyntaxError(`Invalid filter clause "${part}" — expected "<key>=<value>", "<key>~<value>", or "<key>=/<regex>/"`);
|
|
35
60
|
}
|
|
36
|
-
if (!
|
|
61
|
+
if (!key) {
|
|
62
|
+
throw new FilterSyntaxError(`Empty key in filter clause "${part}"`);
|
|
63
|
+
}
|
|
64
|
+
if (!raw) {
|
|
37
65
|
throw new FilterSyntaxError(`Empty value for filter clause "${part}"`);
|
|
38
66
|
}
|
|
39
|
-
|
|
67
|
+
if (!allowedKeys.includes(key)) {
|
|
68
|
+
throw new FilterSyntaxError(`Unknown filter key "${key}" — supported: ${allowedKeys.join(', ')}`);
|
|
69
|
+
}
|
|
70
|
+
clauses.push({ key, op, raw, regex });
|
|
40
71
|
}
|
|
41
72
|
return clauses;
|
|
42
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Match a single candidate string against a clause.
|
|
76
|
+
*
|
|
77
|
+
* - `regex` → RegExp.test against the candidate (case-insensitive by construction).
|
|
78
|
+
* - `sub` → case-insensitive substring.
|
|
79
|
+
* - `eq` → case-insensitive substring, except for keys listed in
|
|
80
|
+
* `exactKeys`, which get case-insensitive exact comparison.
|
|
81
|
+
* Default `exactKeys` is `['category']` to preserve the existing
|
|
82
|
+
* list/batch behavior for that key.
|
|
83
|
+
*/
|
|
84
|
+
export function matchClause(candidate, clause, options) {
|
|
85
|
+
if (candidate === undefined)
|
|
86
|
+
return false;
|
|
87
|
+
if (clause.op === 'regex') {
|
|
88
|
+
return clause.regex.test(candidate);
|
|
89
|
+
}
|
|
90
|
+
const cLower = candidate.toLowerCase();
|
|
91
|
+
const vLower = clause.raw.toLowerCase();
|
|
92
|
+
if (clause.op === 'sub') {
|
|
93
|
+
return cLower.includes(vLower);
|
|
94
|
+
}
|
|
95
|
+
const exactKeys = options?.exactKeys ?? ['category'];
|
|
96
|
+
if (exactKeys.includes(clause.key)) {
|
|
97
|
+
return cLower === vLower;
|
|
98
|
+
}
|
|
99
|
+
return cLower.includes(vLower);
|
|
100
|
+
}
|
|
101
|
+
const BATCH_KEYS = ['type', 'family', 'room', 'category'];
|
|
102
|
+
/**
|
|
103
|
+
* Back-compat narrow signature: parses with the batch key set. Callers that
|
|
104
|
+
* need a different key set (list, events tail) should call parseFilterExpr
|
|
105
|
+
* directly.
|
|
106
|
+
*/
|
|
107
|
+
export function parseFilter(expr) {
|
|
108
|
+
return parseFilterExpr(expr, BATCH_KEYS);
|
|
109
|
+
}
|
|
43
110
|
/** Normalize a physical / IR device entry to the shape the filter matcher expects. */
|
|
44
111
|
function toFilterable(d, isPhysical, hubLocation) {
|
|
45
112
|
if (isPhysical) {
|
|
@@ -62,27 +129,23 @@ function toFilterable(d, isPhysical, hubLocation) {
|
|
|
62
129
|
category: 'ir',
|
|
63
130
|
};
|
|
64
131
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return candidate.toLowerCase().includes(clause.value.toLowerCase());
|
|
132
|
+
function candidateFor(d, key) {
|
|
133
|
+
switch (key) {
|
|
134
|
+
case 'type':
|
|
135
|
+
return d.type;
|
|
136
|
+
case 'family':
|
|
137
|
+
return d.family;
|
|
138
|
+
case 'room':
|
|
139
|
+
return d.room;
|
|
140
|
+
case 'category':
|
|
141
|
+
return d.category;
|
|
142
|
+
default:
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
79
145
|
}
|
|
80
146
|
/**
|
|
81
147
|
* Apply the parsed clauses to a mixed list of physical devices + IR remotes.
|
|
82
|
-
* Returns the
|
|
83
|
-
*
|
|
84
|
-
* `hubLocation` (optional) allows family/room filters to match IR remotes by
|
|
85
|
-
* the Hub-inherited location.
|
|
148
|
+
* Returns the filterable entries that satisfy every clause.
|
|
86
149
|
*/
|
|
87
150
|
export function applyFilter(clauses, deviceList, infraredRemoteList, hubLocation) {
|
|
88
151
|
const candidates = [
|
|
@@ -91,5 +154,5 @@ export function applyFilter(clauses, deviceList, infraredRemoteList, hubLocation
|
|
|
91
154
|
];
|
|
92
155
|
if (clauses.length === 0)
|
|
93
156
|
return candidates;
|
|
94
|
-
return candidates.filter((c) => clauses.every((clause) =>
|
|
157
|
+
return candidates.filter((c) => clauses.every((clause) => matchClause(candidateFor(c, clause.key), clause)));
|
|
95
158
|
}
|
package/dist/utils/flags.js
CHANGED
|
@@ -92,7 +92,7 @@ export function isQuotaDisabled() {
|
|
|
92
92
|
}
|
|
93
93
|
const DEFAULT_LIST_TTL_MS = 60 * 60 * 1000;
|
|
94
94
|
function parseDurationToMs(v) {
|
|
95
|
-
const m = /^(\d+)(ms|s|m|h)?$/.exec(v.trim().toLowerCase());
|
|
95
|
+
const m = /^(\d+)(ms|s|m|h|d|w)?$/.exec(v.trim().toLowerCase());
|
|
96
96
|
if (!m)
|
|
97
97
|
return null;
|
|
98
98
|
const n = Number(m[1]);
|
|
@@ -104,6 +104,8 @@ function parseDurationToMs(v) {
|
|
|
104
104
|
case 's': return n * 1000;
|
|
105
105
|
case 'm': return n * 60 * 1000;
|
|
106
106
|
case 'h': return n * 60 * 60 * 1000;
|
|
107
|
+
case 'd': return n * 24 * 60 * 60 * 1000;
|
|
108
|
+
case 'w': return n * 7 * 24 * 60 * 60 * 1000;
|
|
107
109
|
default: return null;
|
|
108
110
|
}
|
|
109
111
|
}
|
package/dist/utils/format.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { printTable, printJson, isJsonMode, UsageError } from './output.js';
|
|
1
|
+
import { printTable, printJson, isJsonMode, UsageError, emitJsonError } from './output.js';
|
|
2
2
|
import { getFormat, getFields } from './flags.js';
|
|
3
3
|
import { dump as yamlDump } from 'js-yaml';
|
|
4
4
|
export function parseFormat(flag) {
|
|
@@ -12,10 +12,11 @@ export function parseFormat(flag) {
|
|
|
12
12
|
case 'tsv': return 'tsv';
|
|
13
13
|
case 'yaml': return 'yaml';
|
|
14
14
|
case 'id': return 'id';
|
|
15
|
+
case 'markdown': return 'markdown';
|
|
15
16
|
default: {
|
|
16
|
-
const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id.`;
|
|
17
|
+
const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id, markdown.`;
|
|
17
18
|
if (isJsonMode()) {
|
|
18
|
-
|
|
19
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
19
20
|
}
|
|
20
21
|
else {
|
|
21
22
|
console.error(msg);
|
|
@@ -67,6 +68,13 @@ export function renderRows(headers, rows, format, fields, aliases) {
|
|
|
67
68
|
const filtered = filterFields(headers, rows, fields, aliases);
|
|
68
69
|
const h = filtered.headers;
|
|
69
70
|
const r = filtered.rows;
|
|
71
|
+
// Markdown format is rendered as table with markdown style forced regardless
|
|
72
|
+
// of the user's --table-style, so `--format markdown` is a self-contained
|
|
73
|
+
// contract (bug #8).
|
|
74
|
+
if (format === 'markdown') {
|
|
75
|
+
printTable(h, r, 'markdown');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
70
78
|
switch (format) {
|
|
71
79
|
case 'table':
|
|
72
80
|
printTable(h, r);
|
|
@@ -2,7 +2,7 @@ import { loadCache } from '../devices/cache.js';
|
|
|
2
2
|
import { loadDeviceMeta } from '../devices/device-meta.js';
|
|
3
3
|
import { levenshtein, normalizeDeviceName } from './string.js';
|
|
4
4
|
import { UsageError, StructuredUsageError } from './output.js';
|
|
5
|
-
const ALL_STRATEGIES = [
|
|
5
|
+
export const ALL_STRATEGIES = [
|
|
6
6
|
'exact', 'prefix', 'substring', 'fuzzy', 'first', 'require-unique',
|
|
7
7
|
];
|
|
8
8
|
export function isValidStrategy(s) {
|
|
@@ -34,9 +34,17 @@ function resolveDeviceByName(query, opts = {}) {
|
|
|
34
34
|
const alias = meta.devices[deviceId]?.alias;
|
|
35
35
|
const rawName = normalizeDeviceName(device.name);
|
|
36
36
|
const normAlias = alias ? normalizeDeviceName(alias) : null;
|
|
37
|
-
// exact alias/name wins
|
|
37
|
+
// exact alias/name wins immediately for lenient strategies.
|
|
38
|
+
// Under require-unique we must NOT short-circuit: there may be other devices
|
|
39
|
+
// that also match (e.g. via substring), making the result ambiguous. Collect
|
|
40
|
+
// the exact hit as a candidate and let the full ambiguity check decide below.
|
|
38
41
|
if ((normAlias && normAlias === q) || rawName === q) {
|
|
39
|
-
|
|
42
|
+
if (strategy !== 'require-unique') {
|
|
43
|
+
return { ok: true, deviceId };
|
|
44
|
+
}
|
|
45
|
+
// require-unique: treat exact match as a high-priority candidate (score 0)
|
|
46
|
+
candidates.push({ deviceId, name: device.name, score: 0 });
|
|
47
|
+
continue;
|
|
40
48
|
}
|
|
41
49
|
if (strategy === 'exact')
|
|
42
50
|
continue;
|
package/dist/utils/output.js
CHANGED
|
@@ -9,6 +9,25 @@ export function isJsonMode() {
|
|
|
9
9
|
export function printJson(data) {
|
|
10
10
|
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, data }, null, 2));
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Emit a structured JSON error envelope on stdout.
|
|
14
|
+
*
|
|
15
|
+
* Bug #SYS-1: Under `--json`, both success and error payloads must share
|
|
16
|
+
* the same output channel (stdout) so a single `cli --json ... | jq` pipe
|
|
17
|
+
* can decode either shape. Use this helper everywhere that previously
|
|
18
|
+
* called `console.error(JSON.stringify({ error: ... }))` in --json mode.
|
|
19
|
+
*
|
|
20
|
+
* The envelope is always `{ schemaVersion, error }` — callers pass only the
|
|
21
|
+
* error payload. Also emits a brief human-readable line on stderr when a
|
|
22
|
+
* TTY is attached, so interactive runs still see the failure.
|
|
23
|
+
*/
|
|
24
|
+
export function emitJsonError(errorPayload) {
|
|
25
|
+
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: errorPayload }));
|
|
26
|
+
if (process.stderr.isTTY) {
|
|
27
|
+
const msg = typeof errorPayload.message === 'string' ? errorPayload.message : 'Error';
|
|
28
|
+
console.error(chalk.red(msg));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
12
31
|
function escapeMarkdownCell(s) {
|
|
13
32
|
// Pipes break markdown table layout; backslash-escape them. Collapse
|
|
14
33
|
// newlines into <br> so each row stays on one line.
|
|
@@ -46,8 +65,8 @@ const ASCII_BORDER_CHARS = {
|
|
|
46
65
|
left: '|', 'left-mid': '+', mid: '-', 'mid-mid': '+',
|
|
47
66
|
right: '|', 'right-mid': '+', middle: '|',
|
|
48
67
|
};
|
|
49
|
-
export function printTable(headers, rows) {
|
|
50
|
-
const style = getTableStyle();
|
|
68
|
+
export function printTable(headers, rows, styleOverride) {
|
|
69
|
+
const style = styleOverride ?? getTableStyle();
|
|
51
70
|
if (style === 'markdown') {
|
|
52
71
|
console.log(renderMarkdownTable(headers, rows));
|
|
53
72
|
return;
|
|
@@ -119,11 +138,12 @@ export class StructuredUsageError extends Error {
|
|
|
119
138
|
function classifyApiError(code) {
|
|
120
139
|
switch (code) {
|
|
121
140
|
case 151:
|
|
122
|
-
case 160:
|
|
141
|
+
case 160:
|
|
142
|
+
case 3005: return 'command-not-supported';
|
|
123
143
|
case 152: return 'device-not-found';
|
|
124
144
|
case 161:
|
|
125
145
|
case 171: return 'device-offline';
|
|
126
|
-
case 190: return 'device-
|
|
146
|
+
case 190: return 'device-internal-error';
|
|
127
147
|
case 401: return 'auth-failed';
|
|
128
148
|
case 429: return 'quota-exceeded';
|
|
129
149
|
default: return 'unknown-api-error';
|
|
@@ -212,7 +232,16 @@ export function handleError(error) {
|
|
|
212
232
|
}
|
|
213
233
|
const payload = buildErrorPayload(error);
|
|
214
234
|
if (isJsonMode()) {
|
|
215
|
-
|
|
235
|
+
// Bug #SYS-1: Under --json, route the structured envelope to stdout so
|
|
236
|
+
// `cli --json ... | jq` pipelines can decode the error shape exactly
|
|
237
|
+
// the same way they decode success. Previously it went to stderr, which
|
|
238
|
+
// silently broke every error-path pipeline. TTY users still get a
|
|
239
|
+
// terse human-readable line on stderr so interactive runs don't look
|
|
240
|
+
// like the process simply exited.
|
|
241
|
+
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: payload }));
|
|
242
|
+
if (process.stderr.isTTY) {
|
|
243
|
+
console.error(chalk.red(payload.message));
|
|
244
|
+
}
|
|
216
245
|
process.exit(payload.code === 2 ? 2 : 1);
|
|
217
246
|
}
|
|
218
247
|
if (payload.kind === 'usage') {
|
|
@@ -247,11 +276,13 @@ function errorHint(code) {
|
|
|
247
276
|
case 171:
|
|
248
277
|
return 'The Hub itself is offline — check its power and Wi-Fi.';
|
|
249
278
|
case 190:
|
|
250
|
-
return
|
|
279
|
+
return 'SwitchBot API code 190 is a generic internal error. Common causes: invalid deviceId, unsupported command/parameter, or the endpoint does not apply (e.g., "webhook query" with no webhook configured). Verify with --verbose.';
|
|
251
280
|
case 401:
|
|
252
281
|
return "Re-run 'switchbot config set-token <token> <secret>', or verify SWITCHBOT_TOKEN / SWITCHBOT_SECRET.";
|
|
253
282
|
case 429:
|
|
254
283
|
return 'Daily quota is 10,000 requests/account — retry after midnight UTC.';
|
|
284
|
+
case 3005:
|
|
285
|
+
return "SwitchBot rejected the command as invalid for this specific device model. For IR remotes, this often means the command works only on --type customize (user-learned buttons). Try 'switchbot devices commands <type>' or check the device's capabilities.";
|
|
255
286
|
default:
|
|
256
287
|
return null;
|
|
257
288
|
}
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchbot/openapi-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.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",
|