@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.
@@ -1,7 +1,7 @@
1
1
  import { enumArg, stringArg } from '../utils/arg-parsers.js';
2
- import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, emitJsonError, exitWithError } from '../utils/output.js';
2
+ import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, exitWithError } from '../utils/output.js';
3
3
  import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
4
- import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js';
4
+ import { findCatalogEntry, getEffectiveCatalog, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
5
5
  import { getCachedDevice, loadCache } from '../devices/cache.js';
6
6
  import { loadDeviceMeta } from '../devices/device-meta.js';
7
7
  import { resolveDeviceId, ALL_STRATEGIES } from '../utils/name-resolver.js';
@@ -15,7 +15,7 @@ import { registerExpandCommand } from './expand.js';
15
15
  import { registerDevicesMetaCommand } from './device-meta.js';
16
16
  import { isDryRun } from '../utils/flags.js';
17
17
  import { DryRunSignal } from '../api/client.js';
18
- import { resolveField, listSupportedFieldInputs } from '../schema/field-aliases.js';
18
+ import { resolveField, resolveFieldList, listSupportedFieldInputs } from '../schema/field-aliases.js';
19
19
  const EXPAND_HINTS = {
20
20
  'Air Conditioner': { command: 'setAll', flags: '--temp 26 --mode cool --fan low --power on' },
21
21
  'Curtain': { command: 'setPosition', flags: '--position 50 --mode silent' },
@@ -88,7 +88,7 @@ Examples:
88
88
  `)
89
89
  .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
90
90
  .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
91
- .option('--filter <expr>', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: deviceId/id, deviceName/name, deviceType/type, controlType, roomName/room, category.', stringArg('--filter'))
91
+ .option('--filter <expr>', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: deviceId/id, deviceName/name, deviceType/type, controlType, roomName/room, category, familyName/family, hubDeviceId/hub, roomID/roomid, enableCloudService/cloud, alias.', stringArg('--filter'))
92
92
  .action(async (options) => {
93
93
  try {
94
94
  const body = await fetchDeviceList();
@@ -98,8 +98,11 @@ Examples:
98
98
  const hubLocation = buildHubLocationMap(deviceList);
99
99
  // Parse --filter into a list of clauses. Shared grammar across
100
100
  // `devices list`, `devices batch`, and `events tail` / `mqtt-tail`.
101
- const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room', 'controlType'];
102
- const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'controlType', 'roomName', 'category'];
101
+ const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room', 'controlType',
102
+ 'family', 'hub', 'roomID', 'cloud', 'alias'];
103
+ const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'controlType',
104
+ 'roomName', 'category', 'familyName', 'hubDeviceId', 'roomID',
105
+ 'enableCloudService', 'alias'];
103
106
  const LIST_FILTER_TO_RUNTIME = {
104
107
  deviceId: 'deviceId',
105
108
  deviceName: 'name',
@@ -107,6 +110,11 @@ Examples:
107
110
  controlType: 'controlType',
108
111
  roomName: 'room',
109
112
  category: 'category',
113
+ familyName: 'family',
114
+ hubDeviceId: 'hub',
115
+ roomID: 'roomID',
116
+ enableCloudService: 'cloud',
117
+ alias: 'alias',
110
118
  };
111
119
  let listClauses = null;
112
120
  if (options.filter) {
@@ -137,10 +145,10 @@ Examples:
137
145
  };
138
146
  if (fmt === 'json' && process.argv.includes('--json')) {
139
147
  if (listClauses) {
140
- const filteredDeviceList = deviceList.filter((d) => matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }));
148
+ const filteredDeviceList = deviceList.filter((d) => matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' }));
141
149
  const filteredIrList = infraredRemoteList.filter((d) => {
142
150
  const inherited = hubLocation.get(d.hubDeviceId);
143
- return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' });
151
+ return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' });
144
152
  });
145
153
  printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList });
146
154
  }
@@ -157,7 +165,7 @@ Examples:
157
165
  for (const d of deviceList) {
158
166
  if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
159
167
  continue;
160
- if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }))
168
+ if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' }))
161
169
  continue;
162
170
  rows.push([
163
171
  d.deviceId,
@@ -177,7 +185,7 @@ Examples:
177
185
  if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
178
186
  continue;
179
187
  const inherited = hubLocation.get(d.hubDeviceId);
180
- if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' }))
188
+ if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' }))
181
189
  continue;
182
190
  rows.push([
183
191
  d.deviceId,
@@ -280,7 +288,7 @@ Examples:
280
288
  }
281
289
  }
282
290
  else {
283
- const fields = resolveFields();
291
+ const rawFields = resolveFields();
284
292
  for (const entry of batch) {
285
293
  const { deviceId, ok, error, _fetchedAt: ts, ...status } = entry;
286
294
  console.log(`\n─── ${String(deviceId)} ───`);
@@ -288,9 +296,13 @@ Examples:
288
296
  console.error(` error: ${String(error)}`);
289
297
  }
290
298
  else {
299
+ const statusMap = status;
300
+ const fields = rawFields
301
+ ? resolveFieldList(rawFields, Object.keys(statusMap))
302
+ : undefined;
291
303
  const displayStatus = fields
292
- ? Object.fromEntries(fields.map((f) => [f, status[f] ?? null]))
293
- : status;
304
+ ? Object.fromEntries(fields.map((f) => [f, statusMap[f] ?? null]))
305
+ : statusMap;
294
306
  printKeyValue(displayStatus);
295
307
  console.error(` fetched at ${String(ts)}`);
296
308
  }
@@ -315,7 +327,10 @@ Examples:
315
327
  const statusWithTs = { ...body, _fetchedAt: fetchedAt };
316
328
  const allHeaders = Object.keys(statusWithTs);
317
329
  const allRows = [Object.values(statusWithTs)];
318
- const fields = resolveFields();
330
+ const rawFields = resolveFields();
331
+ const fields = rawFields
332
+ ? resolveFieldList(rawFields, allHeaders)
333
+ : undefined;
319
334
  renderRows(allHeaders, allRows, fmt, fields);
320
335
  return;
321
336
  }
@@ -453,26 +468,22 @@ Examples:
453
468
  const validation = validateCommand(deviceId, cmd, parameter, options.type);
454
469
  if (!validation.ok) {
455
470
  const err = validation.error;
456
- if (isJsonMode()) {
457
- const obj = { code: 2, kind: 'usage', message: err.message };
458
- if (err.hint)
459
- obj.hint = err.hint;
460
- obj.context = { validationKind: err.kind };
461
- emitJsonError(obj);
462
- }
463
- else {
464
- console.error(`Error: ${err.message}`);
465
- if (err.hint)
466
- console.error(err.hint);
467
- if (err.kind === 'unknown-command') {
468
- const cached = getCachedDevice(deviceId);
469
- if (cached) {
470
- console.error(`Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.`);
471
- console.error(`(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`);
472
- }
471
+ let hint = err.hint;
472
+ if (err.kind === 'unknown-command') {
473
+ const cached = getCachedDevice(deviceId);
474
+ if (cached) {
475
+ const extra = `Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.\n` +
476
+ `(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`;
477
+ hint = hint ? `${hint}\n${extra}` : extra;
473
478
  }
474
479
  }
475
- process.exit(2);
480
+ exitWithError({
481
+ code: 2,
482
+ kind: 'usage',
483
+ message: err.message,
484
+ hint,
485
+ context: { validationKind: err.kind },
486
+ });
476
487
  }
477
488
  // Case-only mismatch: emit a warning and continue with the canonical name.
478
489
  if (validation.caseNormalizedFrom && validation.normalized) {
@@ -507,7 +518,7 @@ Examples:
507
518
  hint: reason
508
519
  ? `Re-run with --yes to confirm. Reason: ${reason}`
509
520
  : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
510
- context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
521
+ context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}) },
511
522
  });
512
523
  }
513
524
  // Warn when --yes is given but the command is not destructive (no-op flag)
@@ -645,7 +656,7 @@ Examples:
645
656
  const joinedMatch = findCatalogEntry(joined);
646
657
  if (joinedMatch && !Array.isArray(joinedMatch)) {
647
658
  if (isJsonMode()) {
648
- printJson(joinedMatch);
659
+ printJson(normalizeCatalogForJson(joinedMatch));
649
660
  }
650
661
  else {
651
662
  renderCatalogEntry(joinedMatch);
@@ -664,7 +675,7 @@ Examples:
664
675
  }
665
676
  if (individualMatches.length === typeParts.length) {
666
677
  if (isJsonMode()) {
667
- printJson(individualMatches);
678
+ printJson(individualMatches.map(normalizeCatalogForJson));
668
679
  }
669
680
  else {
670
681
  individualMatches.forEach((entry, i) => {
@@ -824,6 +835,21 @@ Examples:
824
835
  // switchbot devices meta set/get/list/clear
825
836
  registerDevicesMetaCommand(devices);
826
837
  }
838
+ function normalizeCatalogForJson(entry) {
839
+ return {
840
+ ...entry,
841
+ commands: entry.commands.map((c) => {
842
+ const tier = deriveSafetyTier(c, entry);
843
+ const reason = getCommandSafetyReason(c);
844
+ return {
845
+ ...c,
846
+ safetyTier: tier,
847
+ destructive: tier === 'destructive',
848
+ ...(reason ? { safetyReason: reason } : {}),
849
+ };
850
+ }),
851
+ };
852
+ }
827
853
  function renderCatalogEntry(entry) {
828
854
  console.log(`Type: ${entry.type}`);
829
855
  console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`);
@@ -841,10 +867,11 @@ function renderCatalogEntry(entry) {
841
867
  console.log('\nCommands:');
842
868
  const hasExamples = entry.commands.some((c) => c.exampleParams && c.exampleParams.length > 0);
843
869
  const rows = entry.commands.map((c) => {
870
+ const tier = deriveSafetyTier(c, entry);
844
871
  const flags = [];
845
872
  if (c.commandType === 'customize')
846
873
  flags.push('customize');
847
- if (c.destructive)
874
+ if (tier === 'destructive')
848
875
  flags.push('!destructive');
849
876
  const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command;
850
877
  const base = [label, c.parameter, c.description];
@@ -854,7 +881,7 @@ function renderCatalogEntry(entry) {
854
881
  ? ['command', 'parameter', 'description', 'example']
855
882
  : ['command', 'parameter', 'description'];
856
883
  printTable(tableHeaders, rows);
857
- const hasDestructive = entry.commands.some((c) => c.destructive);
884
+ const hasDestructive = entry.commands.some((c) => deriveSafetyTier(c, entry) === 'destructive');
858
885
  if (hasDestructive) {
859
886
  console.log('\n[!destructive] commands have hard-to-reverse real-world effects — confirm before issuing.');
860
887
  }
@@ -1,10 +1,14 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { printJson, isJsonMode } from '../utils/output.js';
4
+ import { printJson, isJsonMode, exitWithError } from '../utils/output.js';
5
5
  import { getEffectiveCatalog } from '../devices/catalog.js';
6
6
  import { configFilePath, listProfiles, readProfileMeta } from '../config.js';
7
- import { describeCache } from '../devices/cache.js';
7
+ import { describeCache, resetListCache } from '../devices/cache.js';
8
+ import { DAILY_QUOTA, todayUsage } from '../utils/quota.js';
9
+ import { AGENT_BOOTSTRAP_SCHEMA_VERSION } from './agent-bootstrap.js';
10
+ import { CATALOG_SCHEMA_VERSION } from '../devices/catalog.js';
11
+ import { createSwitchBotMcpServer, listRegisteredTools } from './mcp.js';
8
12
  export const DOCTOR_SCHEMA_VERSION = 1;
9
13
  async function checkCredentials() {
10
14
  const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
@@ -160,15 +164,148 @@ function checkCache() {
160
164
  function checkQuotaFile() {
161
165
  const p = path.join(os.homedir(), '.switchbot', 'quota.json');
162
166
  if (!fs.existsSync(p)) {
163
- return { name: 'quota', status: 'ok', detail: 'no quota file yet (will be created on first call)' };
167
+ return {
168
+ name: 'quota',
169
+ status: 'ok',
170
+ detail: {
171
+ path: p,
172
+ percentUsed: 0,
173
+ remaining: DAILY_QUOTA,
174
+ message: 'no quota file yet (will be created on first call)',
175
+ },
176
+ };
164
177
  }
165
178
  try {
166
179
  const raw = fs.readFileSync(p, 'utf-8');
167
180
  JSON.parse(raw);
168
- return { name: 'quota', status: 'ok', detail: p };
169
181
  }
170
182
  catch {
171
- return { name: 'quota', status: 'warn', detail: `${p} unreadable/malformed — run 'switchbot quota reset'` };
183
+ return {
184
+ name: 'quota',
185
+ status: 'warn',
186
+ detail: { path: p, message: `unreadable/malformed — run 'switchbot quota reset'` },
187
+ };
188
+ }
189
+ // P9: surface headroom so agents can decide when to slow down or pause.
190
+ // Quota resets at local midnight (the quota counter buckets by local
191
+ // date), so project the next reset to the next 00:00:00 local.
192
+ const usage = todayUsage();
193
+ const percentUsed = Math.round((usage.total / DAILY_QUOTA) * 100);
194
+ const now = new Date();
195
+ const reset = new Date(now);
196
+ reset.setHours(24, 0, 0, 0); // next local midnight
197
+ const status = percentUsed > 80 ? 'warn' : 'ok';
198
+ const recommendation = percentUsed > 90
199
+ ? 'over 90% used — consider --no-quota for read-only triage or rescheduling work after the reset'
200
+ : percentUsed > 80
201
+ ? 'over 80% used — avoid bulk operations until the daily reset'
202
+ : 'headroom available';
203
+ return {
204
+ name: 'quota',
205
+ status,
206
+ detail: {
207
+ path: p,
208
+ percentUsed,
209
+ remaining: usage.remaining,
210
+ total: usage.total,
211
+ dailyCap: DAILY_QUOTA,
212
+ projectedResetTime: reset.toISOString(),
213
+ recommendation,
214
+ },
215
+ };
216
+ }
217
+ function checkCatalogSchema() {
218
+ // P9: sentinel against silent drift between the catalog shape and the
219
+ // agent-bootstrap payload. Both constants are exported from their
220
+ // respective modules; if a future refactor changes one without the
221
+ // other, this check fails so consumers (agents) learn before the
222
+ // mismatch corrupts their mental model.
223
+ const match = CATALOG_SCHEMA_VERSION === AGENT_BOOTSTRAP_SCHEMA_VERSION;
224
+ return {
225
+ name: 'catalog-schema',
226
+ status: match ? 'ok' : 'fail',
227
+ detail: {
228
+ catalogSchemaVersion: CATALOG_SCHEMA_VERSION,
229
+ bootstrapExpectsVersion: AGENT_BOOTSTRAP_SCHEMA_VERSION,
230
+ match,
231
+ message: match
232
+ ? 'catalog and agent-bootstrap schemaVersion aligned'
233
+ : 'catalog and agent-bootstrap schemaVersion have drifted — bump in lockstep',
234
+ },
235
+ };
236
+ }
237
+ function checkAudit() {
238
+ // P9: surface recent command failures so agents / ops can spot problems
239
+ // before they page. When --audit-log was never enabled, the file won't
240
+ // exist — report that cleanly rather than as an error.
241
+ const p = path.join(os.homedir(), '.switchbot', 'audit.log');
242
+ if (!fs.existsSync(p)) {
243
+ return {
244
+ name: 'audit',
245
+ status: 'ok',
246
+ detail: {
247
+ path: p,
248
+ enabled: false,
249
+ message: 'audit log not present (enable with --audit-log)',
250
+ },
251
+ };
252
+ }
253
+ try {
254
+ const raw = fs.readFileSync(p, 'utf-8');
255
+ const since = Date.now() - 24 * 60 * 60 * 1000;
256
+ const recent = [];
257
+ let total = 0;
258
+ for (const line of raw.split('\n')) {
259
+ const trimmed = line.trim();
260
+ if (!trimmed)
261
+ continue;
262
+ let rec;
263
+ try {
264
+ rec = JSON.parse(trimmed);
265
+ }
266
+ catch {
267
+ continue;
268
+ }
269
+ if (rec.result !== 'error')
270
+ continue;
271
+ total += 1;
272
+ const ts = rec.t ? Date.parse(rec.t) : NaN;
273
+ if (Number.isFinite(ts) && ts >= since) {
274
+ recent.push({
275
+ t: rec.t,
276
+ command: rec.command ?? '?',
277
+ deviceId: rec.deviceId,
278
+ error: rec.error ?? 'unknown',
279
+ });
280
+ }
281
+ }
282
+ // Cap the report to the 10 most recent so the doctor payload stays
283
+ // bounded even on a log with thousands of errors.
284
+ recent.sort((a, b) => (a.t < b.t ? 1 : -1));
285
+ const clipped = recent.slice(0, 10);
286
+ const status = recent.length > 0 ? 'warn' : 'ok';
287
+ return {
288
+ name: 'audit',
289
+ status,
290
+ detail: {
291
+ path: p,
292
+ enabled: true,
293
+ totalErrors: total,
294
+ errorsLast24h: recent.length,
295
+ recent: clipped,
296
+ },
297
+ };
298
+ }
299
+ catch (err) {
300
+ return {
301
+ name: 'audit',
302
+ status: 'warn',
303
+ detail: {
304
+ path: p,
305
+ enabled: true,
306
+ message: `could not read audit log: ${err instanceof Error ? err.message : String(err)}`,
307
+ },
308
+ };
172
309
  }
173
310
  }
174
311
  function checkNodeVersion() {
@@ -210,10 +347,160 @@ function checkMqtt() {
210
347
  detail: "unavailable — configure credentials first (see credentials check above)",
211
348
  };
212
349
  }
350
+ async function checkMqttProbe() {
351
+ // P10: live-probe the MQTT broker. Only runs when --probe is passed.
352
+ // Does not subscribe — just connects + disconnects to verify the
353
+ // credential + TLS handshake works end-to-end. Hard 5s timeout so
354
+ // a misbehaving broker never wedges the doctor command.
355
+ const { fetchMqttCredential } = await import('../mqtt/credential.js');
356
+ const { SwitchBotMqttClient } = await import('../mqtt/client.js');
357
+ const token = process.env.SWITCHBOT_TOKEN;
358
+ const secret = process.env.SWITCHBOT_SECRET;
359
+ let creds = null;
360
+ if (token && secret) {
361
+ creds = { token, secret };
362
+ }
363
+ else {
364
+ const file = configFilePath();
365
+ if (fs.existsSync(file)) {
366
+ try {
367
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
368
+ if (cfg.token && cfg.secret) {
369
+ creds = { token: cfg.token, secret: cfg.secret };
370
+ }
371
+ }
372
+ catch { /* fall through */ }
373
+ }
374
+ }
375
+ if (!creds) {
376
+ return {
377
+ name: 'mqtt',
378
+ status: 'warn',
379
+ detail: { probe: 'skipped', reason: 'no credentials configured' },
380
+ };
381
+ }
382
+ const deadline = new Promise((_, reject) => setTimeout(() => reject(new Error('probe timeout after 5000ms')), 5000));
383
+ try {
384
+ const cred = await Promise.race([fetchMqttCredential(creds.token, creds.secret), deadline]);
385
+ const client = new SwitchBotMqttClient(cred);
386
+ await Promise.race([client.connect(), deadline]);
387
+ await client.disconnect();
388
+ return {
389
+ name: 'mqtt',
390
+ status: 'ok',
391
+ detail: { probe: 'connected', brokerUrl: cred.brokerUrl, region: cred.region },
392
+ };
393
+ }
394
+ catch (err) {
395
+ return {
396
+ name: 'mqtt',
397
+ status: 'warn',
398
+ detail: { probe: 'failed', reason: err instanceof Error ? err.message : String(err) },
399
+ };
400
+ }
401
+ }
402
+ function checkMcp() {
403
+ // P10: dry-run instantiation of the MCP server to catch tool-registration
404
+ // regressions. No network I/O, no token needed. If createSwitchBotMcpServer
405
+ // throws (e.g. duplicate tool name, schema build error) the check fails.
406
+ try {
407
+ const server = createSwitchBotMcpServer();
408
+ const tools = listRegisteredTools(server);
409
+ return {
410
+ name: 'mcp',
411
+ status: 'ok',
412
+ detail: {
413
+ serverInstantiated: true,
414
+ toolCount: tools.length,
415
+ tools,
416
+ transportsAvailable: ['stdio', 'http'],
417
+ message: `${tools.length} tools registered; no network probe`,
418
+ },
419
+ };
420
+ }
421
+ catch (err) {
422
+ return {
423
+ name: 'mcp',
424
+ status: 'fail',
425
+ detail: {
426
+ serverInstantiated: false,
427
+ error: err instanceof Error ? err.message : String(err),
428
+ },
429
+ };
430
+ }
431
+ }
432
+ const CHECK_REGISTRY = [
433
+ { name: 'node', description: 'Node.js version compatibility', run: () => checkNodeVersion() },
434
+ { name: 'credentials', description: 'credentials file present and parseable', run: () => checkCredentials() },
435
+ { name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() },
436
+ { name: 'catalog', description: 'catalog loads', run: () => checkCatalog() },
437
+ { name: 'catalog-schema', description: 'catalog vs agent-bootstrap version aligned', run: () => checkCatalogSchema() },
438
+ { name: 'cache', description: 'device cache state', run: () => checkCache() },
439
+ { name: 'quota', description: 'API quota headroom', run: () => checkQuotaFile() },
440
+ { name: 'clock', description: 'system clock skew', run: () => checkClockSkew() },
441
+ {
442
+ name: 'mqtt',
443
+ description: 'MQTT credentials (+ --probe for live broker handshake)',
444
+ run: ({ probe }) => (probe ? checkMqttProbe() : checkMqtt()),
445
+ },
446
+ { name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() },
447
+ { name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() },
448
+ ];
449
+ function applyFixes(checks, writeOk) {
450
+ const results = [];
451
+ for (const c of checks) {
452
+ if (c.name === 'cache' && c.status !== 'ok') {
453
+ if (writeOk) {
454
+ try {
455
+ resetListCache();
456
+ results.push({ check: 'cache', action: 'cache-cleared', applied: true });
457
+ }
458
+ catch (err) {
459
+ results.push({
460
+ check: 'cache',
461
+ action: 'cache-clear',
462
+ applied: false,
463
+ message: err instanceof Error ? err.message : String(err),
464
+ });
465
+ }
466
+ }
467
+ else {
468
+ results.push({
469
+ check: 'cache',
470
+ action: 'cache-clear',
471
+ applied: false,
472
+ message: 'pass --yes to apply',
473
+ });
474
+ }
475
+ }
476
+ else if (c.name === 'catalog-schema' && c.status !== 'ok') {
477
+ results.push({
478
+ check: 'catalog-schema',
479
+ action: 'manual',
480
+ applied: false,
481
+ message: "drift detected — run 'switchbot capabilities --reload' to refresh overlay",
482
+ });
483
+ }
484
+ else if (c.name === 'credentials' && c.status === 'fail') {
485
+ results.push({
486
+ check: 'credentials',
487
+ action: 'manual',
488
+ applied: false,
489
+ message: "run 'switchbot config set-token' to configure credentials",
490
+ });
491
+ }
492
+ }
493
+ return results;
494
+ }
213
495
  export function registerDoctorCommand(program) {
214
496
  program
215
497
  .command('doctor')
216
- .description('Self-check: credentials, catalog, cache, quota, profiles, Node version')
498
+ .description('Self-check the SwitchBot CLI setup: credentials, catalog, cache, quota, MQTT, MCP')
499
+ .option('--section <names>', 'Comma-separated list of checks to run (see --list for names)')
500
+ .option('--list', 'Print the registered check names and exit 0 without running any check')
501
+ .option('--fix', 'Apply safe, reversible remediations for failing checks (e.g. clear stale cache)')
502
+ .option('--yes', 'Required together with --fix to confirm write actions')
503
+ .option('--probe', 'Perform live-probe variant of checks that support it (mqtt)')
217
504
  .addHelpText('after', `
218
505
  Runs a battery of local sanity checks and exits with code 0 only when every
219
506
  check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1.
@@ -221,18 +508,52 @@ check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1.
221
508
  Examples:
222
509
  $ switchbot doctor
223
510
  $ switchbot --json doctor | jq '.checks[] | select(.status != "ok")'
511
+ $ switchbot doctor --list
512
+ $ switchbot doctor --section credentials,mcp --json
513
+ $ switchbot doctor --probe --json
514
+ $ switchbot doctor --fix --yes --json
224
515
  `)
225
- .action(async () => {
226
- const checks = [
227
- checkNodeVersion(),
228
- await checkCredentials(),
229
- checkProfiles(),
230
- checkCatalog(),
231
- checkCache(),
232
- checkQuotaFile(),
233
- await checkClockSkew(),
234
- checkMqtt(),
235
- ];
516
+ .action(async (opts) => {
517
+ // --list: print the registry and exit 0.
518
+ if (opts.list) {
519
+ if (isJsonMode()) {
520
+ printJson({
521
+ checks: CHECK_REGISTRY.map((c) => ({ name: c.name, description: c.description })),
522
+ });
523
+ }
524
+ else {
525
+ console.log('Available checks:');
526
+ for (const c of CHECK_REGISTRY) {
527
+ console.log(` ${c.name.padEnd(16)} ${c.description}`);
528
+ }
529
+ }
530
+ return;
531
+ }
532
+ // --section: run only the named subset, dedup and validate.
533
+ let selected = CHECK_REGISTRY;
534
+ if (opts.section) {
535
+ const raw = opts.section.split(',').map((s) => s.trim()).filter(Boolean);
536
+ const names = Array.from(new Set(raw));
537
+ const known = new Set(CHECK_REGISTRY.map((c) => c.name));
538
+ const unknown = names.filter((n) => !known.has(n));
539
+ if (unknown.length > 0) {
540
+ exitWithError({
541
+ code: 2,
542
+ kind: 'usage',
543
+ message: `Unknown check name(s): ${unknown.join(', ')}. Valid: ${CHECK_REGISTRY.map((c) => c.name).join(', ')}`,
544
+ });
545
+ return;
546
+ }
547
+ const order = new Map(CHECK_REGISTRY.map((c, i) => [c.name, i]));
548
+ selected = names
549
+ .map((n) => CHECK_REGISTRY.find((c) => c.name === n))
550
+ .sort((a, b) => (order.get(a.name) - order.get(b.name)));
551
+ }
552
+ const runOpts = { probe: Boolean(opts.probe) };
553
+ const checks = [];
554
+ for (const def of selected) {
555
+ checks.push(await def.run(runOpts));
556
+ }
236
557
  const summary = {
237
558
  ok: checks.filter((c) => c.status === 'ok').length,
238
559
  warn: checks.filter((c) => c.status === 'warn').length,
@@ -240,20 +561,27 @@ Examples:
240
561
  };
241
562
  const overallFail = summary.fail > 0;
242
563
  const overall = overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok';
564
+ let fixes;
565
+ if (opts.fix) {
566
+ fixes = applyFixes(checks, Boolean(opts.yes));
567
+ }
243
568
  if (isJsonMode()) {
244
569
  // Stable contract (locked as doctor.schemaVersion=1):
245
570
  // { ok: boolean, overall: 'ok'|'warn'|'fail', generatedAt, schemaVersion,
246
571
  // summary: { ok, warn, fail }, checks: [{ name, status, detail }] }
247
572
  // `ok` is an alias of (overall === 'ok') — agents prefer the boolean,
248
573
  // humans prefer the string; both are provided.
249
- printJson({
574
+ const payload = {
250
575
  ok: overall === 'ok',
251
576
  overall,
252
577
  generatedAt: new Date().toISOString(),
253
578
  schemaVersion: DOCTOR_SCHEMA_VERSION,
254
579
  summary,
255
580
  checks,
256
- });
581
+ };
582
+ if (fixes !== undefined)
583
+ payload.fixes = fixes;
584
+ printJson(payload);
257
585
  }
258
586
  else {
259
587
  for (const c of checks) {
@@ -263,6 +591,14 @@ Examples:
263
591
  }
264
592
  console.log('');
265
593
  console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`);
594
+ if (fixes && fixes.length > 0) {
595
+ console.log('');
596
+ console.log('Fixes:');
597
+ for (const f of fixes) {
598
+ const marker = f.applied ? '✓' : '-';
599
+ console.log(` ${marker} ${f.check}: ${f.action}${f.message ? ' — ' + f.message : ''}`);
600
+ }
601
+ }
266
602
  }
267
603
  if (overallFail)
268
604
  process.exit(1);