@switchbot/openapi-cli 2.3.0 → 2.4.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.
@@ -4,7 +4,23 @@ import { buildAuthHeaders } from '../auth.js';
4
4
  import { loadConfig } from '../config.js';
5
5
  import { isVerbose, isDryRun, getTimeout, getRetryOn429, getBackoffStrategy, isQuotaDisabled, } from '../utils/flags.js';
6
6
  import { nextRetryDelayMs, sleep } from '../utils/retry.js';
7
- import { recordRequest } from '../utils/quota.js';
7
+ import { recordRequest, checkDailyCap } from '../utils/quota.js';
8
+ import { readProfileMeta } from '../config.js';
9
+ import { getActiveProfile } from '../lib/request-context.js';
10
+ import { redactHeaders, warnOnceIfUnsafe } from '../utils/redact.js';
11
+ class DailyCapExceededError extends Error {
12
+ cap;
13
+ total;
14
+ profile;
15
+ constructor(cap, total, profile) {
16
+ super(`Local daily cap reached: ${total}/${cap} SwitchBot API calls used today${profile ? ` for profile "${profile}"` : ''}. ` +
17
+ `Raise with: switchbot ${profile ? `--profile ${profile} ` : ''}config set-token --daily-cap <N>`);
18
+ this.cap = cap;
19
+ this.total = total;
20
+ this.profile = profile;
21
+ this.name = 'DailyCapExceededError';
22
+ }
23
+ }
8
24
  const API_ERROR_MESSAGES = {
9
25
  151: 'Device type does not support this command',
10
26
  152: 'Device ID does not exist',
@@ -31,18 +47,34 @@ export function createClient() {
31
47
  const maxRetries = getRetryOn429();
32
48
  const backoff = getBackoffStrategy();
33
49
  const quotaEnabled = !isQuotaDisabled();
50
+ const profile = getActiveProfile();
51
+ const profileMeta = readProfileMeta(profile);
52
+ const dailyCap = profileMeta?.limits?.dailyCap;
34
53
  const client = axios.create({
35
54
  baseURL: 'https://api.switch-bot.com',
36
55
  timeout: getTimeout(),
37
56
  });
38
57
  // Inject auth headers; optionally log the request; short-circuit on --dry-run.
39
58
  client.interceptors.request.use((config) => {
59
+ // Pre-flight cap check: refuse the call before it touches the network.
60
+ if (dailyCap) {
61
+ const check = checkDailyCap(dailyCap);
62
+ if (check.over) {
63
+ throw new DailyCapExceededError(dailyCap, check.total, profile);
64
+ }
65
+ }
40
66
  const authHeaders = buildAuthHeaders(token, secret);
41
67
  Object.assign(config.headers, authHeaders);
42
68
  const method = (config.method ?? 'get').toUpperCase();
43
69
  const url = `${config.baseURL ?? ''}${config.url ?? ''}`;
44
70
  if (verbose) {
71
+ warnOnceIfUnsafe();
45
72
  process.stderr.write(chalk.grey(`[verbose] ${method} ${url}\n`));
73
+ const { safe, redactedCount } = redactHeaders(config.headers);
74
+ process.stderr.write(chalk.grey(`[verbose] headers: ${JSON.stringify(safe)}\n`));
75
+ if (redactedCount > 0) {
76
+ process.stderr.write(chalk.grey(`[verbose] 🔒 ${redactedCount} sensitive header(s) redacted.\n`));
77
+ }
46
78
  if (config.data !== undefined) {
47
79
  process.stderr.write(chalk.grey(`[verbose] body: ${JSON.stringify(config.data)}\n`));
48
80
  }
@@ -0,0 +1,125 @@
1
+ import { printJson } from '../utils/output.js';
2
+ import { loadCache } from '../devices/cache.js';
3
+ import { getEffectiveCatalog } from '../devices/catalog.js';
4
+ import { readProfileMeta } from '../config.js';
5
+ import { todayUsage, DAILY_QUOTA } from '../utils/quota.js';
6
+ import { createRequire } from 'node:module';
7
+ const require = createRequire(import.meta.url);
8
+ const { version: pkgVersion } = require('../../package.json');
9
+ const IDENTITY = {
10
+ product: 'SwitchBot',
11
+ domain: 'IoT smart home device control',
12
+ vendor: 'Wonderlabs, Inc.',
13
+ apiVersion: 'v1.1',
14
+ authMethod: 'HMAC-SHA256 token+secret',
15
+ };
16
+ const SAFETY_TIERS = {
17
+ read: 'No state mutation; safe to call freely.',
18
+ action: 'Mutates device/cloud state but reversible (turnOn, setColor).',
19
+ destructive: 'Hard to reverse / physical-world side effects (unlock). Requires confirmation.',
20
+ };
21
+ const QUICK_REFERENCE = {
22
+ discovery: ['devices list', 'devices describe <id>', 'devices status <id>'],
23
+ action: ['devices command <id> <cmd>', 'devices command --name <q> <cmd>', 'scenes execute <id>'],
24
+ safety: ['--dry-run', '--idempotency-key <k>', '--audit-log', '--no-quota'],
25
+ observability: ['doctor --json', 'quota status', 'cache status', 'events mqtt-tail'],
26
+ history: ['history range <id> --since 7d', 'history stats <id>'],
27
+ };
28
+ export function registerAgentBootstrapCommand(program) {
29
+ program
30
+ .command('agent-bootstrap')
31
+ .description('Print a compact, aggregate JSON snapshot for agent onboarding — combines identity, cached devices, catalog summary, quota usage, and profile in a single call. Offline-safe; does not hit the API.')
32
+ .option('--compact', 'Emit an even smaller payload by dropping catalog descriptions and non-essential fields (target: <20 KB).')
33
+ .addHelpText('after', `
34
+ Output is always JSON (this command ignores --format). It is a one-shot
35
+ orientation document for an agent/LLM to understand what's available without
36
+ spending quota. It reads from local cache (devices + quota + profile) and the
37
+ bundled catalog — no network calls.
38
+
39
+ For fresher device state, have the agent follow up with:
40
+ $ switchbot devices list --json # refreshes cache
41
+ $ switchbot devices status <id> --json
42
+
43
+ Examples:
44
+ $ switchbot agent-bootstrap --compact | wc -c # fit in agent context window
45
+ $ switchbot agent-bootstrap | jq '.devices | length'
46
+ $ switchbot agent-bootstrap --compact | jq '.quickReference'
47
+ `)
48
+ .action((opts) => {
49
+ const compact = Boolean(opts.compact);
50
+ const cache = loadCache();
51
+ const catalog = getEffectiveCatalog();
52
+ const usage = todayUsage();
53
+ const meta = readProfileMeta(undefined);
54
+ const cachedDevices = cache
55
+ ? Object.entries(cache.devices).map(([id, d]) => ({
56
+ deviceId: id,
57
+ type: d.type,
58
+ name: d.name,
59
+ category: d.category,
60
+ roomName: d.roomName ?? null,
61
+ }))
62
+ : [];
63
+ const usedTypes = new Set(cachedDevices.map((d) => d.type.toLowerCase()));
64
+ const relevantCatalog = cachedDevices.length > 0
65
+ ? catalog.filter((e) => usedTypes.has(e.type.toLowerCase()) ||
66
+ (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase())))
67
+ : catalog;
68
+ const catalogTypes = relevantCatalog.map((e) => {
69
+ if (compact) {
70
+ return {
71
+ type: e.type,
72
+ category: e.category,
73
+ role: e.role ?? null,
74
+ readOnly: e.readOnly ?? false,
75
+ commands: e.commands.map((c) => c.command),
76
+ statusFields: e.statusFields ?? [],
77
+ };
78
+ }
79
+ return {
80
+ type: e.type,
81
+ category: e.category,
82
+ role: e.role ?? null,
83
+ readOnly: e.readOnly ?? false,
84
+ commands: e.commands.map((c) => ({
85
+ command: c.command,
86
+ parameter: c.parameter,
87
+ destructive: Boolean(c.destructive),
88
+ idempotent: Boolean(c.idempotent),
89
+ })),
90
+ statusFields: e.statusFields ?? [],
91
+ };
92
+ });
93
+ const payload = {
94
+ schemaVersion: '1.0',
95
+ generatedAt: new Date().toISOString(),
96
+ cliVersion: pkgVersion,
97
+ identity: IDENTITY,
98
+ quickReference: QUICK_REFERENCE,
99
+ safetyTiers: SAFETY_TIERS,
100
+ profile: meta
101
+ ? {
102
+ label: meta.label ?? null,
103
+ description: meta.description ?? null,
104
+ dailyCap: meta.limits?.dailyCap ?? null,
105
+ defaultFlags: meta.defaults?.flags ?? null,
106
+ }
107
+ : null,
108
+ quota: {
109
+ date: usage.date,
110
+ total: usage.total,
111
+ remaining: usage.remaining,
112
+ dailyLimit: DAILY_QUOTA,
113
+ },
114
+ devices: cachedDevices,
115
+ catalog: {
116
+ scope: cachedDevices.length > 0 ? 'used' : 'all',
117
+ types: catalogTypes,
118
+ },
119
+ hints: cachedDevices.length === 0
120
+ ? ['Run `switchbot devices list` once to populate the device cache for richer bootstrap output.']
121
+ : [],
122
+ };
123
+ printJson(payload);
124
+ });
125
+ }
@@ -8,8 +8,12 @@ import { DryRunSignal } from '../api/client.js';
8
8
  import { getCachedTypeMap } from '../devices/cache.js';
9
9
  const DEFAULT_CONCURRENCY = 5;
10
10
  const COMMAND_TYPES = ['command', 'customize'];
11
- /** Run `task(x)` for every element with at most `concurrency` running at once. */
12
- async function runPool(items, concurrency, task) {
11
+ /**
12
+ * Run `task(x)` for every element with at most `concurrency` running at once.
13
+ * `staggerMs`: when > 0, delay each task start by this fixed interval (replaces
14
+ * the default 20-60ms jitter). Useful for rate-limited endpoints.
15
+ */
16
+ async function runPool(items, concurrency, staggerMs, task) {
13
17
  const results = new Array(items.length);
14
18
  let cursor = 0;
15
19
  const workers = [];
@@ -19,9 +23,10 @@ async function runPool(items, concurrency, task) {
19
23
  while (cursor < items.length) {
20
24
  const idx = cursor++;
21
25
  results[idx] = await task(items[idx]);
22
- // Tiny jitter between starts so we don't hammer the endpoint in a
23
- // perfectly aligned burst. Keeps the default concurrency=5 polite.
24
- await new Promise((r) => setTimeout(r, 20 + Math.random() * 40));
26
+ // Fixed stagger wins over random jitter when set; else keep the
27
+ // default polite spacing so we don't hammer the endpoint.
28
+ const delay = staggerMs > 0 ? staggerMs : 20 + Math.random() * 40;
29
+ await new Promise((r) => setTimeout(r, delay));
25
30
  }
26
31
  })());
27
32
  }
@@ -89,6 +94,9 @@ export function registerBatchCommand(devices) {
89
94
  .option('--filter <expr>', 'Target devices matching a filter, e.g. type=Bot,family=Home', stringArg('--filter'))
90
95
  .option('--ids <csv>', 'Explicit comma-separated list of deviceIds', stringArg('--ids'))
91
96
  .option('--concurrency <n>', 'Max parallel in-flight requests (default 5)', intArg('--concurrency', { min: 1 }), '5')
97
+ .option('--max-concurrent <n>', 'Alias for --concurrency; takes priority when set', intArg('--max-concurrent', { min: 1 }))
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')
92
100
  .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
93
101
  .option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
94
102
  .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
@@ -109,7 +117,16 @@ Supported keys: type, family, room, category (category: physical | ir)
109
117
 
110
118
  Output:
111
119
  Human mode: one status line per device, summary at the end.
112
- --json: {succeeded[], failed[{deviceId,error}], summary:{total,ok,failed,skipped,durationMs}}
120
+ --json: {succeeded[], failed[{deviceId,error}], summary:{total,ok,failed,skipped,durationMs,maxConcurrent,staggerMs}}
121
+ Each step includes startedAt / finishedAt / durationMs / replayed (when cached).
122
+
123
+ Concurrency & pacing:
124
+ --max-concurrent <n> Upper bound on in-flight requests (alias for --concurrency).
125
+ --stagger <ms> Fixed delay between task starts; default 0 uses random 20-60ms jitter.
126
+
127
+ Planning:
128
+ --dry-run --plan Print the plan JSON without executing anything. Useful
129
+ for agents that want to show the user what will run.
113
130
 
114
131
  Safety:
115
132
  Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
@@ -212,10 +229,48 @@ Examples:
212
229
  // keep as string
213
230
  }
214
231
  }
215
- const concurrency = Math.max(1, Number.parseInt(options.concurrency, 10) || DEFAULT_CONCURRENCY);
232
+ const maxConcurrentRaw = options.maxConcurrent ?? options.concurrency;
233
+ const concurrency = Math.max(1, Number.parseInt(maxConcurrentRaw, 10) || DEFAULT_CONCURRENCY);
234
+ const staggerMs = Math.max(0, Number.parseInt(options.stagger, 10) || 0);
216
235
  const dryRun = isDryRun();
236
+ // --dry-run --plan: emit a plan document and return without executing.
237
+ if (dryRun && options.plan) {
238
+ const steps = resolved.ids.map((id) => ({
239
+ deviceId: id,
240
+ command: cmd,
241
+ parameter: parsedParam,
242
+ type: effectiveType,
243
+ idempotencyKey: options.idempotencyKeyPrefix
244
+ ? `${options.idempotencyKeyPrefix}-${id}`
245
+ : undefined,
246
+ }));
247
+ const planDoc = {
248
+ schemaVersion: '1.1',
249
+ dryRun: true,
250
+ plan: {
251
+ command: cmd,
252
+ parameter: parsedParam,
253
+ type: effectiveType,
254
+ maxConcurrent: concurrency,
255
+ staggerMs,
256
+ stepCount: steps.length,
257
+ steps,
258
+ },
259
+ };
260
+ if (isJsonMode()) {
261
+ printJson(planDoc);
262
+ }
263
+ else {
264
+ console.log(`Plan: ${steps.length} step(s), command=${cmd}, maxConcurrent=${concurrency}, staggerMs=${staggerMs}`);
265
+ for (const s of steps)
266
+ console.log(` → ${s.deviceId} ${s.type} ${s.command}`);
267
+ }
268
+ return;
269
+ }
217
270
  const startedAt = Date.now();
218
- const outcomes = await runPool(resolved.ids, concurrency, async (id) => {
271
+ const outcomes = await runPool(resolved.ids, concurrency, staggerMs, async (id) => {
272
+ const stepStart = Date.now();
273
+ const startedIso = new Date(stepStart).toISOString();
219
274
  try {
220
275
  const idempotencyKey = options.idempotencyKeyPrefix
221
276
  ? `${options.idempotencyKeyPrefix}-${id}`
@@ -223,30 +278,67 @@ Examples:
223
278
  const result = await executeCommand(id, cmd, parsedParam, effectiveType, getClient(), {
224
279
  idempotencyKey,
225
280
  });
281
+ const finishedIso = new Date().toISOString();
282
+ const durationMs = Date.now() - stepStart;
283
+ const replayed = typeof result === 'object' && result !== null && result.replayed === true;
226
284
  if (!isJsonMode()) {
227
- console.log(`✓ ${id}: ${cmd}`);
285
+ console.log(`✓ ${id}: ${cmd}${replayed ? ' (replayed)' : ''}`);
228
286
  }
229
- return { ok: true, deviceId: id, result };
287
+ return {
288
+ ok: true,
289
+ deviceId: id,
290
+ result,
291
+ startedAt: startedIso,
292
+ finishedAt: finishedIso,
293
+ durationMs,
294
+ replayed,
295
+ };
230
296
  }
231
297
  catch (err) {
232
298
  // --dry-run uses DryRunSignal to short-circuit; surface that as a
233
299
  // "skipped" outcome, not a failure.
234
300
  if (err instanceof DryRunSignal) {
235
- return { ok: 'dry-run', deviceId: id };
301
+ return {
302
+ ok: 'dry-run',
303
+ deviceId: id,
304
+ startedAt: startedIso,
305
+ finishedAt: new Date().toISOString(),
306
+ durationMs: Date.now() - stepStart,
307
+ };
236
308
  }
237
309
  const errorPayload = buildErrorPayload(err);
238
310
  if (!isJsonMode()) {
239
311
  console.error(`✗ ${id}: ${errorPayload.message}`);
240
312
  }
241
- return { ok: false, deviceId: id, error: errorPayload };
313
+ return {
314
+ ok: false,
315
+ deviceId: id,
316
+ error: errorPayload,
317
+ startedAt: startedIso,
318
+ finishedAt: new Date().toISOString(),
319
+ durationMs: Date.now() - stepStart,
320
+ };
242
321
  }
243
322
  });
244
323
  const succeeded = outcomes.filter((o) => o.ok === true);
245
324
  const failed = outcomes.filter((o) => o.ok === false);
246
325
  const dryRunned = outcomes.filter((o) => o.ok === 'dry-run');
247
326
  const result = {
248
- succeeded: succeeded.map((s) => ({ deviceId: s.deviceId, result: s.result })),
249
- failed: failed.map((f) => ({ deviceId: f.deviceId, error: f.error })),
327
+ succeeded: succeeded.map((s) => ({
328
+ deviceId: s.deviceId,
329
+ result: s.result,
330
+ startedAt: s.startedAt,
331
+ finishedAt: s.finishedAt,
332
+ durationMs: s.durationMs,
333
+ replayed: s.replayed,
334
+ })),
335
+ failed: failed.map((f) => ({
336
+ deviceId: f.deviceId,
337
+ error: f.error,
338
+ startedAt: f.startedAt,
339
+ finishedAt: f.finishedAt,
340
+ durationMs: f.durationMs,
341
+ })),
250
342
  summary: {
251
343
  total: resolved.ids.length,
252
344
  ok: succeeded.length,
@@ -254,6 +346,8 @@ Examples:
254
346
  skipped: dryRunned.length,
255
347
  durationMs: Date.now() - startedAt,
256
348
  schemaVersion: '1.1',
349
+ maxConcurrent: concurrency,
350
+ staggerMs,
257
351
  ...(dryRun ? { dryRun: true } : {}),
258
352
  },
259
353
  };