@switchbot/openapi-cli 3.0.0 → 3.1.1

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.
@@ -28,6 +28,16 @@ const AGENT_GUIDE = {
28
28
  action: 'Mutates device or cloud state but is reversible and routine (turnOn, setColor).',
29
29
  destructive: 'Hard to reverse / physical-world side effects (unlock, garage open, delete key). Requires explicit user confirmation.',
30
30
  },
31
+ riskLevels: {
32
+ low: 'Read-only or non-mutating. Safe to call autonomously.',
33
+ medium: 'Mutates state (action tier). Prefer `plan` workflow. Reversible.',
34
+ high: 'Destructive / hard-to-reverse. Must go through review-before-execute. Direct --yes execution is reserved for explicit dev profiles.',
35
+ },
36
+ recommendedModes: {
37
+ direct: 'May be called directly without a plan step.',
38
+ plan: 'Prefer batching in a plan for traceability and dry-run support.',
39
+ 'review-before-execute': 'Must be reviewed/approved before execution. Use `plan save`, `plan review`, `plan approve`, then `plan execute`.',
40
+ },
31
41
  verifiability: {
32
42
  local: 'Result is fully verifiable from the CLI return value itself.',
33
43
  deviceConfirmed: 'Device returns an ack with an observable state field.',
@@ -35,55 +45,128 @@ const AGENT_GUIDE = {
35
45
  none: 'No feedback — e.g. IR transmission. Pair with an external sensor to confirm.',
36
46
  },
37
47
  };
48
+ function deriveRiskMeta(meta) {
49
+ const riskLevel = meta.agentSafetyTier === 'destructive' ? 'high'
50
+ : meta.agentSafetyTier === 'action' ? 'medium' : 'low';
51
+ return {
52
+ riskLevel,
53
+ requiresConfirmation: meta.agentSafetyTier === 'destructive',
54
+ supportsDryRun: meta.mutating,
55
+ idempotencyHint: meta.idempotencySupported ? 'safe' : meta.mutating ? 'non-idempotent' : 'safe',
56
+ recommendedMode: meta.agentSafetyTier === 'destructive' ? 'review-before-execute'
57
+ : meta.agentSafetyTier === 'action' ? 'plan' : 'direct',
58
+ };
59
+ }
60
+ function meta(mutating, consumesQuota, idempotencySupported, agentSafetyTier, verifiability, typicalLatencyMs) {
61
+ return { mutating, consumesQuota, idempotencySupported, agentSafetyTier, verifiability, typicalLatencyMs };
62
+ }
63
+ const READ_LOCAL = meta(false, false, false, 'read', 'local', 20);
64
+ const READ_REMOTE = meta(false, true, false, 'read', 'local', 500);
65
+ const ACTION_LOCAL = meta(true, false, false, 'action', 'local', 20);
66
+ const ACTION_REMOTE = meta(true, true, false, 'action', 'deviceDependent', 900);
67
+ const ACTION_REMOTE_IDEMPOTENT = meta(true, true, true, 'action', 'deviceDependent', 900);
68
+ const DESTRUCTIVE_LOCAL = meta(true, false, false, 'destructive', 'local', 20);
69
+ const DESTRUCTIVE_REMOTE = meta(true, true, false, 'destructive', 'deviceDependent', 1200);
70
+ const READ_NONE = meta(false, false, false, 'read', 'none', 50);
38
71
  const COMMAND_META = {
39
- // devices: reads
40
- 'devices list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 },
41
- 'devices status': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
42
- 'devices describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 },
43
- 'devices types': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
44
- 'devices commands': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
45
- 'devices watch': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
46
- // devices meta (local metadata — no quota, no API call)
47
- 'devices meta set': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
48
- 'devices meta get': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
49
- 'devices meta list': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
50
- 'devices meta clear': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
51
- // devices: actions
52
- 'devices command': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 800 },
53
- 'devices batch': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1200 },
54
- // scenes
55
- 'scenes list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
56
- 'scenes execute': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1500 },
57
- 'scenes describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
58
- // webhook
59
- 'webhook setup': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 500 },
60
- 'webhook query': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
61
- 'webhook delete': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 500 },
62
- // quota
63
- 'quota status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
64
- 'quota show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
65
- 'quota reset': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 10 },
66
- // doctor / schema / capabilities / catalog / config / cache / events / history / plan
67
- 'doctor': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 900 },
68
- 'schema export': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
69
- 'capabilities': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 },
70
- 'catalog': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 },
71
- 'config set-token': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 5 },
72
- 'config show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
73
- 'config list-profiles': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
74
- 'cache status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
75
- 'cache clear': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
76
- 'events mqtt-tail': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
77
- 'history show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
78
- 'history replay': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1000 },
79
- 'history range': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 50 },
80
- 'history stats': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
81
- 'history aggregate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 80 },
82
- 'plan run': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 2000 },
83
- 'plan validate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
84
- 'plan schema': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
85
- 'completion': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
86
- 'mcp serve': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
72
+ 'agent-bootstrap': READ_LOCAL,
73
+ 'auth keychain describe': READ_LOCAL,
74
+ 'auth keychain get': READ_LOCAL,
75
+ 'auth keychain set': DESTRUCTIVE_LOCAL,
76
+ 'auth keychain delete': DESTRUCTIVE_LOCAL,
77
+ 'auth keychain migrate': DESTRUCTIVE_LOCAL,
78
+ 'cache show': READ_LOCAL,
79
+ 'cache clear': ACTION_LOCAL,
80
+ 'capabilities': READ_LOCAL,
81
+ 'catalog path': READ_LOCAL,
82
+ 'catalog show': READ_LOCAL,
83
+ 'catalog search': READ_LOCAL,
84
+ 'catalog diff': READ_LOCAL,
85
+ 'catalog refresh': ACTION_LOCAL,
86
+ 'completion': READ_LOCAL,
87
+ 'config set-token': DESTRUCTIVE_LOCAL,
88
+ 'config show': READ_LOCAL,
89
+ 'config list-profiles': READ_LOCAL,
90
+ 'config agent-profile': ACTION_LOCAL,
91
+ 'daemon start': ACTION_LOCAL,
92
+ 'daemon stop': ACTION_LOCAL,
93
+ 'daemon status': READ_LOCAL,
94
+ 'daemon reload': ACTION_LOCAL,
95
+ 'devices list': READ_REMOTE,
96
+ 'devices status': READ_REMOTE,
97
+ 'devices command': ACTION_REMOTE_IDEMPOTENT,
98
+ 'devices types': READ_LOCAL,
99
+ 'devices commands': READ_LOCAL,
100
+ 'devices describe': READ_REMOTE,
101
+ 'devices batch': ACTION_REMOTE_IDEMPOTENT,
102
+ 'devices watch': READ_REMOTE,
103
+ 'devices explain': READ_LOCAL,
104
+ 'devices expand': READ_LOCAL,
105
+ 'devices meta set': ACTION_LOCAL,
106
+ 'devices meta get': READ_LOCAL,
107
+ 'devices meta list': READ_LOCAL,
108
+ 'devices meta clear': ACTION_LOCAL,
109
+ 'doctor': READ_LOCAL,
110
+ 'events tail': READ_NONE,
111
+ 'events mqtt-tail': READ_REMOTE,
112
+ 'health check': READ_LOCAL,
113
+ 'health serve': READ_LOCAL,
114
+ 'history show': READ_LOCAL,
115
+ 'history replay': ACTION_REMOTE_IDEMPOTENT,
116
+ 'history range': READ_LOCAL,
117
+ 'history stats': READ_LOCAL,
118
+ 'history verify': READ_LOCAL,
119
+ 'history aggregate': READ_LOCAL,
120
+ 'install': ACTION_LOCAL,
121
+ 'mcp serve': READ_LOCAL,
122
+ 'plan schema': READ_LOCAL,
123
+ 'plan validate': READ_LOCAL,
124
+ 'plan suggest': READ_LOCAL,
125
+ 'plan run': ACTION_REMOTE_IDEMPOTENT,
126
+ 'plan save': ACTION_LOCAL,
127
+ 'plan list': READ_LOCAL,
128
+ 'plan review': READ_LOCAL,
129
+ 'plan approve': DESTRUCTIVE_LOCAL,
130
+ 'plan execute': DESTRUCTIVE_REMOTE,
131
+ 'policy validate': READ_LOCAL,
132
+ 'policy new': ACTION_LOCAL,
133
+ 'policy migrate': ACTION_LOCAL,
134
+ 'policy diff': READ_LOCAL,
135
+ 'policy add-rule': ACTION_LOCAL,
136
+ 'policy backup': READ_LOCAL,
137
+ 'policy restore': DESTRUCTIVE_LOCAL,
138
+ 'quota status': READ_LOCAL,
139
+ 'quota reset': ACTION_LOCAL,
140
+ 'rules suggest': READ_LOCAL,
141
+ 'rules lint': READ_LOCAL,
142
+ 'rules list': READ_LOCAL,
143
+ 'rules run': ACTION_REMOTE,
144
+ 'rules reload': ACTION_LOCAL,
145
+ 'rules tail': READ_LOCAL,
146
+ 'rules replay': READ_LOCAL,
147
+ 'rules webhook-rotate-token': DESTRUCTIVE_LOCAL,
148
+ 'rules webhook-show-token': DESTRUCTIVE_LOCAL,
149
+ 'rules conflicts': READ_LOCAL,
150
+ 'rules doctor': READ_LOCAL,
151
+ 'rules summary': READ_LOCAL,
152
+ 'rules last-fired': READ_LOCAL,
153
+ 'schema export': READ_LOCAL,
154
+ 'scenes list': READ_REMOTE,
155
+ 'scenes execute': ACTION_REMOTE,
156
+ 'scenes describe': READ_REMOTE,
157
+ 'scenes validate': READ_REMOTE,
158
+ 'scenes simulate': READ_REMOTE,
159
+ 'scenes explain': READ_REMOTE,
160
+ 'status-sync run': ACTION_REMOTE,
161
+ 'status-sync start': ACTION_LOCAL,
162
+ 'status-sync stop': ACTION_LOCAL,
163
+ 'status-sync status': READ_LOCAL,
164
+ 'uninstall': ACTION_LOCAL,
165
+ 'upgrade-check': READ_REMOTE,
166
+ 'webhook setup': ACTION_REMOTE,
167
+ 'webhook query': READ_REMOTE,
168
+ 'webhook update': ACTION_REMOTE,
169
+ 'webhook delete': DESTRUCTIVE_REMOTE,
87
170
  };
88
171
  function metaFor(command) {
89
172
  return COMMAND_META[command] ?? null;
@@ -110,27 +193,30 @@ const IDEMPOTENCY_CONTRACT = {
110
193
  scope: 'Process-local. Replay + conflict apply within a single long-lived process (MCP session, devices batch, plan run, history replay). Independent CLI invocations do NOT share cache — each fresh `node` process starts empty.',
111
194
  mcp: 'MCP send_command accepts the same idempotencyKey field with identical semantics.',
112
195
  };
196
+ function enumerateLeafNames(program, prefix = '') {
197
+ const out = [];
198
+ for (const cmd of program.commands) {
199
+ const full = prefix ? `${prefix} ${cmd.name()}` : cmd.name();
200
+ if (cmd.commands.length === 0)
201
+ out.push(full);
202
+ else
203
+ out.push(...enumerateLeafNames(cmd, full));
204
+ }
205
+ return out;
206
+ }
207
+ function validateCommandMetaCoverage(program) {
208
+ const leaves = enumerateLeafNames(program);
209
+ return leaves.filter((leaf) => !COMMAND_META[leaf]).sort().map((leaf) => `missing:${leaf}`);
210
+ }
113
211
  function enumerateLeaves(program, prefix = '') {
114
212
  const out = [];
115
213
  for (const cmd of program.commands) {
116
214
  const full = prefix ? `${prefix} ${cmd.name()}` : cmd.name();
117
215
  if (cmd.commands.length === 0) {
118
216
  const meta = metaFor(full);
119
- if (meta) {
120
- out.push({ name: full, ...meta });
121
- }
122
- else {
123
- // Unknown leaf → default to read-safe with a warning flag so agents notice.
124
- out.push({
125
- name: full,
126
- mutating: false,
127
- consumesQuota: false,
128
- idempotencySupported: false,
129
- agentSafetyTier: 'read',
130
- verifiability: 'local',
131
- typicalLatencyMs: 50,
132
- });
133
- }
217
+ if (!meta)
218
+ throw new Error(`capabilities metadata missing for leaf command "${full}"`);
219
+ out.push({ name: full, ...meta, ...deriveRiskMeta(meta) });
134
220
  }
135
221
  else {
136
222
  out.push(...enumerateLeaves(cmd, full));
@@ -157,6 +243,10 @@ export function registerCapabilitiesCommand(program) {
157
243
  .option('--surface <s>', 'Restrict surfaces block to one of: cli, mcp, plan, mqtt, all (default: all)', enumArg('--surface', SURFACES))
158
244
  .option('--project <csv>', 'Project top-level fields (e.g. --project identity,commands,agentGuide)', stringArg('--project'))
159
245
  .action((opts) => {
246
+ const coverageIssues = validateCommandMetaCoverage(program);
247
+ if (coverageIssues.length > 0) {
248
+ throw new Error(`capabilities metadata coverage error: ${coverageIssues.join(', ')}`);
249
+ }
160
250
  const compact = Boolean(opts.minimal || opts.compact);
161
251
  const catalog = getEffectiveCatalog();
162
252
  const leaves = enumerateLeaves(program);
@@ -246,8 +336,8 @@ export function registerCapabilitiesCommand(program) {
246
336
  // Flat command → meta map keyed by full command path. Published in
247
337
  // addition to the tree (where every leaf `subcommands[*]` already
248
338
  // carries the same fields via spread) so agents can do O(1) lookup
249
- // without walking the tree.
250
- commandMeta: COMMAND_META,
339
+ // without walking the tree. Includes derived risk metadata fields.
340
+ commandMeta: Object.fromEntries(Object.entries(COMMAND_META).map(([k, v]) => [k, { ...v, ...deriveRiskMeta(v) }])),
251
341
  ...(globalFlags ? { globalFlags } : {}),
252
342
  catalog: {
253
343
  typeCount: catalog.length,
@@ -1,4 +1,6 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
2
4
  import readline from 'node:readline';
3
5
  import { execFileSync } from 'node:child_process';
4
6
  import { stringArg } from '../utils/arg-parsers.js';
@@ -251,6 +253,24 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
251
253
  }
252
254
  else {
253
255
  console.log(chalk.green('✓ Credentials saved'));
256
+ // Keychain-first hint: proactively suggest storing in OS keychain when
257
+ // the user is on a supported platform and saved to file (default path).
258
+ try {
259
+ const { selectCredentialStore } = await import('../credentials/keychain.js');
260
+ const store = await selectCredentialStore();
261
+ if (store.describe().backend === 'file') {
262
+ const platform = process.platform;
263
+ if (platform === 'darwin' || platform === 'win32') {
264
+ console.error(chalk.grey('Tip: Your OS supports a native keychain. Run `switchbot auth keychain store` to move credentials off the plain config file for better security.'));
265
+ }
266
+ else if (platform === 'linux') {
267
+ console.error(chalk.grey('Tip: If you have GNOME Keyring (secret-tool) installed, run `switchbot auth keychain store` to store credentials more securely.'));
268
+ }
269
+ }
270
+ }
271
+ catch {
272
+ // Keychain probe failed — silently skip the tip
273
+ }
254
274
  }
255
275
  });
256
276
  config
@@ -294,4 +314,63 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
294
314
  console.log(bits.join(' '));
295
315
  }
296
316
  });
317
+ // switchbot config agent-profile [--write]
318
+ config
319
+ .command('agent-profile')
320
+ .description('Emit (or write) an agent-safe profile template with conservative rate limits and audit logging.')
321
+ .option('--write', 'Write the template to ~/.switchbot/profiles/agent.json (requires --profile agent config set-token to add credentials).')
322
+ .option('--force', 'Overwrite an existing agent.json when used with --write.')
323
+ .addHelpText('after', `
324
+ Outputs a starter profile.json suitable for AI agent / MCP integration:
325
+ - dailyCap: 100 (conservative; prevents runaway automation)
326
+ - label: "agent"
327
+ - description: "AI agent profile — conservative limits + audit enabled"
328
+ - defaults: { auditLog: true } (enables audit logging by default)
329
+
330
+ After writing, add credentials:
331
+ $ switchbot --profile agent config set-token <token> <secret>
332
+
333
+ Then use the profile:
334
+ $ switchbot --profile agent devices list
335
+ `)
336
+ .action((opts) => {
337
+ const template = {
338
+ label: 'agent',
339
+ description: 'AI agent profile — conservative limits + audit enabled',
340
+ limits: {
341
+ dailyCap: 100,
342
+ },
343
+ defaults: {
344
+ auditLog: true,
345
+ },
346
+ };
347
+ if (opts.write) {
348
+ const dir = path.join(os.homedir(), '.switchbot', 'profiles');
349
+ const dest = path.join(dir, 'agent.json');
350
+ if (!opts.force && fs.existsSync(dest)) {
351
+ exitWithError({ code: 2, kind: 'usage', message: `Agent profile already exists: ${dest}. Use --force to overwrite.` });
352
+ }
353
+ if (!fs.existsSync(dir)) {
354
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
355
+ }
356
+ fs.writeFileSync(dest, JSON.stringify(template, null, 2), { mode: 0o600 });
357
+ if (isJsonMode()) {
358
+ printJson({ ok: true, path: dest, template });
359
+ }
360
+ else {
361
+ console.log(`Agent profile written: ${dest}`);
362
+ console.log(`Next: switchbot --profile agent config set-token <token> <secret>`);
363
+ }
364
+ }
365
+ else {
366
+ if (isJsonMode()) {
367
+ printJson(template);
368
+ }
369
+ else {
370
+ console.log(JSON.stringify(template, null, 2));
371
+ console.log('');
372
+ console.log('Write with: switchbot config agent-profile --write');
373
+ }
374
+ }
375
+ });
297
376
  }