@switchbot/openapi-cli 2.6.4 → 3.0.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 +385 -103
- package/dist/api/client.js +13 -12
- package/dist/commands/agent-bootstrap.js +67 -16
- package/dist/commands/auth.js +354 -0
- package/dist/commands/batch.js +26 -21
- package/dist/commands/capabilities.js +29 -21
- package/dist/commands/catalog.js +4 -3
- package/dist/commands/config.js +57 -37
- package/dist/commands/devices.js +63 -37
- package/dist/commands/doctor.js +539 -26
- package/dist/commands/events.js +115 -26
- package/dist/commands/expand.js +7 -15
- package/dist/commands/explain.js +10 -7
- package/dist/commands/history.js +12 -18
- package/dist/commands/identity.js +59 -0
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +895 -15
- package/dist/commands/plan.js +111 -15
- package/dist/commands/policy.js +469 -0
- package/dist/commands/rules.js +657 -0
- package/dist/commands/schema.js +20 -12
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/commands/watch.js +15 -2
- package/dist/config.js +14 -0
- package/dist/credentials/backends/file.js +101 -0
- package/dist/credentials/backends/linux.js +129 -0
- package/dist/credentials/backends/macos.js +129 -0
- package/dist/credentials/backends/windows.js +215 -0
- package/dist/credentials/keychain.js +88 -0
- package/dist/credentials/prime.js +52 -0
- package/dist/devices/catalog.js +118 -11
- package/dist/devices/resources.js +270 -0
- package/dist/index.js +39 -4
- package/dist/install/default-steps.js +257 -0
- package/dist/install/preflight.js +212 -0
- package/dist/install/steps.js +67 -0
- package/dist/lib/command-keywords.js +17 -0
- package/dist/lib/devices.js +15 -5
- package/dist/policy/add-rule.js +124 -0
- package/dist/policy/diff.js +91 -0
- package/dist/policy/examples/policy.example.yaml +99 -0
- package/dist/policy/format.js +57 -0
- package/dist/policy/load.js +61 -0
- package/dist/policy/migrate.js +67 -0
- package/dist/policy/schema/v0.2.json +302 -0
- package/dist/policy/schema.js +18 -0
- package/dist/policy/validate.js +262 -0
- package/dist/rules/action.js +205 -0
- package/dist/rules/audit-query.js +89 -0
- package/dist/rules/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +567 -0
- package/dist/rules/matcher.js +230 -0
- package/dist/rules/pid-file.js +95 -0
- package/dist/rules/quiet-hours.js +45 -0
- package/dist/rules/suggest.js +95 -0
- package/dist/rules/throttle.js +78 -0
- package/dist/rules/types.js +34 -0
- package/dist/rules/webhook-listener.js +223 -0
- package/dist/rules/webhook-token.js +90 -0
- package/dist/schema/field-aliases.js +95 -0
- package/dist/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/dist/utils/help-json.js +54 -0
- package/dist/utils/output.js +17 -0
- package/package.json +12 -4
|
@@ -1,7 +1,27 @@
|
|
|
1
|
-
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
1
|
+
import { getEffectiveCatalog, deriveSafetyTier, deriveStatusQueries, } from '../devices/catalog.js';
|
|
2
|
+
import { RESOURCE_CATALOG } from '../devices/resources.js';
|
|
2
3
|
import { loadCache } from '../devices/cache.js';
|
|
3
4
|
import { printJson } from '../utils/output.js';
|
|
4
5
|
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
6
|
+
import { IDENTITY } from './identity.js';
|
|
7
|
+
/** Collect the distinct catalog safety tiers actually used across the given entries. Sorted. */
|
|
8
|
+
function collectSafetyTiersInUse(entries) {
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
for (const e of entries) {
|
|
11
|
+
for (const c of e.commands) {
|
|
12
|
+
seen.add(deriveSafetyTier(c, e));
|
|
13
|
+
}
|
|
14
|
+
// P11: statusQueries contribute the 'read' tier.
|
|
15
|
+
if (deriveStatusQueries(e).length > 0) {
|
|
16
|
+
seen.add('read');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return [...seen].sort();
|
|
20
|
+
}
|
|
21
|
+
/** P11: total number of read-only queries exposed across the catalog. */
|
|
22
|
+
function countStatusQueries(entries) {
|
|
23
|
+
return entries.reduce((n, e) => n + deriveStatusQueries(e).length, 0);
|
|
24
|
+
}
|
|
5
25
|
const AGENT_GUIDE = {
|
|
6
26
|
safetyTiers: {
|
|
7
27
|
read: 'No state mutation; safe to call freely — does not consume quota unless noted.',
|
|
@@ -68,23 +88,6 @@ const COMMAND_META = {
|
|
|
68
88
|
function metaFor(command) {
|
|
69
89
|
return COMMAND_META[command] ?? null;
|
|
70
90
|
}
|
|
71
|
-
const IDENTITY = {
|
|
72
|
-
product: 'SwitchBot',
|
|
73
|
-
domain: 'IoT smart home device control',
|
|
74
|
-
vendor: 'Wonderlabs, Inc.',
|
|
75
|
-
apiVersion: 'v1.1',
|
|
76
|
-
apiDocs: 'https://github.com/OpenWonderLabs/SwitchBotAPI',
|
|
77
|
-
deviceCategories: {
|
|
78
|
-
physical: 'Wi-Fi/BLE devices controllable via Cloud API (Hub required for BLE-only)',
|
|
79
|
-
ir: 'IR remote devices learned by a SwitchBot Hub (TV, AC, etc.)',
|
|
80
|
-
},
|
|
81
|
-
constraints: {
|
|
82
|
-
quotaPerDay: 10000,
|
|
83
|
-
bleRequiresHub: true,
|
|
84
|
-
authMethod: 'HMAC-SHA256 token+secret',
|
|
85
|
-
},
|
|
86
|
-
agentGuide: 'docs/agent-guide.md',
|
|
87
|
-
};
|
|
88
91
|
const MCP_TOOLS = [
|
|
89
92
|
'list_devices',
|
|
90
93
|
'get_device_status',
|
|
@@ -147,7 +150,7 @@ export function registerCapabilitiesCommand(program) {
|
|
|
147
150
|
const SURFACES = ['cli', 'mcp', 'plan', 'mqtt', 'all'];
|
|
148
151
|
program
|
|
149
152
|
.command('capabilities')
|
|
150
|
-
.description('Print a machine-readable manifest of CLI capabilities (for agent bootstrap)')
|
|
153
|
+
.description('Print a machine-readable manifest of SwitchBot CLI capabilities (for AI agent bootstrap)')
|
|
151
154
|
.option('--minimal', 'Omit per-subcommand flag details to reduce output size (alias of --compact)')
|
|
152
155
|
.option('--compact', 'Emit a compact summary: identity + leaf command list with safety metadata only')
|
|
153
156
|
.option('--used', 'Restrict the catalog summary to device types present in the local cache. Mirrors `schema export --used`.')
|
|
@@ -249,9 +252,12 @@ export function registerCapabilitiesCommand(program) {
|
|
|
249
252
|
catalog: {
|
|
250
253
|
typeCount: catalog.length,
|
|
251
254
|
roles,
|
|
252
|
-
destructiveCommandCount: catalog.reduce((n, e) => n + e.commands.filter((c) => c
|
|
255
|
+
destructiveCommandCount: catalog.reduce((n, e) => n + e.commands.filter((c) => deriveSafetyTier(c, e) === 'destructive').length, 0),
|
|
256
|
+
safetyTiersInUse: collectSafetyTiersInUse(catalog),
|
|
253
257
|
readOnlyTypeCount: catalog.filter((e) => e.readOnly).length,
|
|
258
|
+
readOnlyQueryCount: countStatusQueries(catalog),
|
|
254
259
|
},
|
|
260
|
+
resources: RESOURCE_CATALOG,
|
|
255
261
|
};
|
|
256
262
|
if (!compact)
|
|
257
263
|
payload.generatedAt = new Date().toISOString();
|
|
@@ -273,8 +279,10 @@ export function registerCapabilitiesCommand(program) {
|
|
|
273
279
|
payload.catalog = {
|
|
274
280
|
typeCount: filteredCatalog.length,
|
|
275
281
|
roles: [...new Set(filteredCatalog.map((e) => e.role ?? 'other'))].sort(),
|
|
276
|
-
destructiveCommandCount: filteredCatalog.reduce((n, e) => n + e.commands.filter((c) => c
|
|
282
|
+
destructiveCommandCount: filteredCatalog.reduce((n, e) => n + e.commands.filter((c) => deriveSafetyTier(c, e) === 'destructive').length, 0),
|
|
283
|
+
safetyTiersInUse: collectSafetyTiersInUse(filteredCatalog),
|
|
277
284
|
readOnlyTypeCount: filteredCatalog.filter((e) => e.readOnly).length,
|
|
285
|
+
readOnlyQueryCount: countStatusQueries(filteredCatalog),
|
|
278
286
|
};
|
|
279
287
|
payload.usedFilter = { applied: true, typesInCache: [...seen].sort() };
|
|
280
288
|
}
|
package/dist/commands/catalog.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { enumArg } from '../utils/arg-parsers.js';
|
|
2
2
|
import { printTable, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
3
3
|
import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
|
|
4
|
-
import { DEVICE_CATALOG, findCatalogEntry, getCatalogOverlayPath, getEffectiveCatalog, loadCatalogOverlay, resetCatalogOverlayCache, } from '../devices/catalog.js';
|
|
4
|
+
import { DEVICE_CATALOG, findCatalogEntry, getCatalogOverlayPath, getEffectiveCatalog, loadCatalogOverlay, resetCatalogOverlayCache, deriveSafetyTier, } from '../devices/catalog.js';
|
|
5
5
|
export function registerCatalogCommand(program) {
|
|
6
6
|
const SOURCES = ['built-in', 'overlay', 'effective'];
|
|
7
7
|
const catalog = program
|
|
8
8
|
.command('catalog')
|
|
9
|
-
.description('Inspect the
|
|
9
|
+
.description('Inspect the SwitchBot device catalog (supported device types + any local overlay)')
|
|
10
10
|
.addHelpText('after', `
|
|
11
11
|
This CLI ships with a static catalog of known SwitchBot device types and
|
|
12
12
|
their commands (see 'switchbot devices types'). You can extend or override
|
|
@@ -341,10 +341,11 @@ function renderEntry(entry) {
|
|
|
341
341
|
else {
|
|
342
342
|
console.log('\nCommands:');
|
|
343
343
|
const rows = entry.commands.map((c) => {
|
|
344
|
+
const tier = deriveSafetyTier(c, entry);
|
|
344
345
|
const flags = [];
|
|
345
346
|
if (c.commandType === 'customize')
|
|
346
347
|
flags.push('customize');
|
|
347
|
-
if (
|
|
348
|
+
if (tier === 'destructive')
|
|
348
349
|
flags.push('!destructive');
|
|
349
350
|
const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command;
|
|
350
351
|
return [label, c.parameter, c.description];
|
package/dist/commands/config.js
CHANGED
|
@@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
4
4
|
import { stringArg } from '../utils/arg-parsers.js';
|
|
5
5
|
import { intArg } from '../utils/arg-parsers.js';
|
|
6
6
|
import { saveConfig, showConfig, getConfigSummary, listProfiles, readProfileMeta } from '../config.js';
|
|
7
|
-
import { isJsonMode, printJson,
|
|
7
|
+
import { isJsonMode, printJson, exitWithError } from '../utils/output.js';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
function parseEnvFile(file) {
|
|
10
10
|
const out = {};
|
|
@@ -89,6 +89,36 @@ async function promptSecret(question) {
|
|
|
89
89
|
void mutableStdout;
|
|
90
90
|
});
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Interactive echo-off prompt for token + secret. Used by both
|
|
94
|
+
* `switchbot config set-token` and the install orchestrator. Throws if
|
|
95
|
+
* stdin is not a TTY.
|
|
96
|
+
*/
|
|
97
|
+
export async function promptTokenAndSecret() {
|
|
98
|
+
if (!process.stdin.isTTY) {
|
|
99
|
+
throw new Error('interactive prompt requires a TTY');
|
|
100
|
+
}
|
|
101
|
+
const token = (await promptSecret('Token: ')).trim();
|
|
102
|
+
const secret = (await promptSecret('Secret: ')).trim();
|
|
103
|
+
if (!token || !secret) {
|
|
104
|
+
throw new Error('token and secret are both required');
|
|
105
|
+
}
|
|
106
|
+
return { token, secret };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Read a two-line credential file (line 1 = token, line 2 = secret)
|
|
110
|
+
* and unlink it on success. The installer's `--token-file` escape
|
|
111
|
+
* hatch uses this; keeps credentials off the command line and shell
|
|
112
|
+
* history for CI-style installs.
|
|
113
|
+
*/
|
|
114
|
+
export function readCredentialsFile(filePath) {
|
|
115
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
116
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
|
|
117
|
+
if (lines.length < 2) {
|
|
118
|
+
throw new Error(`credential file ${filePath} must contain two lines: token, then secret`);
|
|
119
|
+
}
|
|
120
|
+
return { token: lines[0].trim(), secret: lines[1].trim() };
|
|
121
|
+
}
|
|
92
122
|
export function registerConfigCommand(program) {
|
|
93
123
|
const config = program
|
|
94
124
|
.command('config')
|
|
@@ -145,14 +175,11 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
145
175
|
}
|
|
146
176
|
if (options.fromEnvFile) {
|
|
147
177
|
if (!fs.existsSync(options.fromEnvFile)) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
console.error(msg);
|
|
154
|
-
}
|
|
155
|
-
process.exit(2);
|
|
178
|
+
exitWithError({
|
|
179
|
+
code: 2,
|
|
180
|
+
kind: 'usage',
|
|
181
|
+
message: `--from-env-file: file not found: ${options.fromEnvFile}`,
|
|
182
|
+
});
|
|
156
183
|
}
|
|
157
184
|
const parsed = parseEnvFile(options.fromEnvFile);
|
|
158
185
|
token = token ?? parsed.token;
|
|
@@ -160,37 +187,33 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
160
187
|
}
|
|
161
188
|
if (options.fromOp) {
|
|
162
189
|
if (!options.opSecret) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
console.error(msg);
|
|
169
|
-
}
|
|
170
|
-
process.exit(2);
|
|
190
|
+
exitWithError({
|
|
191
|
+
code: 2,
|
|
192
|
+
kind: 'usage',
|
|
193
|
+
message: '--from-op requires --op-secret <ref> for the secret reference.',
|
|
194
|
+
});
|
|
171
195
|
}
|
|
172
196
|
try {
|
|
173
197
|
token = readFromOp(options.fromOp);
|
|
174
198
|
secret = readFromOp(options.opSecret);
|
|
175
199
|
}
|
|
176
200
|
catch (err) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
console.error('Ensure the "op" CLI is installed and authenticated (op signin).');
|
|
184
|
-
}
|
|
185
|
-
process.exit(1);
|
|
201
|
+
exitWithError({
|
|
202
|
+
code: 1,
|
|
203
|
+
kind: 'runtime',
|
|
204
|
+
message: `1Password CLI read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
205
|
+
hint: 'Ensure the "op" CLI is installed and authenticated (op signin).',
|
|
206
|
+
});
|
|
186
207
|
}
|
|
187
208
|
}
|
|
188
209
|
// No credentials yet and stdin is a TTY → interactive prompt (safest path).
|
|
189
210
|
if ((!token || !secret) && !options.fromEnvFile && !options.fromOp && process.stdin.isTTY) {
|
|
190
211
|
if (isJsonMode()) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
212
|
+
exitWithError({
|
|
213
|
+
code: 2,
|
|
214
|
+
kind: 'usage',
|
|
215
|
+
message: 'Interactive mode cannot run under --json. Provide token/secret via --from-env-file, --from-op, or positional args.',
|
|
216
|
+
});
|
|
194
217
|
}
|
|
195
218
|
try {
|
|
196
219
|
if (!token)
|
|
@@ -204,14 +227,11 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
204
227
|
}
|
|
205
228
|
}
|
|
206
229
|
if (!token || !secret) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
console.error(msg);
|
|
213
|
-
}
|
|
214
|
-
process.exit(2);
|
|
230
|
+
exitWithError({
|
|
231
|
+
code: 2,
|
|
232
|
+
kind: 'usage',
|
|
233
|
+
message: 'Missing token/secret. Run interactively, or use --from-env-file / --from-op, or pass positional arguments (discouraged).',
|
|
234
|
+
});
|
|
215
235
|
}
|
|
216
236
|
saveConfig(token, secret, {
|
|
217
237
|
label: options.label,
|
package/dist/commands/devices.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
2
|
-
import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError,
|
|
2
|
+
import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, exitWithError } from '../utils/output.js';
|
|
3
3
|
import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
|
|
4
|
-
import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js';
|
|
4
|
+
import { findCatalogEntry, getEffectiveCatalog, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
|
|
5
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';
|
|
@@ -15,7 +15,7 @@ import { registerExpandCommand } from './expand.js';
|
|
|
15
15
|
import { registerDevicesMetaCommand } from './device-meta.js';
|
|
16
16
|
import { isDryRun } from '../utils/flags.js';
|
|
17
17
|
import { DryRunSignal } from '../api/client.js';
|
|
18
|
-
import { resolveField, listSupportedFieldInputs } from '../schema/field-aliases.js';
|
|
18
|
+
import { resolveField, resolveFieldList, listSupportedFieldInputs } from '../schema/field-aliases.js';
|
|
19
19
|
const EXPAND_HINTS = {
|
|
20
20
|
'Air Conditioner': { command: 'setAll', flags: '--temp 26 --mode cool --fan low --power on' },
|
|
21
21
|
'Curtain': { command: 'setPosition', flags: '--position 50 --mode silent' },
|
|
@@ -88,7 +88,7 @@ Examples:
|
|
|
88
88
|
`)
|
|
89
89
|
.option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
|
|
90
90
|
.option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
|
|
91
|
-
.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: deviceId/id, deviceName/name, deviceType/type, controlType, roomName/room, category.', stringArg('--filter'))
|
|
91
|
+
.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: deviceId/id, deviceName/name, deviceType/type, controlType, roomName/room, category, familyName/family, hubDeviceId/hub, roomID/roomid, enableCloudService/cloud, alias.', stringArg('--filter'))
|
|
92
92
|
.action(async (options) => {
|
|
93
93
|
try {
|
|
94
94
|
const body = await fetchDeviceList();
|
|
@@ -98,8 +98,11 @@ Examples:
|
|
|
98
98
|
const hubLocation = buildHubLocationMap(deviceList);
|
|
99
99
|
// Parse --filter into a list of clauses. Shared grammar across
|
|
100
100
|
// `devices list`, `devices batch`, and `events tail` / `mqtt-tail`.
|
|
101
|
-
const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room', 'controlType'
|
|
102
|
-
|
|
101
|
+
const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room', 'controlType',
|
|
102
|
+
'family', 'hub', 'roomID', 'cloud', 'alias'];
|
|
103
|
+
const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'controlType',
|
|
104
|
+
'roomName', 'category', 'familyName', 'hubDeviceId', 'roomID',
|
|
105
|
+
'enableCloudService', 'alias'];
|
|
103
106
|
const LIST_FILTER_TO_RUNTIME = {
|
|
104
107
|
deviceId: 'deviceId',
|
|
105
108
|
deviceName: 'name',
|
|
@@ -107,6 +110,11 @@ Examples:
|
|
|
107
110
|
controlType: 'controlType',
|
|
108
111
|
roomName: 'room',
|
|
109
112
|
category: 'category',
|
|
113
|
+
familyName: 'family',
|
|
114
|
+
hubDeviceId: 'hub',
|
|
115
|
+
roomID: 'roomID',
|
|
116
|
+
enableCloudService: 'cloud',
|
|
117
|
+
alias: 'alias',
|
|
110
118
|
};
|
|
111
119
|
let listClauses = null;
|
|
112
120
|
if (options.filter) {
|
|
@@ -137,10 +145,10 @@ Examples:
|
|
|
137
145
|
};
|
|
138
146
|
if (fmt === 'json' && process.argv.includes('--json')) {
|
|
139
147
|
if (listClauses) {
|
|
140
|
-
const filteredDeviceList = deviceList.filter((d) => matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }));
|
|
148
|
+
const filteredDeviceList = deviceList.filter((d) => matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' }));
|
|
141
149
|
const filteredIrList = infraredRemoteList.filter((d) => {
|
|
142
150
|
const inherited = hubLocation.get(d.hubDeviceId);
|
|
143
|
-
return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' });
|
|
151
|
+
return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' });
|
|
144
152
|
});
|
|
145
153
|
printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList });
|
|
146
154
|
}
|
|
@@ -157,7 +165,7 @@ Examples:
|
|
|
157
165
|
for (const d of deviceList) {
|
|
158
166
|
if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
|
|
159
167
|
continue;
|
|
160
|
-
if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }))
|
|
168
|
+
if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' }))
|
|
161
169
|
continue;
|
|
162
170
|
rows.push([
|
|
163
171
|
d.deviceId,
|
|
@@ -177,7 +185,7 @@ Examples:
|
|
|
177
185
|
if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
|
|
178
186
|
continue;
|
|
179
187
|
const inherited = hubLocation.get(d.hubDeviceId);
|
|
180
|
-
if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' }))
|
|
188
|
+
if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' }))
|
|
181
189
|
continue;
|
|
182
190
|
rows.push([
|
|
183
191
|
d.deviceId,
|
|
@@ -280,7 +288,7 @@ Examples:
|
|
|
280
288
|
}
|
|
281
289
|
}
|
|
282
290
|
else {
|
|
283
|
-
const
|
|
291
|
+
const rawFields = resolveFields();
|
|
284
292
|
for (const entry of batch) {
|
|
285
293
|
const { deviceId, ok, error, _fetchedAt: ts, ...status } = entry;
|
|
286
294
|
console.log(`\n─── ${String(deviceId)} ───`);
|
|
@@ -288,9 +296,13 @@ Examples:
|
|
|
288
296
|
console.error(` error: ${String(error)}`);
|
|
289
297
|
}
|
|
290
298
|
else {
|
|
299
|
+
const statusMap = status;
|
|
300
|
+
const fields = rawFields
|
|
301
|
+
? resolveFieldList(rawFields, Object.keys(statusMap))
|
|
302
|
+
: undefined;
|
|
291
303
|
const displayStatus = fields
|
|
292
|
-
? Object.fromEntries(fields.map((f) => [f,
|
|
293
|
-
:
|
|
304
|
+
? Object.fromEntries(fields.map((f) => [f, statusMap[f] ?? null]))
|
|
305
|
+
: statusMap;
|
|
294
306
|
printKeyValue(displayStatus);
|
|
295
307
|
console.error(` fetched at ${String(ts)}`);
|
|
296
308
|
}
|
|
@@ -315,7 +327,10 @@ Examples:
|
|
|
315
327
|
const statusWithTs = { ...body, _fetchedAt: fetchedAt };
|
|
316
328
|
const allHeaders = Object.keys(statusWithTs);
|
|
317
329
|
const allRows = [Object.values(statusWithTs)];
|
|
318
|
-
const
|
|
330
|
+
const rawFields = resolveFields();
|
|
331
|
+
const fields = rawFields
|
|
332
|
+
? resolveFieldList(rawFields, allHeaders)
|
|
333
|
+
: undefined;
|
|
319
334
|
renderRows(allHeaders, allRows, fmt, fields);
|
|
320
335
|
return;
|
|
321
336
|
}
|
|
@@ -453,26 +468,22 @@ Examples:
|
|
|
453
468
|
const validation = validateCommand(deviceId, cmd, parameter, options.type);
|
|
454
469
|
if (!validation.ok) {
|
|
455
470
|
const err = validation.error;
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
else {
|
|
464
|
-
console.error(`Error: ${err.message}`);
|
|
465
|
-
if (err.hint)
|
|
466
|
-
console.error(err.hint);
|
|
467
|
-
if (err.kind === 'unknown-command') {
|
|
468
|
-
const cached = getCachedDevice(deviceId);
|
|
469
|
-
if (cached) {
|
|
470
|
-
console.error(`Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.`);
|
|
471
|
-
console.error(`(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`);
|
|
472
|
-
}
|
|
471
|
+
let hint = err.hint;
|
|
472
|
+
if (err.kind === 'unknown-command') {
|
|
473
|
+
const cached = getCachedDevice(deviceId);
|
|
474
|
+
if (cached) {
|
|
475
|
+
const extra = `Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.\n` +
|
|
476
|
+
`(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`;
|
|
477
|
+
hint = hint ? `${hint}\n${extra}` : extra;
|
|
473
478
|
}
|
|
474
479
|
}
|
|
475
|
-
|
|
480
|
+
exitWithError({
|
|
481
|
+
code: 2,
|
|
482
|
+
kind: 'usage',
|
|
483
|
+
message: err.message,
|
|
484
|
+
hint,
|
|
485
|
+
context: { validationKind: err.kind },
|
|
486
|
+
});
|
|
476
487
|
}
|
|
477
488
|
// Case-only mismatch: emit a warning and continue with the canonical name.
|
|
478
489
|
if (validation.caseNormalizedFrom && validation.normalized) {
|
|
@@ -507,7 +518,7 @@ Examples:
|
|
|
507
518
|
hint: reason
|
|
508
519
|
? `Re-run with --yes to confirm. Reason: ${reason}`
|
|
509
520
|
: 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
|
|
510
|
-
context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
|
|
521
|
+
context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}) },
|
|
511
522
|
});
|
|
512
523
|
}
|
|
513
524
|
// Warn when --yes is given but the command is not destructive (no-op flag)
|
|
@@ -645,7 +656,7 @@ Examples:
|
|
|
645
656
|
const joinedMatch = findCatalogEntry(joined);
|
|
646
657
|
if (joinedMatch && !Array.isArray(joinedMatch)) {
|
|
647
658
|
if (isJsonMode()) {
|
|
648
|
-
printJson(joinedMatch);
|
|
659
|
+
printJson(normalizeCatalogForJson(joinedMatch));
|
|
649
660
|
}
|
|
650
661
|
else {
|
|
651
662
|
renderCatalogEntry(joinedMatch);
|
|
@@ -664,7 +675,7 @@ Examples:
|
|
|
664
675
|
}
|
|
665
676
|
if (individualMatches.length === typeParts.length) {
|
|
666
677
|
if (isJsonMode()) {
|
|
667
|
-
printJson(individualMatches);
|
|
678
|
+
printJson(individualMatches.map(normalizeCatalogForJson));
|
|
668
679
|
}
|
|
669
680
|
else {
|
|
670
681
|
individualMatches.forEach((entry, i) => {
|
|
@@ -824,6 +835,20 @@ Examples:
|
|
|
824
835
|
// switchbot devices meta set/get/list/clear
|
|
825
836
|
registerDevicesMetaCommand(devices);
|
|
826
837
|
}
|
|
838
|
+
function normalizeCatalogForJson(entry) {
|
|
839
|
+
return {
|
|
840
|
+
...entry,
|
|
841
|
+
commands: entry.commands.map((c) => {
|
|
842
|
+
const tier = deriveSafetyTier(c, entry);
|
|
843
|
+
const reason = getCommandSafetyReason(c);
|
|
844
|
+
return {
|
|
845
|
+
...c,
|
|
846
|
+
safetyTier: tier,
|
|
847
|
+
...(reason ? { safetyReason: reason } : {}),
|
|
848
|
+
};
|
|
849
|
+
}),
|
|
850
|
+
};
|
|
851
|
+
}
|
|
827
852
|
function renderCatalogEntry(entry) {
|
|
828
853
|
console.log(`Type: ${entry.type}`);
|
|
829
854
|
console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`);
|
|
@@ -841,10 +866,11 @@ function renderCatalogEntry(entry) {
|
|
|
841
866
|
console.log('\nCommands:');
|
|
842
867
|
const hasExamples = entry.commands.some((c) => c.exampleParams && c.exampleParams.length > 0);
|
|
843
868
|
const rows = entry.commands.map((c) => {
|
|
869
|
+
const tier = deriveSafetyTier(c, entry);
|
|
844
870
|
const flags = [];
|
|
845
871
|
if (c.commandType === 'customize')
|
|
846
872
|
flags.push('customize');
|
|
847
|
-
if (
|
|
873
|
+
if (tier === 'destructive')
|
|
848
874
|
flags.push('!destructive');
|
|
849
875
|
const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command;
|
|
850
876
|
const base = [label, c.parameter, c.description];
|
|
@@ -854,7 +880,7 @@ function renderCatalogEntry(entry) {
|
|
|
854
880
|
? ['command', 'parameter', 'description', 'example']
|
|
855
881
|
: ['command', 'parameter', 'description'];
|
|
856
882
|
printTable(tableHeaders, rows);
|
|
857
|
-
const hasDestructive = entry.commands.some((c) => c
|
|
883
|
+
const hasDestructive = entry.commands.some((c) => deriveSafetyTier(c, entry) === 'destructive');
|
|
858
884
|
if (hasDestructive) {
|
|
859
885
|
console.log('\n[!destructive] commands have hard-to-reverse real-world effects — confirm before issuing.');
|
|
860
886
|
}
|