@switchbot/openapi-cli 2.6.4 → 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/README.md CHANGED
@@ -6,8 +6,8 @@
6
6
  [![node](https://img.shields.io/node/v/@switchbot/openapi-cli.svg)](https://nodejs.org)
7
7
  [![CI](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml)
8
8
 
9
- Command-line interface for the [SwitchBot API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI).
10
- List devices, query live status, send control commands, run scenes, receive real-time events, and connect AI agents via the built-in MCP server — all from your terminal or shell scripts.
9
+ **SwitchBot** smart home CLI — control lights, locks, curtains, sensors, plugs, and IR appliances (TV/AC/fan) via the [SwitchBot Cloud API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI).
10
+ Run scenes, stream real-time events over MQTT, and plug AI agents into your home via the built-in MCP server — all from your terminal or shell scripts.
11
11
 
12
12
  - **npm package:** [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli)
13
13
  - **Source code:** [github.com/OpenWonderLabs/switchbot-openapi-cli](https://github.com/OpenWonderLabs/switchbot-openapi-cli)
@@ -87,6 +87,15 @@ export function createClient() {
87
87
  }
88
88
  throw new DryRunSignal(method, url);
89
89
  }
90
+ // P8: record the quota attempt BEFORE the request is dispatched so
91
+ // failures (timeouts / DNS errors / 5xx / aborted) also count. Only
92
+ // pre-flight refusals (daily-cap, --dry-run) above skip recording
93
+ // since they never touch the network. Retries re-enter this
94
+ // interceptor and record again, which matches the SwitchBot API
95
+ // billing model (every dispatched HTTP request consumes quota).
96
+ if (quotaEnabled) {
97
+ recordRequest(method, url);
98
+ }
90
99
  return config;
91
100
  });
92
101
  // Handle API-level errors (HTTP 200 but statusCode !== 100)
@@ -94,11 +103,6 @@ export function createClient() {
94
103
  if (verbose) {
95
104
  process.stderr.write(chalk.grey(`[verbose] ${response.status} ${response.statusText}\n`));
96
105
  }
97
- if (quotaEnabled && response.config) {
98
- const method = (response.config.method ?? 'get').toUpperCase();
99
- const url = `${response.config.baseURL ?? ''}${response.config.url ?? ''}`;
100
- recordRequest(method, url);
101
- }
102
106
  const data = response.data;
103
107
  if (data.statusCode !== undefined && data.statusCode !== 100) {
104
108
  const msg = API_ERROR_MESSAGES[data.statusCode] ??
@@ -161,13 +165,10 @@ export function createClient() {
161
165
  return sleep(delay).then(() => client.request(config));
162
166
  }
163
167
  }
164
- // Record exhausted/non-retryable HTTP responses too they count
165
- // against the daily quota.
166
- if (quotaEnabled && error.response && config) {
167
- const method = (config.method ?? 'get').toUpperCase();
168
- const url = `${config.baseURL ?? ''}${config.url ?? ''}`;
169
- recordRequest(method, url);
170
- }
168
+ // P8: quota already recorded in the request interceptor before
169
+ // dispatch — no extra bookkeeping needed here on the error path.
170
+ // Timeouts, DNS failures, 5xx, and exhausted retries all counted
171
+ // when the attempt was first made.
171
172
  if (status === 401) {
172
173
  throw new ApiError('Authentication failed: invalid token or daily 10,000-request quota exceeded', 401, {
173
174
  transient: false,
@@ -1,19 +1,21 @@
1
1
  import { printJson } from '../utils/output.js';
2
2
  import { loadCache } from '../devices/cache.js';
3
- import { getEffectiveCatalog } from '../devices/catalog.js';
3
+ import { getEffectiveCatalog, deriveSafetyTier, CATALOG_SCHEMA_VERSION, } from '../devices/catalog.js';
4
4
  import { readProfileMeta } from '../config.js';
5
5
  import { todayUsage, DAILY_QUOTA } from '../utils/quota.js';
6
6
  import { ALL_STRATEGIES } from '../utils/name-resolver.js';
7
+ import { IDENTITY } from './identity.js';
7
8
  import { createRequire } from 'node:module';
8
9
  const require = createRequire(import.meta.url);
9
10
  const { version: pkgVersion } = require('../../package.json');
10
- const IDENTITY = {
11
- product: 'SwitchBot',
12
- domain: 'IoT smart home device control',
13
- vendor: 'Wonderlabs, Inc.',
14
- apiVersion: 'v1.1',
15
- authMethod: 'HMAC-SHA256 token+secret',
16
- };
11
+ /**
12
+ * Schema version of the agent-bootstrap payload. Must stay in lockstep
13
+ * with the catalog schema — bootstrap consumers (AI agents) reason about
14
+ * catalog-derived fields (safetyTier, destructive flag), so a drift
15
+ * between the two would silently break their assumptions. `doctor`
16
+ * fails the `catalog-schema` check when these differ.
17
+ */
18
+ export const AGENT_BOOTSTRAP_SCHEMA_VERSION = CATALOG_SCHEMA_VERSION;
17
19
  const SAFETY_TIERS = {
18
20
  read: 'No state mutation; safe to call freely.',
19
21
  action: 'Mutates device/cloud state but reversible (turnOn, setColor).',
@@ -83,17 +85,21 @@ Examples:
83
85
  category: e.category,
84
86
  role: e.role ?? null,
85
87
  readOnly: e.readOnly ?? false,
86
- commands: e.commands.map((c) => ({
87
- command: c.command,
88
- parameter: c.parameter,
89
- destructive: Boolean(c.destructive),
90
- idempotent: Boolean(c.idempotent),
91
- })),
88
+ commands: e.commands.map((c) => {
89
+ const tier = deriveSafetyTier(c, e);
90
+ return {
91
+ command: c.command,
92
+ parameter: c.parameter,
93
+ safetyTier: tier,
94
+ destructive: tier === 'destructive',
95
+ idempotent: Boolean(c.idempotent),
96
+ };
97
+ }),
92
98
  statusFields: e.statusFields ?? [],
93
99
  };
94
100
  });
95
101
  const payload = {
96
- schemaVersion: '1.0',
102
+ schemaVersion: AGENT_BOOTSTRAP_SCHEMA_VERSION,
97
103
  generatedAt: new Date().toISOString(),
98
104
  cliVersion: pkgVersion,
99
105
  identity: IDENTITY,
@@ -1,5 +1,5 @@
1
1
  import { intArg, enumArg, stringArg } from '../utils/arg-parsers.js';
2
- import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, emitJsonError, exitWithError } from '../utils/output.js';
2
+ import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, exitWithError } from '../utils/output.js';
3
3
  import { fetchDeviceList, executeCommand, isDestructiveCommand, buildHubLocationMap, } from '../lib/devices.js';
4
4
  import { createClient } from '../api/client.js';
5
5
  import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js';
@@ -96,7 +96,8 @@ export function registerBatchCommand(devices) {
96
96
  .option('--concurrency <n>', 'Max parallel in-flight requests (default 5)', intArg('--concurrency', { min: 1 }), '5')
97
97
  .option('--max-concurrent <n>', 'Alias for --concurrency; takes priority when set', intArg('--max-concurrent', { min: 1 }))
98
98
  .option('--stagger <ms>', 'Fixed delay between task starts in ms (default 0 = random 20-60ms jitter)', intArg('--stagger', { min: 0 }), '0')
99
- .option('--plan', 'With --dry-run: emit a plan JSON document instead of executing anything')
99
+ .option('--plan', '[DEPRECATED, use --emit-plan] With --dry-run: emit a plan JSON document instead of executing anything')
100
+ .option('--emit-plan', 'With --dry-run: emit a plan JSON document instead of executing anything')
100
101
  .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
101
102
  .option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
102
103
  .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
@@ -127,8 +128,9 @@ Concurrency & pacing:
127
128
  --stagger <ms> Fixed delay between task starts; default 0 uses random 20-60ms jitter.
128
129
 
129
130
  Planning:
130
- --dry-run --plan Print the plan JSON without executing anything. Useful
131
+ --dry-run --emit-plan Print the plan JSON without executing anything. Useful
131
132
  for agents that want to show the user what will run.
133
+ (--plan is the deprecated alias, removed in v3.0.)
132
134
 
133
135
  Safety:
134
136
  Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
@@ -146,6 +148,17 @@ Examples:
146
148
  // Trailing "-" sentinel selects stdin mode.
147
149
  const extra = commandObj.args ?? [];
148
150
  const readStdin = Boolean(options.stdin) || extra.includes('-');
151
+ // P12: --plan is deprecated in favor of --emit-plan. Reject both
152
+ // together (conflicting) and warn when only the old flag is used.
153
+ if (options.plan && options.emitPlan) {
154
+ handleError(new UsageError('Use --emit-plan; --plan is deprecated and cannot be combined with --emit-plan.'));
155
+ return;
156
+ }
157
+ if (options.plan && !options.emitPlan) {
158
+ // Warning goes to stderr so it cannot corrupt --json output on stdout.
159
+ console.error('[WARN] --plan is deprecated; use --emit-plan. Will be removed in v3.0.');
160
+ }
161
+ const emitPlan = Boolean(options.emitPlan || options.plan);
149
162
  // Accept --idempotency-key as alias; reject when both forms are supplied.
150
163
  if (options.idempotencyKey !== undefined && options.idempotencyKeyPrefix !== undefined) {
151
164
  handleError(new UsageError('Use either --idempotency-key or --idempotency-key-prefix, not both.'));
@@ -216,22 +229,14 @@ Examples:
216
229
  }
217
230
  }
218
231
  if (blockedForDestructive.length > 0 && !options.yes) {
219
- if (isJsonMode()) {
220
- const deviceIds = blockedForDestructive.map((b) => b.deviceId);
221
- emitJsonError({
222
- code: 2,
223
- kind: 'guard',
224
- message: `Destructive command "${cmd}" requires --yes to run on ${blockedForDestructive.length} device(s).`,
225
- hint: 'Re-issue the call with --yes to proceed.',
226
- context: { command: cmd, deviceIds },
227
- });
228
- }
229
- else {
230
- console.error(`Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes:`);
231
- for (const b of blockedForDestructive)
232
- console.error(` ${b.deviceId}`);
233
- }
234
- process.exit(2);
232
+ const deviceIds = blockedForDestructive.map((b) => b.deviceId);
233
+ exitWithError({
234
+ code: 2,
235
+ kind: 'guard',
236
+ message: `Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes: ${deviceIds.join(', ')}`,
237
+ hint: 'Re-issue the call with --yes to proceed.',
238
+ context: { command: cmd, deviceIds },
239
+ });
235
240
  }
236
241
  // parameter may be a JSON object string; mirror the single-command action.
237
242
  let parsedParam = parameter ?? 'default';
@@ -247,8 +252,8 @@ Examples:
247
252
  const concurrency = Math.max(1, Number.parseInt(maxConcurrentRaw, 10) || DEFAULT_CONCURRENCY);
248
253
  const staggerMs = Math.max(0, Number.parseInt(options.stagger, 10) || 0);
249
254
  const dryRun = isDryRun();
250
- // --dry-run --plan: emit a plan document and return without executing.
251
- if (dryRun && options.plan) {
255
+ // --dry-run --emit-plan (or legacy --plan): emit a plan document and return without executing.
256
+ if (dryRun && emitPlan) {
252
257
  const steps = resolved.ids.map((id) => ({
253
258
  deviceId: id,
254
259
  command: cmd,
@@ -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 = {};
@@ -145,14 +145,11 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
145
145
  }
146
146
  if (options.fromEnvFile) {
147
147
  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);
148
+ exitWithError({
149
+ code: 2,
150
+ kind: 'usage',
151
+ message: `--from-env-file: file not found: ${options.fromEnvFile}`,
152
+ });
156
153
  }
157
154
  const parsed = parseEnvFile(options.fromEnvFile);
158
155
  token = token ?? parsed.token;
@@ -160,37 +157,33 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
160
157
  }
161
158
  if (options.fromOp) {
162
159
  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);
160
+ exitWithError({
161
+ code: 2,
162
+ kind: 'usage',
163
+ message: '--from-op requires --op-secret <ref> for the secret reference.',
164
+ });
171
165
  }
172
166
  try {
173
167
  token = readFromOp(options.fromOp);
174
168
  secret = readFromOp(options.opSecret);
175
169
  }
176
170
  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);
171
+ exitWithError({
172
+ code: 1,
173
+ kind: 'runtime',
174
+ message: `1Password CLI read failed: ${err instanceof Error ? err.message : String(err)}`,
175
+ hint: 'Ensure the "op" CLI is installed and authenticated (op signin).',
176
+ });
186
177
  }
187
178
  }
188
179
  // No credentials yet and stdin is a TTY → interactive prompt (safest path).
189
180
  if ((!token || !secret) && !options.fromEnvFile && !options.fromOp && process.stdin.isTTY) {
190
181
  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);
182
+ exitWithError({
183
+ code: 2,
184
+ kind: 'usage',
185
+ message: 'Interactive mode cannot run under --json. Provide token/secret via --from-env-file, --from-op, or positional args.',
186
+ });
194
187
  }
195
188
  try {
196
189
  if (!token)
@@ -204,14 +197,11 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
204
197
  }
205
198
  }
206
199
  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);
200
+ exitWithError({
201
+ code: 2,
202
+ kind: 'usage',
203
+ message: 'Missing token/secret. Run interactively, or use --from-env-file / --from-op, or pass positional arguments (discouraged).',
204
+ });
215
205
  }
216
206
  saveConfig(token, secret, {
217
207
  label: options.label,