@switchbot/openapi-cli 2.2.0 → 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.
@@ -1,86 +1,11 @@
1
+ import { intArg, stringArg } from '../utils/arg-parsers.js';
1
2
  import { handleError, isJsonMode, printJson, UsageError } from '../utils/output.js';
2
3
  import { getCachedDevice } from '../devices/cache.js';
3
4
  import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js';
4
5
  import { isDryRun } from '../utils/flags.js';
5
6
  import { resolveDeviceId } from '../utils/name-resolver.js';
6
7
  import { DryRunSignal } from '../api/client.js';
7
- // ---- Mapping tables --------------------------------------------------------
8
- const AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
9
- const AC_FAN_MAP = { auto: 1, low: 2, mid: 3, high: 4 };
10
- const CURTAIN_MODE_MAP = { default: 'ff', performance: '0', silent: '1' };
11
- const RELAY_MODE_MAP = { toggle: 0, edge: 1, detached: 2, momentary: 3 };
12
- const BLIND_DIRECTION = new Set(['up', 'down']);
13
- // ---- Translators -----------------------------------------------------------
14
- function buildAcSetAll(opts) {
15
- if (!opts.temp)
16
- throw new UsageError('--temp is required for setAll (e.g. --temp 26)');
17
- if (!opts.mode)
18
- throw new UsageError('--mode is required for setAll (auto|cool|dry|fan|heat)');
19
- if (!opts.fan)
20
- throw new UsageError('--fan is required for setAll (auto|low|mid|high)');
21
- if (!opts.power)
22
- throw new UsageError('--power is required for setAll (on|off)');
23
- const temp = parseInt(opts.temp, 10);
24
- if (!Number.isFinite(temp) || temp < 16 || temp > 30) {
25
- throw new UsageError(`--temp must be an integer between 16 and 30 (got "${opts.temp}")`);
26
- }
27
- const modeInt = AC_MODE_MAP[opts.mode.toLowerCase()];
28
- if (modeInt === undefined) {
29
- throw new UsageError(`--mode must be one of: auto, cool, dry, fan, heat (got "${opts.mode}")`);
30
- }
31
- const fanInt = AC_FAN_MAP[opts.fan.toLowerCase()];
32
- if (fanInt === undefined) {
33
- throw new UsageError(`--fan must be one of: auto, low, mid, high (got "${opts.fan}")`);
34
- }
35
- const power = opts.power.toLowerCase();
36
- if (power !== 'on' && power !== 'off') {
37
- throw new UsageError(`--power must be "on" or "off" (got "${opts.power}")`);
38
- }
39
- return `${temp},${modeInt},${fanInt},${power}`;
40
- }
41
- function buildCurtainSetPosition(opts) {
42
- if (!opts.position)
43
- throw new UsageError('--position is required (0-100)');
44
- const pos = parseInt(opts.position, 10);
45
- if (!Number.isFinite(pos) || pos < 0 || pos > 100) {
46
- throw new UsageError(`--position must be an integer between 0 and 100 (got "${opts.position}")`);
47
- }
48
- const modeStr = opts.mode ? CURTAIN_MODE_MAP[opts.mode.toLowerCase()] : 'ff';
49
- if (modeStr === undefined) {
50
- throw new UsageError(`--mode must be one of: default, performance, silent (got "${opts.mode}")`);
51
- }
52
- return `0,${modeStr},${pos}`;
53
- }
54
- function buildBlindTiltSetPosition(opts) {
55
- if (!opts.direction)
56
- throw new UsageError('--direction is required (up|down)');
57
- if (!opts.angle)
58
- throw new UsageError('--angle is required (0-100)');
59
- const dir = opts.direction.toLowerCase();
60
- if (!BLIND_DIRECTION.has(dir)) {
61
- throw new UsageError(`--direction must be "up" or "down" (got "${opts.direction}")`);
62
- }
63
- const angle = parseInt(opts.angle, 10);
64
- if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
65
- throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
66
- }
67
- return `${dir};${angle}`;
68
- }
69
- function buildRelaySetMode(opts) {
70
- if (!opts.channel)
71
- throw new UsageError('--channel is required (1 or 2)');
72
- if (!opts.mode)
73
- throw new UsageError('--mode is required (toggle|edge|detached|momentary)');
74
- const ch = parseInt(opts.channel, 10);
75
- if (ch !== 1 && ch !== 2) {
76
- throw new UsageError(`--channel must be 1 or 2 (got "${opts.channel}")`);
77
- }
78
- const modeInt = RELAY_MODE_MAP[opts.mode.toLowerCase()];
79
- if (modeInt === undefined) {
80
- throw new UsageError(`--mode must be one of: toggle, edge, detached, momentary (got "${opts.mode}")`);
81
- }
82
- return `${ch};${modeInt}`;
83
- }
8
+ import { buildAcSetAll, buildCurtainSetPosition, buildBlindTiltSetPosition, buildRelaySetMode, } from '../devices/param-validator.js';
84
9
  // ---- Registration ----------------------------------------------------------
85
10
  export function registerExpandCommand(devices) {
86
11
  devices
@@ -88,15 +13,15 @@ export function registerExpandCommand(devices) {
88
13
  .description('Send a command with semantic flags instead of raw positional parameters')
89
14
  .argument('[deviceId]', 'Target device ID from "devices list" (or use --name)')
90
15
  .argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)')
91
- .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
92
- .option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)')
93
- .option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary')
94
- .option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high')
95
- .option('--power <state>', 'AC setAll: on|off')
96
- .option('--position <percent>', 'Curtain setPosition: 0-100 (0=open, 100=closed)')
97
- .option('--direction <dir>', 'Blind Tilt setPosition: up|down')
98
- .option('--angle <percent>', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)')
99
- .option('--channel <n>', 'Relay Switch 2 setMode: channel 1 or 2')
16
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
17
+ .option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)', intArg('--temp', { min: 16, max: 30 }))
18
+ .option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary', stringArg('--mode'))
19
+ .option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high', stringArg('--fan'))
20
+ .option('--power <state>', 'AC setAll: on|off', stringArg('--power'))
21
+ .option('--position <percent>', 'Curtain setPosition: 0-100 (0=open, 100=closed)', intArg('--position', { min: 0, max: 100 }))
22
+ .option('--direction <dir>', 'Blind Tilt setPosition: up|down', stringArg('--direction'))
23
+ .option('--angle <percent>', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)', intArg('--angle', { min: 0, max: 100 }))
24
+ .option('--channel <n>', 'Relay Switch 2 setMode: channel 1 or 2', intArg('--channel', { min: 1, max: 2 }))
100
25
  .option('--yes', 'Confirm destructive commands')
101
26
  .addHelpText('after', `
102
27
  Translates semantic flags into the wire parameter format, then sends the command.
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
+ import { intArg, stringArg } from '../utils/arg-parsers.js';
3
4
  import { printJson, isJsonMode, handleError } from '../utils/output.js';
4
5
  import { readAudit } from '../utils/audit.js';
5
6
  import { executeCommand } from '../lib/devices.js';
@@ -21,8 +22,8 @@ Examples:
21
22
  history
22
23
  .command('show')
23
24
  .description('Print recent audit entries')
24
- .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`)
25
- .option('--limit <n>', 'Show only the last N entries')
25
+ .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
26
+ .option('--limit <n>', 'Show only the last N entries', intArg('--limit', { min: 1 }))
26
27
  .action((options) => {
27
28
  const file = options.file ?? DEFAULT_AUDIT;
28
29
  const entries = readAudit(file);
@@ -52,7 +53,7 @@ Examples:
52
53
  .command('replay')
53
54
  .description('Re-run a recorded command by its 1-indexed position')
54
55
  .argument('<index>', 'Entry index (1 = oldest; as shown by "history show")')
55
- .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`)
56
+ .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
56
57
  .addHelpText('after', `
57
58
  Dry-run-honouring: pass --dry-run on the parent command to preview without
58
59
  sending the actual call. Errors from the recorded entry are NOT replayed —
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { z } from 'zod';
5
+ import { intArg, stringArg } from '../utils/arg-parsers.js';
5
6
  import { handleError, isJsonMode } from '../utils/output.js';
6
7
  import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
7
8
  import { fetchScenes, executeScene } from '../lib/scenes.js';
@@ -489,11 +490,11 @@ Inspect locally:
489
490
  mcp
490
491
  .command('serve')
491
492
  .description('Start the MCP server on stdio (default) or HTTP (--port)')
492
- .option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)')
493
- .option('--bind <host>', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', '127.0.0.1')
494
- .option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)')
495
- .option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)')
496
- .option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', '60')
493
+ .option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)', intArg('--port', { min: 1, max: 65535 }))
494
+ .option('--bind <host>', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', stringArg('--bind'), '127.0.0.1')
495
+ .option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)', stringArg('--auth-token'))
496
+ .option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
497
+ .option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60')
497
498
  .action(async (options) => {
498
499
  try {
499
500
  if (options.port) {
@@ -1,3 +1,4 @@
1
+ import { enumArg, stringArg } from '../utils/arg-parsers.js';
1
2
  import { printJson } from '../utils/output.js';
2
3
  import { getEffectiveCatalog } from '../devices/catalog.js';
3
4
  function toSchemaEntry(e) {
@@ -24,15 +25,17 @@ function toSchemaCommand(c) {
24
25
  };
25
26
  }
26
27
  export function registerSchemaCommand(program) {
28
+ const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'];
29
+ const CATEGORIES = ['physical', 'ir'];
27
30
  const schema = program
28
31
  .command('schema')
29
32
  .description('Export the device catalog as structured JSON (for agent prompts / tooling)');
30
33
  schema
31
34
  .command('export')
32
35
  .description('Print the full catalog as structured JSON (one object per type)')
33
- .option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")')
34
- .option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other')
35
- .option('--category <cat>', 'Restrict to "physical" or "ir"')
36
+ .option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type'))
37
+ .option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES))
38
+ .option('--category <cat>', 'Restrict to "physical" or "ir"', enumArg('--category', CATEGORIES))
36
39
  .addHelpText('after', `
37
40
  Output is always JSON (this command ignores --format). The output is a
38
41
  catalog export — not a formal JSON Schema standard document — suitable for
@@ -2,6 +2,7 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.
2
2
  import { fetchDeviceStatus } from '../lib/devices.js';
3
3
  import { getCachedDevice } from '../devices/cache.js';
4
4
  import { parseDurationToMs, getFields } from '../utils/flags.js';
5
+ import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js';
5
6
  import { createClient } from '../api/client.js';
6
7
  import { resolveDeviceId } from '../utils/name-resolver.js';
7
8
  const DEFAULT_INTERVAL_MS = 30_000;
@@ -57,9 +58,9 @@ export function registerWatchCommand(devices) {
57
58
  .command('watch')
58
59
  .description('Poll device status on an interval and emit field-level changes (JSONL)')
59
60
  .argument('[deviceId...]', 'One or more deviceIds to watch (or use --name for one device)')
60
- .option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)')
61
- .option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, '30s')
62
- .option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)')
61
+ .option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)', stringArg('--name'))
62
+ .option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, durationArg('--interval'), '30s')
63
+ .option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
63
64
  .option('--include-unchanged', 'Emit a tick even when no field changed')
64
65
  .addHelpText('after', `
65
66
  Each poll emits one JSON line per deviceId with the shape:
@@ -1,3 +1,4 @@
1
+ import { stringArg } from '../utils/arg-parsers.js';
1
2
  import { createClient } from '../api/client.js';
2
3
  import { printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
3
4
  import chalk from 'chalk';
@@ -54,7 +55,7 @@ Example:
54
55
  webhook
55
56
  .command('query')
56
57
  .description('Query webhook configuration')
57
- .option('--details <url>', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL')
58
+ .option('--details <url>', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL', stringArg('--details'))
58
59
  .addHelpText('after', `
59
60
  Without --details, lists all configured webhook URLs.
60
61
  With --details, prints enable/deviceList/createTime/lastUpdateTime for the given URL.
package/dist/config.js CHANGED
@@ -99,7 +99,7 @@ export function showConfig() {
99
99
  const envSecret = process.env.SWITCHBOT_SECRET;
100
100
  if (envToken && envSecret) {
101
101
  console.log('Credential source: environment variables');
102
- console.log(`token : ${envToken}`);
102
+ console.log(`token : ${maskCredential(envToken)}`);
103
103
  console.log(`secret: ${maskSecret(envSecret)}`);
104
104
  return;
105
105
  }
@@ -112,13 +112,18 @@ export function showConfig() {
112
112
  const raw = fs.readFileSync(file, 'utf-8');
113
113
  const cfg = JSON.parse(raw);
114
114
  console.log(`Credential source: ${file}`);
115
- console.log(`token : ${cfg.token}`);
115
+ console.log(`token : ${maskCredential(cfg.token)}`);
116
116
  console.log(`secret: ${maskSecret(cfg.secret)}`);
117
117
  }
118
118
  catch {
119
119
  console.error('Failed to read config file');
120
120
  }
121
121
  }
122
+ function maskCredential(token) {
123
+ if (token.length <= 8)
124
+ return '*'.repeat(Math.max(4, token.length));
125
+ return token.slice(0, 4) + '*'.repeat(token.length - 8) + token.slice(-4);
126
+ }
122
127
  function maskSecret(secret) {
123
128
  if (secret.length <= 4)
124
129
  return '****';
@@ -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
+ }
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { Command, CommanderError } from 'commander';
2
+ import { Command, CommanderError, InvalidArgumentError } from 'commander';
3
3
  import { createRequire } from 'node:module';
4
+ import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
5
+ import { parseDurationToMs } from './utils/flags.js';
4
6
  import { registerConfigCommand } from './commands/config.js';
5
7
  import { registerDevicesCommand } from './commands/devices.js';
6
8
  import { registerScenesCommand } from './commands/scenes.js';
@@ -19,26 +21,44 @@ import { registerCapabilitiesCommand } from './commands/capabilities.js';
19
21
  const require = createRequire(import.meta.url);
20
22
  const { version: pkgVersion } = require('../package.json');
21
23
  const program = new Command();
24
+ // Top-level subcommand names. Used by stringArg to produce clearer errors when
25
+ // a value is omitted and the next argv token turns out to be a subcommand name.
26
+ const TOP_LEVEL_COMMANDS = [
27
+ 'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp',
28
+ 'quota', 'catalog', 'cache', 'events', 'doctor', 'schema',
29
+ 'history', 'plan', 'capabilities',
30
+ ];
31
+ const cacheModeArg = (value) => {
32
+ if (value.startsWith('-')) {
33
+ throw new InvalidArgumentError(`--cache requires a mode value, got "${value}". ` +
34
+ `Valid: "off", "auto", or a duration like "5m", "1h". Use --cache=<mode> if needed.`);
35
+ }
36
+ if (value === 'off' || value === 'auto')
37
+ return value;
38
+ if (parseDurationToMs(value) !== null)
39
+ return value;
40
+ throw new InvalidArgumentError(`--cache must be "off", "auto", or a duration like "30s"/"5m"/"1h" (got "${value}")`);
41
+ };
22
42
  program
23
43
  .name('switchbot')
24
44
  .description('Command-line tool for SwitchBot API v1.1')
25
45
  .version(pkgVersion)
26
46
  .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
27
- .option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id')
28
- .option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)')
47
+ .option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id']))
48
+ .option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
29
49
  .option('-v, --verbose', 'Log HTTP request/response details to stderr')
30
50
  .option('--dry-run', 'Print mutating requests without sending them (GETs still execute)')
31
- .option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)')
32
- .option('--retry-on-429 <n>', 'Max 429 retries before surfacing the error (default: 3)')
33
- .option('--backoff <strategy>', 'Backoff strategy for retries: "linear" or "exponential" (default)')
51
+ .option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)', intArg('--timeout', { min: 1 }))
52
+ .option('--retry-on-429 <n>', 'Max 429 retries before surfacing the error (default: 3)', intArg('--retry-on-429', { min: 0 }))
53
+ .option('--backoff <strategy>', 'Backoff strategy for retries: "linear" or "exponential" (default)', enumArg('--backoff', ['linear', 'exponential']))
34
54
  .option('--no-retry', 'Disable 429 retries entirely (equivalent to --retry-on-429 0)')
35
55
  .option('--no-quota', 'Disable the local ~/.switchbot/quota.json counter for this run')
36
- .option('--cache <mode>', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)')
56
+ .option('--cache <mode>', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)', cacheModeArg)
37
57
  .option('--no-cache', 'Disable cache reads (equivalent to --cache off)')
38
- .option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)')
39
- .option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json')
58
+ .option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)', stringArg('--config', { disallow: TOP_LEVEL_COMMANDS }))
59
+ .option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json', stringArg('--profile', { disallow: TOP_LEVEL_COMMANDS }))
40
60
  .option('--audit-log', 'Append every mutating command to JSONL audit log (default path: ~/.switchbot/audit.log)')
41
- .option('--audit-log-path <path>', 'Custom audit log file path; use together with --audit-log')
61
+ .option('--audit-log-path <path>', 'Custom audit log file path; use together with --audit-log', stringArg('--audit-log-path', { disallow: TOP_LEVEL_COMMANDS }))
42
62
  .showHelpAfterError('(run with --help to see usage)')
43
63
  .showSuggestionAfterError();
44
64
  registerConfigCommand(program);
@@ -95,24 +115,33 @@ Discovery:
95
115
 
96
116
  Docs: https://github.com/OpenWonderLabs/SwitchBotAPI
97
117
  `);
98
- // Map commander usage errors (unknown option, missing argument, etc.) to exit code 2.
99
- program.exitOverride((err) => {
100
- // --help and --version print to stdout and exit 0
118
+ // Map commander usage errors (unknown option, missing argument, argParser
119
+ // InvalidArgumentError, etc.) to exit code 2. Commander's exitOverride is
120
+ // per-command: subcommand errors won't bubble to the root override, so walk
121
+ // every registered command and apply the same handler.
122
+ const usageExitHandler = (err) => {
101
123
  if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
102
124
  process.exit(0);
103
125
  }
104
- // Everything else from commander (unknown option, missing argument,
105
- // invalid choice, conflicting options, unknown command) is a usage error.
106
126
  process.exit(2);
107
- });
127
+ };
128
+ function applyExitOverride(cmd) {
129
+ cmd.exitOverride(usageExitHandler);
130
+ cmd.commands.forEach(applyExitOverride);
131
+ }
132
+ applyExitOverride(program);
108
133
  try {
109
134
  await program.parseAsync();
110
135
  }
111
136
  catch (err) {
112
- // exitOverride already handled CommanderErrors; anything that escapes is a
113
- // runtime error (should be rare since actions use handleError).
137
+ // Subcommand-level CommanderErrors (e.g. InvalidArgumentError from an
138
+ // argParser on a subcommand option) don't always hit the root exitOverride.
139
+ // Mirror the root mapping so all usage errors surface as exit 2.
114
140
  if (err instanceof CommanderError) {
115
- process.exit(err.exitCode ?? 2);
141
+ if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
142
+ process.exit(0);
143
+ }
144
+ process.exit(2);
116
145
  }
117
146
  throw err;
118
147
  }