@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.
Files changed (67) hide show
  1. package/README.md +385 -103
  2. package/dist/api/client.js +13 -12
  3. package/dist/commands/agent-bootstrap.js +67 -16
  4. package/dist/commands/auth.js +354 -0
  5. package/dist/commands/batch.js +26 -21
  6. package/dist/commands/capabilities.js +29 -21
  7. package/dist/commands/catalog.js +4 -3
  8. package/dist/commands/config.js +57 -37
  9. package/dist/commands/devices.js +63 -37
  10. package/dist/commands/doctor.js +539 -26
  11. package/dist/commands/events.js +115 -26
  12. package/dist/commands/expand.js +7 -15
  13. package/dist/commands/explain.js +10 -7
  14. package/dist/commands/history.js +12 -18
  15. package/dist/commands/identity.js +59 -0
  16. package/dist/commands/install.js +246 -0
  17. package/dist/commands/mcp.js +895 -15
  18. package/dist/commands/plan.js +111 -15
  19. package/dist/commands/policy.js +469 -0
  20. package/dist/commands/rules.js +657 -0
  21. package/dist/commands/schema.js +20 -12
  22. package/dist/commands/status-sync.js +131 -0
  23. package/dist/commands/uninstall.js +237 -0
  24. package/dist/commands/watch.js +15 -2
  25. package/dist/config.js +14 -0
  26. package/dist/credentials/backends/file.js +101 -0
  27. package/dist/credentials/backends/linux.js +129 -0
  28. package/dist/credentials/backends/macos.js +129 -0
  29. package/dist/credentials/backends/windows.js +215 -0
  30. package/dist/credentials/keychain.js +88 -0
  31. package/dist/credentials/prime.js +52 -0
  32. package/dist/devices/catalog.js +118 -11
  33. package/dist/devices/resources.js +270 -0
  34. package/dist/index.js +39 -4
  35. package/dist/install/default-steps.js +257 -0
  36. package/dist/install/preflight.js +212 -0
  37. package/dist/install/steps.js +67 -0
  38. package/dist/lib/command-keywords.js +17 -0
  39. package/dist/lib/devices.js +15 -5
  40. package/dist/policy/add-rule.js +124 -0
  41. package/dist/policy/diff.js +91 -0
  42. package/dist/policy/examples/policy.example.yaml +99 -0
  43. package/dist/policy/format.js +57 -0
  44. package/dist/policy/load.js +61 -0
  45. package/dist/policy/migrate.js +67 -0
  46. package/dist/policy/schema/v0.2.json +302 -0
  47. package/dist/policy/schema.js +18 -0
  48. package/dist/policy/validate.js +262 -0
  49. package/dist/rules/action.js +205 -0
  50. package/dist/rules/audit-query.js +89 -0
  51. package/dist/rules/cron-scheduler.js +186 -0
  52. package/dist/rules/destructive.js +52 -0
  53. package/dist/rules/engine.js +567 -0
  54. package/dist/rules/matcher.js +230 -0
  55. package/dist/rules/pid-file.js +95 -0
  56. package/dist/rules/quiet-hours.js +45 -0
  57. package/dist/rules/suggest.js +95 -0
  58. package/dist/rules/throttle.js +78 -0
  59. package/dist/rules/types.js +34 -0
  60. package/dist/rules/webhook-listener.js +223 -0
  61. package/dist/rules/webhook-token.js +90 -0
  62. package/dist/schema/field-aliases.js +95 -0
  63. package/dist/status-sync/manager.js +268 -0
  64. package/dist/utils/audit.js +12 -2
  65. package/dist/utils/help-json.js +54 -0
  66. package/dist/utils/output.js +17 -0
  67. 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.destructive).length, 0),
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.destructive).length, 0),
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
  }
@@ -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 built-in device catalog and any local overlay')
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 (c.destructive)
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];
@@ -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, emitJsonError } from '../utils/output.js';
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
- const msg = `--from-env-file: file not found: ${options.fromEnvFile}`;
149
- if (isJsonMode()) {
150
- emitJsonError({ code: 2, kind: 'usage', message: msg });
151
- }
152
- else {
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
- const msg = '--from-op requires --op-secret <ref> for the secret reference.';
164
- if (isJsonMode()) {
165
- emitJsonError({ code: 2, kind: 'usage', message: msg });
166
- }
167
- else {
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
- const msg = `1Password CLI read failed: ${err instanceof Error ? err.message : String(err)}`;
178
- if (isJsonMode()) {
179
- emitJsonError({ code: 1, kind: 'runtime', message: msg, hint: 'Ensure the "op" CLI is installed and authenticated (op signin).' });
180
- }
181
- else {
182
- console.error(msg);
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
- const msg = 'Interactive mode cannot run under --json. Provide token/secret via --from-env-file, --from-op, or positional args.';
192
- emitJsonError({ code: 2, kind: 'usage', message: msg });
193
- process.exit(2);
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
- const msg = 'Missing token/secret. Run interactively, or use --from-env-file / --from-op, or pass positional arguments (discouraged).';
208
- if (isJsonMode()) {
209
- emitJsonError({ code: 2, kind: 'usage', message: msg });
210
- }
211
- else {
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,
@@ -1,7 +1,7 @@
1
1
  import { enumArg, stringArg } from '../utils/arg-parsers.js';
2
- import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, emitJsonError, exitWithError } from '../utils/output.js';
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
- const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'controlType', 'roomName', 'category'];
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 fields = resolveFields();
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, status[f] ?? null]))
293
- : status;
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 fields = resolveFields();
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
- if (isJsonMode()) {
457
- const obj = { code: 2, kind: 'usage', message: err.message };
458
- if (err.hint)
459
- obj.hint = err.hint;
460
- obj.context = { validationKind: err.kind };
461
- emitJsonError(obj);
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
- process.exit(2);
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 (c.destructive)
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.destructive);
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
  }