@switchbot/openapi-cli 2.6.3 → 2.7.2

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/dist/index.js CHANGED
@@ -4,6 +4,9 @@ import { createRequire } from 'node:module';
4
4
  import chalk from 'chalk';
5
5
  import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
6
6
  import { parseDurationToMs } from './utils/flags.js';
7
+ import { emitJsonError, isJsonMode, printJson } from './utils/output.js';
8
+ import { commandToJson, resolveTargetCommand } from './utils/help-json.js';
9
+ import { PRODUCT_TAGLINE } from './commands/identity.js';
7
10
  import { registerConfigCommand } from './commands/config.js';
8
11
  import { registerDevicesCommand } from './commands/devices.js';
9
12
  import { registerScenesCommand } from './commands/scenes.js';
@@ -28,6 +31,11 @@ if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) {
28
31
  chalk.level = 0;
29
32
  }
30
33
  const program = new Command();
34
+ if (isJsonMode()) {
35
+ // In --json mode, commander writes plain-text usage errors by default.
36
+ // Silence that channel and emit a single structured error in the catch block.
37
+ program.configureOutput({ writeErr: () => { } });
38
+ }
31
39
  // Top-level subcommand names. Used by stringArg to produce clearer errors when
32
40
  // a value is omitted and the next argv token turns out to be a subcommand name.
33
41
  const TOP_LEVEL_COMMANDS = [
@@ -48,7 +56,7 @@ const cacheModeArg = (value) => {
48
56
  };
49
57
  program
50
58
  .name('switchbot')
51
- .description('Command-line tool for SwitchBot API v1.1')
59
+ .description(PRODUCT_TAGLINE)
52
60
  .version(pkgVersion)
53
61
  .option('--no-color', 'Disable ANSI colors in output')
54
62
  .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
@@ -130,10 +138,7 @@ Docs: https://github.com/OpenWonderLabs/SwitchBotAPI
130
138
  // per-command: subcommand errors won't bubble to the root override, so walk
131
139
  // every registered command and apply the same handler.
132
140
  const usageExitHandler = (err) => {
133
- if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
134
- process.exit(0);
135
- }
136
- process.exit(2);
141
+ throw err;
137
142
  };
138
143
  function applyExitOverride(cmd) {
139
144
  cmd.exitOverride(usageExitHandler);
@@ -147,6 +152,10 @@ function enableSuggestions(cmd) {
147
152
  cmd.commands.forEach(enableSuggestions);
148
153
  }
149
154
  enableSuggestions(program);
155
+ // In JSON mode suppress the plain-text help output so we can emit structured JSON instead.
156
+ if (isJsonMode()) {
157
+ program.configureOutput({ writeOut: () => { } });
158
+ }
150
159
  try {
151
160
  await program.parseAsync();
152
161
  }
@@ -155,9 +164,19 @@ catch (err) {
155
164
  // argParser on a subcommand option) don't always hit the root exitOverride.
156
165
  // Mirror the root mapping so all usage errors surface as exit 2.
157
166
  if (err instanceof CommanderError) {
158
- if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
167
+ if (err.code === 'commander.helpDisplayed') {
168
+ if (isJsonMode()) {
169
+ const target = resolveTargetCommand(program, process.argv.slice(2));
170
+ printJson(commandToJson(target, { includeIdentity: target === program }));
171
+ }
159
172
  process.exit(0);
160
173
  }
174
+ if (err.code === 'commander.version') {
175
+ process.exit(0);
176
+ }
177
+ if (isJsonMode()) {
178
+ emitJsonError({ code: 2, kind: 'usage', message: err.message });
179
+ }
161
180
  process.exit(2);
162
181
  }
163
182
  throw err;
@@ -1,6 +1,6 @@
1
1
  import { createClient } from '../api/client.js';
2
2
  import { idempotencyCache } from './idempotency.js';
3
- import { findCatalogEntry, suggestedActions, getEffectiveCatalog, } from '../devices/catalog.js';
3
+ import { findCatalogEntry, suggestedActions, getEffectiveCatalog, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
4
4
  import { getCachedDevice, updateCacheFromDeviceList, loadCache, isListCacheFresh, getCachedStatus, setCachedStatus, } from '../devices/cache.js';
5
5
  import { getCacheMode } from '../utils/flags.js';
6
6
  import { writeAudit } from '../utils/audit.js';
@@ -151,8 +151,12 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
151
151
  if (!match || Array.isArray(match))
152
152
  return { ok: true };
153
153
  const builtinCommands = match.commands.filter((c) => c.commandType !== 'customize');
154
- if (builtinCommands.length === 0)
155
- return { ok: true };
154
+ if (match.readOnly || builtinCommands.length === 0) {
155
+ return {
156
+ ok: false,
157
+ error: new CommandValidationError(`${cached.name} (${cached.type}) is a read-only sensor — it has no control commands.`, 'read-only-device', `Use 'switchbot devices status ${deviceId}' to read this device instead.`),
158
+ };
159
+ }
156
160
  let spec = builtinCommands.find((c) => c.command === cmd);
157
161
  let caseNormalizedFrom;
158
162
  let normalizedCmd = cmd;
@@ -212,9 +216,11 @@ export function isDestructiveCommand(deviceType, cmd, commandType) {
212
216
  if (!match || Array.isArray(match))
213
217
  return false;
214
218
  const spec = match.commands.find((c) => c.command === cmd);
215
- return Boolean(spec?.destructive);
219
+ if (!spec)
220
+ return false;
221
+ return deriveSafetyTier(spec, match) === 'destructive';
216
222
  }
217
- /** Return the destructiveReason for a command, or null if not destructive / not found. */
223
+ /** Return the safetyReason for a command, or null if not destructive / not found. */
218
224
  export function getDestructiveReason(deviceType, cmd, commandType) {
219
225
  if (commandType === 'customize')
220
226
  return null;
@@ -224,7 +230,7 @@ export function getDestructiveReason(deviceType, cmd, commandType) {
224
230
  if (!match || Array.isArray(match))
225
231
  return null;
226
232
  const spec = match.commands.find((c) => c.command === cmd);
227
- return spec?.destructiveReason ?? null;
233
+ return spec ? getCommandSafetyReason(spec) : null;
228
234
  }
229
235
  /**
230
236
  * Describe a device by id: metadata + catalog entry (if known) +
@@ -260,7 +266,16 @@ export async function describeDevice(deviceId, options = {}, client) {
260
266
  ? {
261
267
  role: catalogEntry.role ?? null,
262
268
  readOnly: catalogEntry.readOnly ?? false,
263
- commands: catalogEntry.commands,
269
+ commands: catalogEntry.commands.map((c) => {
270
+ const tier = deriveSafetyTier(c, catalogEntry);
271
+ const reason = getCommandSafetyReason(c);
272
+ return {
273
+ ...c,
274
+ safetyTier: tier,
275
+ destructive: tier === 'destructive',
276
+ ...(reason ? { safetyReason: reason } : {}),
277
+ };
278
+ }),
264
279
  statusFields: catalogEntry.statusFields ?? [],
265
280
  ...(liveStatus !== undefined ? { liveStatus } : {}),
266
281
  }
@@ -0,0 +1,131 @@
1
+ import { UsageError } from '../utils/output.js';
2
+ /**
3
+ * User-facing aliases for canonical field names.
4
+ *
5
+ * Keys are canonical names (matching API response keys and CLI/schema output);
6
+ * values are lowercase alternatives a user may type for `--fields` or `--filter`.
7
+ *
8
+ * Conflict rules (do not add an alias that violates these — tests will fail):
9
+ * - `temp` is exclusive to `temperature` (NOT `colorTemperature`, `targetTemperature`).
10
+ * - `motion` is exclusive to `moveDetected`; `moving` uses `active` instead.
11
+ * - `mode` is exclusive to top-level `mode` (preset); device-specific modes go through `deviceMode`.
12
+ * - Reserved / too-generic words never appear as aliases: `auto`, `status`, `state`,
13
+ * `switch`, `type`, `on`, `off`.
14
+ * - Device-type words are never aliases: `lock`, `fan`.
15
+ */
16
+ export const FIELD_ALIASES = {
17
+ // Identification (shared with list/filter)
18
+ deviceId: ['id'],
19
+ deviceName: ['name'],
20
+ deviceType: ['type'],
21
+ controlType: ['control'],
22
+ roomName: ['room'],
23
+ roomID: ['roomid'],
24
+ familyName: ['family'],
25
+ hubDeviceId: ['hub'],
26
+ enableCloudService: ['cloud'],
27
+ alias: ['alias'],
28
+ // Phase 1 — common status fields
29
+ battery: ['batt', 'bat'],
30
+ temperature: ['temp', 'ambient'],
31
+ colorTemperature: ['kelvin', 'colortemp'],
32
+ humidity: ['humid', 'rh'],
33
+ brightness: ['bright', 'bri'],
34
+ fanSpeed: ['speed'],
35
+ position: ['pos'],
36
+ moveDetected: ['motion'],
37
+ openState: ['open'],
38
+ doorState: ['door'],
39
+ CO2: ['co2'],
40
+ power: ['enabled'],
41
+ mode: ['preset'],
42
+ // Phase 2 — niche device fields
43
+ childLock: ['safe', 'childlock'],
44
+ targetTemperature: ['setpoint', 'target'],
45
+ electricCurrent: ['current', 'amps'],
46
+ voltage: ['volts'],
47
+ usedElectricity: ['energy', 'kwh'],
48
+ electricityOfDay: ['daily', 'today'],
49
+ weight: ['load'],
50
+ version: ['firmware', 'fw'],
51
+ lightLevel: ['light', 'lux'],
52
+ oscillation: ['swing', 'osc'],
53
+ verticalOscillation: ['vswing'],
54
+ nightStatus: ['night'],
55
+ chargingStatus: ['charging', 'charge'],
56
+ switch1Status: ['ch1', 'channel1'],
57
+ switch2Status: ['ch2', 'channel2'],
58
+ taskType: ['task'],
59
+ moving: ['active'],
60
+ onlineStatus: ['online_status'],
61
+ workingStatus: ['working'],
62
+ // Phase 3 — catalog statusFields coverage
63
+ group: ['cluster'],
64
+ calibrate: ['calibration', 'calib'],
65
+ direction: ['tilt'],
66
+ deviceMode: ['devmode'],
67
+ nebulizationEfficiency: ['mist', 'spray'],
68
+ sound: ['audio'],
69
+ lackWater: ['tank', 'water-low'],
70
+ filterElement: ['filter'],
71
+ color: ['rgb', 'hex'],
72
+ useTime: ['runtime', 'uptime'],
73
+ switchStatus: ['relay'],
74
+ lockState: ['locked'],
75
+ slidePosition: ['slide'],
76
+ // Phase 4 — ultra-niche sensor + webhook fields (~98% coverage target)
77
+ waterLeakDetect: ['leak', 'water'],
78
+ pressure: ['press', 'pa'],
79
+ moveCount: ['movecnt'],
80
+ errorCode: ['err'],
81
+ buttonName: ['btn', 'button'],
82
+ pressedAt: ['pressed'],
83
+ deviceMac: ['mac'],
84
+ detectionState: ['detected', 'detect'],
85
+ };
86
+ /**
87
+ * Resolve a user-typed field name to its canonical form against an allowed list.
88
+ *
89
+ * Matching is case-insensitive and trims surrounding whitespace. Direct matches
90
+ * win over alias matches. Throws UsageError if the input is empty or does not
91
+ * match any canonical / alias in the allowed list.
92
+ */
93
+ export function resolveField(input, allowedCanonical) {
94
+ const normalized = input.trim().toLowerCase();
95
+ if (!normalized) {
96
+ throw new UsageError('Field name cannot be empty.');
97
+ }
98
+ for (const canonical of allowedCanonical) {
99
+ if (canonical.toLowerCase() === normalized)
100
+ return canonical;
101
+ }
102
+ for (const canonical of allowedCanonical) {
103
+ const aliases = FIELD_ALIASES[canonical] ?? [];
104
+ if (aliases.some((a) => a.toLowerCase() === normalized))
105
+ return canonical;
106
+ }
107
+ throw new UsageError(`Unknown field "${input}". Supported: ${listSupportedFieldInputs(allowedCanonical).join(', ')}`);
108
+ }
109
+ /**
110
+ * Resolve every field in a list. Preserves order and the original UsageError
111
+ * from resolveField() on the first unknown input.
112
+ */
113
+ export function resolveFieldList(inputs, allowedCanonical) {
114
+ return inputs.map((f) => resolveField(f, allowedCanonical));
115
+ }
116
+ export function listSupportedFieldInputs(allowedCanonical) {
117
+ const out = new Set();
118
+ for (const canonical of allowedCanonical) {
119
+ out.add(canonical);
120
+ for (const alias of FIELD_ALIASES[canonical] ?? [])
121
+ out.add(alias);
122
+ }
123
+ return [...out];
124
+ }
125
+ /**
126
+ * All canonical keys known to the alias registry. Use when no dynamic
127
+ * canonical list is available (e.g. `watch` before the first poll response).
128
+ */
129
+ export function listAllCanonical() {
130
+ return Object.keys(FIELD_ALIASES);
131
+ }
@@ -20,7 +20,7 @@ export class FilterSyntaxError extends Error {
20
20
  * {type,name,category,room}; `devices batch` uses {type,family,room,category};
21
21
  * `events tail` uses {deviceId,type}.
22
22
  */
23
- export function parseFilterExpr(expr, allowedKeys) {
23
+ export function parseFilterExpr(expr, allowedKeys, options) {
24
24
  if (!expr)
25
25
  return [];
26
26
  const parts = expr.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
@@ -72,10 +72,23 @@ export function parseFilterExpr(expr, allowedKeys) {
72
72
  if (!raw) {
73
73
  throw new FilterSyntaxError(`Empty value for filter clause "${part}"`);
74
74
  }
75
- if (!allowedKeys.includes(key)) {
76
- throw new FilterSyntaxError(`Unknown filter key "${key}" — supported: ${allowedKeys.join(', ')}`);
75
+ let resolvedKey = key;
76
+ if (options?.resolveKey) {
77
+ try {
78
+ resolvedKey = options.resolveKey(key);
79
+ }
80
+ catch (err) {
81
+ if (err instanceof Error) {
82
+ throw new FilterSyntaxError(err.message);
83
+ }
84
+ throw err;
85
+ }
86
+ }
87
+ if (!allowedKeys.includes(resolvedKey)) {
88
+ const printableKeys = options?.supportedKeys ?? allowedKeys;
89
+ throw new FilterSyntaxError(`Unknown filter key "${key}" – supported: ${printableKeys.join(', ')}`);
77
90
  }
78
- clauses.push({ key, op, raw, regex });
91
+ clauses.push({ key: resolvedKey, op, raw, regex });
79
92
  }
80
93
  return clauses;
81
94
  }
@@ -0,0 +1,54 @@
1
+ import { IDENTITY } from '../commands/identity.js';
2
+ export function commandToJson(cmd, opts = {}) {
3
+ const args = cmd.registeredArguments.map((a) => ({
4
+ name: a.name(),
5
+ required: a.required,
6
+ variadic: a.variadic,
7
+ description: a.description ?? '',
8
+ }));
9
+ const options = cmd.options
10
+ .filter((o) => o.long !== '--help' && o.long !== '--version')
11
+ .map((o) => {
12
+ const entry = { flags: o.flags, description: o.description ?? '' };
13
+ if (o.defaultValue !== undefined)
14
+ entry.defaultValue = o.defaultValue;
15
+ if (o.argChoices && o.argChoices.length > 0)
16
+ entry.choices = o.argChoices;
17
+ return entry;
18
+ });
19
+ const subcommands = cmd.commands
20
+ .filter((c) => !c.name().startsWith('_'))
21
+ .map((c) => ({ name: c.name(), description: c.description() }));
22
+ const out = {
23
+ name: cmd.name(),
24
+ description: cmd.description(),
25
+ arguments: args,
26
+ options,
27
+ subcommands,
28
+ };
29
+ if (opts.includeIdentity) {
30
+ out.product = IDENTITY.product;
31
+ out.domain = IDENTITY.domain;
32
+ out.vendor = IDENTITY.vendor;
33
+ out.apiVersion = IDENTITY.apiVersion;
34
+ out.apiDocs = IDENTITY.apiDocs;
35
+ out.productCategories = IDENTITY.productCategories;
36
+ }
37
+ return out;
38
+ }
39
+ /** Walk argv tokens (skipping flags) to find the deepest matching subcommand. */
40
+ export function resolveTargetCommand(root, argv) {
41
+ let cmd = root;
42
+ for (const token of argv) {
43
+ if (token.startsWith('-'))
44
+ continue;
45
+ const sub = cmd.commands.find((c) => c.name() === token || c.aliases().includes(token));
46
+ if (sub) {
47
+ cmd = sub;
48
+ }
49
+ else {
50
+ break;
51
+ }
52
+ }
53
+ return cmd;
54
+ }
@@ -28,6 +28,43 @@ export function emitJsonError(errorPayload) {
28
28
  console.error(chalk.red(msg));
29
29
  }
30
30
  }
31
+ /**
32
+ * P7: emit the stream-header first line for any NDJSON/streaming command
33
+ * running under `--json`. Downstream JSON consumers can key on
34
+ * `{ stream: true }` to distinguish the header from subsequent event
35
+ * lines, and on `eventKind` / `cadence` to pick a parser strategy.
36
+ *
37
+ * Non-streaming commands (single-object / array output) do NOT emit this
38
+ * header — only watch / events tail / events mqtt-tail.
39
+ */
40
+ export function emitStreamHeader(opts) {
41
+ console.log(JSON.stringify({
42
+ schemaVersion: '1',
43
+ stream: true,
44
+ eventKind: opts.eventKind,
45
+ cadence: opts.cadence,
46
+ }));
47
+ }
48
+ export function exitWithError(messageOrOpts) {
49
+ const opts = typeof messageOrOpts === 'string' ? { message: messageOrOpts } : messageOrOpts;
50
+ const { message, kind = 'usage', code = 2, hint, context, extra } = opts;
51
+ if (isJsonMode()) {
52
+ const payload = { code, kind, message };
53
+ if (hint)
54
+ payload.hint = hint;
55
+ if (context)
56
+ payload.context = context;
57
+ if (extra)
58
+ Object.assign(payload, extra);
59
+ emitJsonError(payload);
60
+ }
61
+ else {
62
+ console.error(message);
63
+ if (hint)
64
+ console.error(hint);
65
+ }
66
+ process.exit(code);
67
+ }
31
68
  function escapeMarkdownCell(s) {
32
69
  // Pipes break markdown table layout; backslash-escape them. Collapse
33
70
  // newlines into <br> so each row stays on one line.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchbot/openapi-cli",
3
- "version": "2.6.3",
3
+ "version": "2.7.2",
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",