@switchbot/openapi-cli 2.2.1 → 2.3.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 CHANGED
@@ -278,6 +278,8 @@ Generic parameter shapes (which one applies is decided by the device — see the
278
278
  | `<json object>` | `'{"action":"sweep","param":{"fanLevel":2,"times":1}}'` |
279
279
  | Custom IR button | `devices command <id> MyButton --type customize` |
280
280
 
281
+ Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), and `setMode` (Relay Switch) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list.
282
+
281
283
  For the complete per-device command reference, see the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands).
282
284
 
283
285
  #### `devices expand` — named flags for packed parameters
@@ -6,6 +6,7 @@ import { getCachedDevice } from '../devices/cache.js';
6
6
  import { loadDeviceMeta } from '../devices/device-meta.js';
7
7
  import { resolveDeviceId } from '../utils/name-resolver.js';
8
8
  import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, buildHubLocationMap, DeviceNotFoundError, } from '../lib/devices.js';
9
+ import { validateParameter } from '../devices/param-validator.js';
9
10
  import { registerBatchCommand } from './batch.js';
10
11
  import { registerWatchCommand } from './watch.js';
11
12
  import { registerExplainCommand } from './explain.js';
@@ -341,19 +342,22 @@ Examples:
341
342
  `)
342
343
  .action(async (deviceIdArg, cmdArg, parameter, options) => {
343
344
  try {
344
- // BUG-FIX: When --name is provided, Commander misassigns the first positional
345
- // to [deviceId] instead of [cmd]. Detect and shift positionals accordingly.
345
+ // BUG-FIX: When --name is provided, Commander fills positionals left-to-right
346
+ // starting at [deviceId]. Shift them back to their semantic slots.
346
347
  let cmd;
347
348
  let effectiveDeviceIdArg;
348
349
  if (options.name) {
349
- if (deviceIdArg && cmdArg) {
350
- throw new UsageError('Provide either a deviceId argument or --name, not both.');
351
- }
352
- if (!deviceIdArg && !cmdArg) {
350
+ // `--name "x" <cmd> [parameter]` → Commander binds deviceIdArg=<cmd>, cmdArg=[parameter].
351
+ if (!deviceIdArg) {
353
352
  throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
354
353
  }
355
- // --name "x" turnOn → deviceIdArg="turnOn", cmdArg=undefined → shift
356
- cmd = (deviceIdArg ?? cmdArg);
354
+ cmd = deviceIdArg;
355
+ if (cmdArg !== undefined) {
356
+ if (parameter !== undefined) {
357
+ throw new UsageError('Too many positional arguments after --name. Expected: --name <query> <cmd> [parameter].');
358
+ }
359
+ parameter = cmdArg;
360
+ }
357
361
  effectiveDeviceIdArg = undefined;
358
362
  }
359
363
  else {
@@ -391,6 +395,37 @@ Examples:
391
395
  }
392
396
  process.exit(2);
393
397
  }
398
+ // Case-only mismatch: emit a warning and continue with the canonical name.
399
+ if (validation.caseNormalizedFrom && validation.normalized) {
400
+ console.error(`Note: '${validation.caseNormalizedFrom}' normalized to '${validation.normalized}' (case mismatch). Use exact casing to silence this warning.`);
401
+ cmd = validation.normalized;
402
+ }
403
+ else if (validation.normalized) {
404
+ cmd = validation.normalized;
405
+ }
406
+ // Raw-parameter validation (runs for known (deviceType, command) pairs only).
407
+ const cachedForParam = getCachedDevice(deviceId);
408
+ if (cachedForParam && options.type === 'command') {
409
+ const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
410
+ if (!paramCheck.ok) {
411
+ if (isJsonMode()) {
412
+ console.error(JSON.stringify({
413
+ error: {
414
+ code: 2,
415
+ kind: 'usage',
416
+ message: paramCheck.error,
417
+ context: { command: cmd, deviceType: cachedForParam.type, deviceId },
418
+ },
419
+ }));
420
+ }
421
+ else {
422
+ console.error(`Error: ${paramCheck.error}`);
423
+ }
424
+ process.exit(2);
425
+ }
426
+ if (paramCheck.normalized !== undefined)
427
+ parameter = paramCheck.normalized;
428
+ }
394
429
  const cachedForGuard = getCachedDevice(deviceId);
395
430
  if (!options.yes &&
396
431
  !isDryRun() &&
@@ -5,83 +5,7 @@ import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../l
5
5
  import { isDryRun } from '../utils/flags.js';
6
6
  import { resolveDeviceId } from '../utils/name-resolver.js';
7
7
  import { DryRunSignal } from '../api/client.js';
8
- // ---- Mapping tables --------------------------------------------------------
9
- const AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
10
- const AC_FAN_MAP = { auto: 1, low: 2, mid: 3, high: 4 };
11
- const CURTAIN_MODE_MAP = { default: 'ff', performance: '0', silent: '1' };
12
- const RELAY_MODE_MAP = { toggle: 0, edge: 1, detached: 2, momentary: 3 };
13
- const BLIND_DIRECTION = new Set(['up', 'down']);
14
- // ---- Translators -----------------------------------------------------------
15
- function buildAcSetAll(opts) {
16
- if (!opts.temp)
17
- throw new UsageError('--temp is required for setAll (e.g. --temp 26)');
18
- if (!opts.mode)
19
- throw new UsageError('--mode is required for setAll (auto|cool|dry|fan|heat)');
20
- if (!opts.fan)
21
- throw new UsageError('--fan is required for setAll (auto|low|mid|high)');
22
- if (!opts.power)
23
- throw new UsageError('--power is required for setAll (on|off)');
24
- const temp = parseInt(opts.temp, 10);
25
- if (!Number.isFinite(temp) || temp < 16 || temp > 30) {
26
- throw new UsageError(`--temp must be an integer between 16 and 30 (got "${opts.temp}")`);
27
- }
28
- const modeInt = AC_MODE_MAP[opts.mode.toLowerCase()];
29
- if (modeInt === undefined) {
30
- throw new UsageError(`--mode must be one of: auto, cool, dry, fan, heat (got "${opts.mode}")`);
31
- }
32
- const fanInt = AC_FAN_MAP[opts.fan.toLowerCase()];
33
- if (fanInt === undefined) {
34
- throw new UsageError(`--fan must be one of: auto, low, mid, high (got "${opts.fan}")`);
35
- }
36
- const power = opts.power.toLowerCase();
37
- if (power !== 'on' && power !== 'off') {
38
- throw new UsageError(`--power must be "on" or "off" (got "${opts.power}")`);
39
- }
40
- return `${temp},${modeInt},${fanInt},${power}`;
41
- }
42
- function buildCurtainSetPosition(opts) {
43
- if (!opts.position)
44
- throw new UsageError('--position is required (0-100)');
45
- const pos = parseInt(opts.position, 10);
46
- if (!Number.isFinite(pos) || pos < 0 || pos > 100) {
47
- throw new UsageError(`--position must be an integer between 0 and 100 (got "${opts.position}")`);
48
- }
49
- const modeStr = opts.mode ? CURTAIN_MODE_MAP[opts.mode.toLowerCase()] : 'ff';
50
- if (modeStr === undefined) {
51
- throw new UsageError(`--mode must be one of: default, performance, silent (got "${opts.mode}")`);
52
- }
53
- return `0,${modeStr},${pos}`;
54
- }
55
- function buildBlindTiltSetPosition(opts) {
56
- if (!opts.direction)
57
- throw new UsageError('--direction is required (up|down)');
58
- if (!opts.angle)
59
- throw new UsageError('--angle is required (0-100)');
60
- const dir = opts.direction.toLowerCase();
61
- if (!BLIND_DIRECTION.has(dir)) {
62
- throw new UsageError(`--direction must be "up" or "down" (got "${opts.direction}")`);
63
- }
64
- const angle = parseInt(opts.angle, 10);
65
- if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
66
- throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
67
- }
68
- return `${dir};${angle}`;
69
- }
70
- function buildRelaySetMode(opts) {
71
- if (!opts.channel)
72
- throw new UsageError('--channel is required (1 or 2)');
73
- if (!opts.mode)
74
- throw new UsageError('--mode is required (toggle|edge|detached|momentary)');
75
- const ch = parseInt(opts.channel, 10);
76
- if (ch !== 1 && ch !== 2) {
77
- throw new UsageError(`--channel must be 1 or 2 (got "${opts.channel}")`);
78
- }
79
- const modeInt = RELAY_MODE_MAP[opts.mode.toLowerCase()];
80
- if (modeInt === undefined) {
81
- throw new UsageError(`--mode must be one of: toggle, edge, detached, momentary (got "${opts.mode}")`);
82
- }
83
- return `${ch};${modeInt}`;
84
- }
8
+ import { buildAcSetAll, buildCurtainSetPosition, buildBlindTiltSetPosition, buildRelaySetMode, } from '../devices/param-validator.js';
85
9
  // ---- Registration ----------------------------------------------------------
86
10
  export function registerExpandCommand(devices) {
87
11
  devices
@@ -0,0 +1,263 @@
1
+ import { UsageError } from '../utils/output.js';
2
+ export const AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
3
+ export const AC_FAN_MAP = { auto: 1, low: 2, mid: 3, high: 4 };
4
+ export const CURTAIN_MODE_MAP = { default: 'ff', performance: '0', silent: '1' };
5
+ export const RELAY_MODE_MAP = { toggle: 0, edge: 1, detached: 2, momentary: 3 };
6
+ const BLIND_DIRECTION = new Set(['up', 'down']);
7
+ // ---- Semantic-flag builders (used by `devices expand`) --------------------
8
+ export function buildAcSetAll(opts) {
9
+ if (!opts.temp)
10
+ throw new UsageError('--temp is required for setAll (e.g. --temp 26)');
11
+ if (!opts.mode)
12
+ throw new UsageError('--mode is required for setAll (auto|cool|dry|fan|heat)');
13
+ if (!opts.fan)
14
+ throw new UsageError('--fan is required for setAll (auto|low|mid|high)');
15
+ if (!opts.power)
16
+ throw new UsageError('--power is required for setAll (on|off)');
17
+ const temp = parseInt(opts.temp, 10);
18
+ if (!Number.isFinite(temp) || temp < 16 || temp > 30) {
19
+ throw new UsageError(`--temp must be an integer between 16 and 30 (got "${opts.temp}")`);
20
+ }
21
+ const modeInt = AC_MODE_MAP[opts.mode.toLowerCase()];
22
+ if (modeInt === undefined) {
23
+ throw new UsageError(`--mode must be one of: auto, cool, dry, fan, heat (got "${opts.mode}")`);
24
+ }
25
+ const fanInt = AC_FAN_MAP[opts.fan.toLowerCase()];
26
+ if (fanInt === undefined) {
27
+ throw new UsageError(`--fan must be one of: auto, low, mid, high (got "${opts.fan}")`);
28
+ }
29
+ const power = opts.power.toLowerCase();
30
+ if (power !== 'on' && power !== 'off') {
31
+ throw new UsageError(`--power must be "on" or "off" (got "${opts.power}")`);
32
+ }
33
+ return `${temp},${modeInt},${fanInt},${power}`;
34
+ }
35
+ export function buildCurtainSetPosition(opts) {
36
+ if (!opts.position)
37
+ throw new UsageError('--position is required (0-100)');
38
+ const pos = parseInt(opts.position, 10);
39
+ if (!Number.isFinite(pos) || pos < 0 || pos > 100) {
40
+ throw new UsageError(`--position must be an integer between 0 and 100 (got "${opts.position}")`);
41
+ }
42
+ const modeStr = opts.mode ? CURTAIN_MODE_MAP[opts.mode.toLowerCase()] : 'ff';
43
+ if (modeStr === undefined) {
44
+ throw new UsageError(`--mode must be one of: default, performance, silent (got "${opts.mode}")`);
45
+ }
46
+ return `0,${modeStr},${pos}`;
47
+ }
48
+ export function buildBlindTiltSetPosition(opts) {
49
+ if (!opts.direction)
50
+ throw new UsageError('--direction is required (up|down)');
51
+ if (!opts.angle)
52
+ throw new UsageError('--angle is required (0-100)');
53
+ const dir = opts.direction.toLowerCase();
54
+ if (!BLIND_DIRECTION.has(dir)) {
55
+ throw new UsageError(`--direction must be "up" or "down" (got "${opts.direction}")`);
56
+ }
57
+ const angle = parseInt(opts.angle, 10);
58
+ if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
59
+ throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
60
+ }
61
+ return `${dir};${angle}`;
62
+ }
63
+ export function buildRelaySetMode(opts) {
64
+ if (!opts.channel)
65
+ throw new UsageError('--channel is required (1 or 2)');
66
+ if (!opts.mode)
67
+ throw new UsageError('--mode is required (toggle|edge|detached|momentary)');
68
+ const ch = parseInt(opts.channel, 10);
69
+ if (ch !== 1 && ch !== 2) {
70
+ throw new UsageError(`--channel must be 1 or 2 (got "${opts.channel}")`);
71
+ }
72
+ const modeInt = RELAY_MODE_MAP[opts.mode.toLowerCase()];
73
+ if (modeInt === undefined) {
74
+ throw new UsageError(`--mode must be one of: toggle, edge, detached, momentary (got "${opts.mode}")`);
75
+ }
76
+ return `${ch};${modeInt}`;
77
+ }
78
+ /**
79
+ * Validate a raw wire-format parameter string for (deviceType, command)
80
+ * combos where the shape is well-defined. Unknown combos pass through so
81
+ * `devices command` remains a usable escape hatch for types/commands the
82
+ * CLI hasn't modelled yet.
83
+ *
84
+ * On passthrough, `normalized` is left undefined so the caller keeps the
85
+ * original parameter value (preserving the `undefined → "default"` default
86
+ * for no-arg commands).
87
+ */
88
+ export function validateParameter(deviceType, command, raw) {
89
+ if (!deviceType)
90
+ return { ok: true };
91
+ if (deviceType === 'Air Conditioner' && command === 'setAll') {
92
+ return validateAcSetAll(raw);
93
+ }
94
+ if (deviceType.startsWith('Curtain') && command === 'setPosition') {
95
+ return validateCurtainSetPosition(raw);
96
+ }
97
+ if (deviceType.startsWith('Blind Tilt') && command === 'setPosition') {
98
+ return validateBlindTiltSetPosition(raw);
99
+ }
100
+ if (deviceType.startsWith('Relay Switch') && command === 'setMode') {
101
+ return validateRelaySetMode(raw);
102
+ }
103
+ return { ok: true };
104
+ }
105
+ function validateAcSetAll(raw) {
106
+ if (raw === undefined || raw === '' || raw === 'default') {
107
+ return {
108
+ ok: false,
109
+ error: `setAll requires a parameter "<temp>,<mode>,<fan>,<on|off>". Example: "26,2,2,on".`,
110
+ };
111
+ }
112
+ if (raw.startsWith('{') || raw.startsWith('[')) {
113
+ return {
114
+ ok: false,
115
+ error: `setAll parameter must be a CSV string like "26,2,2,on", not JSON (got ${JSON.stringify(raw)}).`,
116
+ };
117
+ }
118
+ const parts = raw.split(',');
119
+ if (parts.length !== 4) {
120
+ return {
121
+ ok: false,
122
+ error: `setAll expects 4 comma-separated fields "<temp>,<mode>,<fan>,<on|off>", got ${parts.length} (${JSON.stringify(raw)}). Example: "26,2,2,on".`,
123
+ };
124
+ }
125
+ const [tempStr, modeStr, fanStr, powerStr] = parts.map((s) => s.trim());
126
+ const temp = Number(tempStr);
127
+ if (!Number.isInteger(temp) || temp < 16 || temp > 30) {
128
+ return {
129
+ ok: false,
130
+ error: `setAll field 1 (temp) must be an integer 16-30, got "${tempStr}". Example: "26,2,2,on".`,
131
+ };
132
+ }
133
+ const mode = Number(modeStr);
134
+ if (!Number.isInteger(mode) || mode < 1 || mode > 5) {
135
+ return {
136
+ ok: false,
137
+ error: `setAll field 2 (mode) must be 1-5 (1=auto 2=cool 3=dry 4=fan 5=heat), got "${modeStr}". Example: "26,2,2,on".`,
138
+ };
139
+ }
140
+ const fan = Number(fanStr);
141
+ if (!Number.isInteger(fan) || fan < 1 || fan > 4) {
142
+ return {
143
+ ok: false,
144
+ error: `setAll field 3 (fan) must be 1-4 (1=auto 2=low 3=mid 4=high), got "${fanStr}". Example: "26,2,2,on".`,
145
+ };
146
+ }
147
+ const power = powerStr.toLowerCase();
148
+ if (power !== 'on' && power !== 'off') {
149
+ return {
150
+ ok: false,
151
+ error: `setAll field 4 (power) must be "on" or "off", got "${powerStr}". Example: "26,2,2,on".`,
152
+ };
153
+ }
154
+ return { ok: true, normalized: `${temp},${mode},${fan},${power}` };
155
+ }
156
+ function validateCurtainSetPosition(raw) {
157
+ if (raw === undefined || raw === '' || raw === 'default') {
158
+ return {
159
+ ok: false,
160
+ error: `setPosition requires a parameter. Expected: "<0-100>" or "<index>,<ff|0|1>,<0-100>". Example: "50" or "0,ff,50".`,
161
+ };
162
+ }
163
+ if (!raw.includes(',')) {
164
+ const pos = Number(raw);
165
+ if (!Number.isInteger(pos) || pos < 0 || pos > 100) {
166
+ return {
167
+ ok: false,
168
+ error: `setPosition must be an integer 0-100, got "${raw}". Example: "50".`,
169
+ };
170
+ }
171
+ return { ok: true, normalized: String(pos) };
172
+ }
173
+ const parts = raw.split(',').map((s) => s.trim());
174
+ if (parts.length !== 3) {
175
+ return {
176
+ ok: false,
177
+ error: `setPosition tuple form expects 3 comma-separated fields "<index>,<ff|0|1>,<0-100>", got ${parts.length} (${JSON.stringify(raw)}).`,
178
+ };
179
+ }
180
+ const [idxStr, modeStr, posStr] = parts;
181
+ const idx = Number(idxStr);
182
+ if (!Number.isInteger(idx) || idx < 0) {
183
+ return {
184
+ ok: false,
185
+ error: `setPosition field 1 (index) must be a non-negative integer, got "${idxStr}".`,
186
+ };
187
+ }
188
+ const modeLower = modeStr.toLowerCase();
189
+ if (!['ff', '0', '1'].includes(modeLower)) {
190
+ return {
191
+ ok: false,
192
+ error: `setPosition field 2 (mode) must be "ff", "0", or "1", got "${modeStr}". (ff=default, 0=performance, 1=silent)`,
193
+ };
194
+ }
195
+ const pos = Number(posStr);
196
+ if (!Number.isInteger(pos) || pos < 0 || pos > 100) {
197
+ return {
198
+ ok: false,
199
+ error: `setPosition field 3 (position) must be an integer 0-100, got "${posStr}".`,
200
+ };
201
+ }
202
+ return { ok: true, normalized: `${idx},${modeLower},${pos}` };
203
+ }
204
+ function validateBlindTiltSetPosition(raw) {
205
+ if (raw === undefined || raw === '' || raw === 'default') {
206
+ return {
207
+ ok: false,
208
+ error: `Blind Tilt setPosition requires a parameter. Expected: "<up|down>;<0-100>". Example: "up;50".`,
209
+ };
210
+ }
211
+ const parts = raw.split(';');
212
+ if (parts.length !== 2) {
213
+ return {
214
+ ok: false,
215
+ error: `Blind Tilt setPosition expects "<up|down>;<angle>", got ${JSON.stringify(raw)}. Example: "up;50".`,
216
+ };
217
+ }
218
+ const dir = parts[0].toLowerCase();
219
+ if (!BLIND_DIRECTION.has(dir)) {
220
+ return {
221
+ ok: false,
222
+ error: `Blind Tilt setPosition direction must be "up" or "down", got "${parts[0]}".`,
223
+ };
224
+ }
225
+ const angle = Number(parts[1]);
226
+ if (!Number.isInteger(angle) || angle < 0 || angle > 100) {
227
+ return {
228
+ ok: false,
229
+ error: `Blind Tilt setPosition angle must be an integer 0-100, got "${parts[1]}".`,
230
+ };
231
+ }
232
+ return { ok: true, normalized: `${dir};${angle}` };
233
+ }
234
+ function validateRelaySetMode(raw) {
235
+ if (raw === undefined || raw === '' || raw === 'default') {
236
+ return {
237
+ ok: false,
238
+ error: `Relay Switch setMode requires a parameter. Expected: "<1|2>;<0|1|2|3>". Example: "1;1" (channel 1, edge mode).`,
239
+ };
240
+ }
241
+ const parts = raw.split(';');
242
+ if (parts.length !== 2) {
243
+ return {
244
+ ok: false,
245
+ error: `Relay Switch setMode expects "<channel>;<mode>", got ${JSON.stringify(raw)}. Example: "1;1".`,
246
+ };
247
+ }
248
+ const ch = Number(parts[0]);
249
+ if (ch !== 1 && ch !== 2) {
250
+ return {
251
+ ok: false,
252
+ error: `Relay Switch setMode channel must be 1 or 2, got "${parts[0]}".`,
253
+ };
254
+ }
255
+ const mode = Number(parts[1]);
256
+ if (!Number.isInteger(mode) || mode < 0 || mode > 3) {
257
+ return {
258
+ ok: false,
259
+ error: `Relay Switch setMode mode must be 0-3 (0=toggle 1=edge 2=detached 3=momentary), got "${parts[1]}".`,
260
+ };
261
+ }
262
+ return { ok: true, normalized: `${ch};${mode}` };
263
+ }
@@ -129,8 +129,10 @@ export async function executeCommand(deviceId, cmd, parameter, commandType, clie
129
129
  /**
130
130
  * Validate a command against the locally-cached device → catalog mapping.
131
131
  * Returns `{ ok: true }` when validation passes or is skipped (unknown device,
132
- * custom IR button, etc.); returns `{ ok: false, error }` when the caller
133
- * should refuse the call.
132
+ * custom IR button, etc.). On a case-only mismatch the canonical command name
133
+ * is returned via `normalized` along with a `caseNormalizedFrom` field so the
134
+ * caller can emit a warning and continue with the canonical name.
135
+ * Returns `{ ok: false, error }` only when the caller should refuse the call.
134
136
  */
135
137
  export function validateCommand(deviceId, cmd, parameter, commandType) {
136
138
  if (commandType === 'customize')
@@ -144,24 +146,34 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
144
146
  const builtinCommands = match.commands.filter((c) => c.commandType !== 'customize');
145
147
  if (builtinCommands.length === 0)
146
148
  return { ok: true };
147
- const spec = builtinCommands.find((c) => c.command === cmd);
149
+ let spec = builtinCommands.find((c) => c.command === cmd);
150
+ let caseNormalizedFrom;
151
+ let normalizedCmd = cmd;
148
152
  if (!spec) {
149
153
  const unique = [...new Set(builtinCommands.map((c) => c.command))];
150
154
  const caseMatch = unique.find((c) => c.toLowerCase() === cmd.toLowerCase());
151
- const hint = caseMatch
152
- ? `Did you mean "${caseMatch}"? Supported commands: ${unique.join(', ')}`
153
- : `Supported commands: ${unique.join(', ')}`;
154
- return {
155
- ok: false,
156
- error: new CommandValidationError(`"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command', hint),
157
- };
155
+ if (caseMatch) {
156
+ // Case-only mismatch: normalize and continue.
157
+ caseNormalizedFrom = cmd;
158
+ normalizedCmd = caseMatch;
159
+ spec = builtinCommands.find((c) => c.command === caseMatch);
160
+ }
161
+ else {
162
+ const hint = `Supported commands: ${unique.join(', ')}`;
163
+ return {
164
+ ok: false,
165
+ error: new CommandValidationError(`"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command', hint),
166
+ };
167
+ }
158
168
  }
169
+ if (!spec)
170
+ return { ok: true, normalized: normalizedCmd, caseNormalizedFrom };
159
171
  const noParamExpected = spec.parameter === '—';
160
172
  const userProvidedParam = parameter !== undefined && parameter !== 'default';
161
173
  if (noParamExpected && userProvidedParam) {
162
174
  return {
163
175
  ok: false,
164
- error: new CommandValidationError(`"${cmd}" takes no parameter, but one was provided: "${parameter}".`, 'unexpected-parameter', `Try: switchbot devices command ${deviceId} ${cmd}`),
176
+ error: new CommandValidationError(`"${normalizedCmd}" takes no parameter, but one was provided: "${parameter}".`, 'unexpected-parameter', `Try: switchbot devices command ${deviceId} ${normalizedCmd}`),
165
177
  };
166
178
  }
167
179
  // Warn when a parameter is required but the user omitted it
@@ -170,12 +182,12 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
170
182
  const example = spec.exampleParams?.[0];
171
183
  return {
172
184
  ok: false,
173
- error: new CommandValidationError(`"${cmd}" requires a parameter (${spec.parameter}).`, 'missing-parameter', example
174
- ? `Example: switchbot devices command <deviceId> ${cmd} "${example}"`
185
+ error: new CommandValidationError(`"${normalizedCmd}" requires a parameter (${spec.parameter}).`, 'missing-parameter', example
186
+ ? `Example: switchbot devices command <deviceId> ${normalizedCmd} "${example}"`
175
187
  : `See: switchbot devices commands ${cached.type}`),
176
188
  };
177
189
  }
178
- return { ok: true };
190
+ return { ok: true, normalized: normalizedCmd, caseNormalizedFrom };
179
191
  }
180
192
  /**
181
193
  * Inspect catalog annotations to decide whether a command is destructive,
@@ -9,7 +9,10 @@ import { parseDurationToMs } from './flags.js';
9
9
  */
10
10
  export function intArg(flagName, opts) {
11
11
  return (value) => {
12
- if (value.startsWith('-')) {
12
+ // Flag-like tokens (`--something`, `-x`) are rejected up-front.
13
+ // Pure negative integers (`-1`, `-42`) fall through to min/max so the
14
+ // error classifies as a range error rather than "requires a numeric value".
15
+ if (value.startsWith('-') && !/^-\d+$/.test(value)) {
13
16
  throw new InvalidArgumentError(`${flagName} requires a numeric value, got "${value}". ` +
14
17
  `Did you forget a value? Use ${flagName}=<n> if the value really starts with "-".`);
15
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchbot/openapi-cli",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
5
5
  "keywords": [
6
6
  "switchbot",