@switchbot/openapi-cli 3.1.0 → 3.2.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.
Files changed (113) hide show
  1. package/README.md +34 -42
  2. package/dist/index.js +56945 -169
  3. package/dist/policy/schema/v0.2.json +1 -1
  4. package/package.json +3 -2
  5. package/dist/api/client.js +0 -235
  6. package/dist/auth.js +0 -20
  7. package/dist/commands/agent-bootstrap.js +0 -182
  8. package/dist/commands/auth.js +0 -354
  9. package/dist/commands/batch.js +0 -413
  10. package/dist/commands/cache.js +0 -126
  11. package/dist/commands/capabilities.js +0 -385
  12. package/dist/commands/catalog.js +0 -359
  13. package/dist/commands/completion.js +0 -385
  14. package/dist/commands/config.js +0 -376
  15. package/dist/commands/daemon.js +0 -367
  16. package/dist/commands/device-meta.js +0 -159
  17. package/dist/commands/devices.js +0 -948
  18. package/dist/commands/doctor.js +0 -1015
  19. package/dist/commands/events.js +0 -563
  20. package/dist/commands/expand.js +0 -130
  21. package/dist/commands/explain.js +0 -139
  22. package/dist/commands/health.js +0 -113
  23. package/dist/commands/history.js +0 -320
  24. package/dist/commands/identity.js +0 -59
  25. package/dist/commands/install.js +0 -246
  26. package/dist/commands/mcp.js +0 -2017
  27. package/dist/commands/plan.js +0 -653
  28. package/dist/commands/policy.js +0 -586
  29. package/dist/commands/quota.js +0 -78
  30. package/dist/commands/rules.js +0 -875
  31. package/dist/commands/scenes.js +0 -264
  32. package/dist/commands/schema.js +0 -177
  33. package/dist/commands/status-sync.js +0 -131
  34. package/dist/commands/uninstall.js +0 -237
  35. package/dist/commands/upgrade-check.js +0 -88
  36. package/dist/commands/watch.js +0 -194
  37. package/dist/commands/webhook.js +0 -182
  38. package/dist/config.js +0 -258
  39. package/dist/credentials/backends/file.js +0 -101
  40. package/dist/credentials/backends/linux.js +0 -129
  41. package/dist/credentials/backends/macos.js +0 -129
  42. package/dist/credentials/backends/windows.js +0 -215
  43. package/dist/credentials/keychain.js +0 -88
  44. package/dist/credentials/prime.js +0 -52
  45. package/dist/devices/cache.js +0 -293
  46. package/dist/devices/catalog.js +0 -767
  47. package/dist/devices/device-meta.js +0 -56
  48. package/dist/devices/history-agg.js +0 -138
  49. package/dist/devices/history-query.js +0 -181
  50. package/dist/devices/param-validator.js +0 -433
  51. package/dist/devices/resources.js +0 -270
  52. package/dist/install/default-steps.js +0 -257
  53. package/dist/install/preflight.js +0 -212
  54. package/dist/install/steps.js +0 -67
  55. package/dist/lib/command-keywords.js +0 -17
  56. package/dist/lib/daemon-state.js +0 -46
  57. package/dist/lib/destructive-mode.js +0 -12
  58. package/dist/lib/devices.js +0 -382
  59. package/dist/lib/idempotency.js +0 -106
  60. package/dist/lib/plan-store.js +0 -68
  61. package/dist/lib/request-context.js +0 -12
  62. package/dist/lib/scenes.js +0 -10
  63. package/dist/logger.js +0 -16
  64. package/dist/mcp/device-history.js +0 -145
  65. package/dist/mcp/events-subscription.js +0 -213
  66. package/dist/mqtt/client.js +0 -180
  67. package/dist/mqtt/credential.js +0 -30
  68. package/dist/policy/add-rule.js +0 -124
  69. package/dist/policy/diff.js +0 -91
  70. package/dist/policy/format.js +0 -57
  71. package/dist/policy/load.js +0 -61
  72. package/dist/policy/migrate.js +0 -67
  73. package/dist/policy/schema.js +0 -18
  74. package/dist/policy/validate.js +0 -262
  75. package/dist/rules/action.js +0 -205
  76. package/dist/rules/audit-query.js +0 -89
  77. package/dist/rules/conflict-analyzer.js +0 -203
  78. package/dist/rules/cron-scheduler.js +0 -186
  79. package/dist/rules/destructive.js +0 -52
  80. package/dist/rules/engine.js +0 -757
  81. package/dist/rules/matcher.js +0 -230
  82. package/dist/rules/pid-file.js +0 -95
  83. package/dist/rules/quiet-hours.js +0 -45
  84. package/dist/rules/suggest.js +0 -95
  85. package/dist/rules/throttle.js +0 -116
  86. package/dist/rules/types.js +0 -34
  87. package/dist/rules/webhook-listener.js +0 -223
  88. package/dist/rules/webhook-token.js +0 -90
  89. package/dist/schema/field-aliases.js +0 -131
  90. package/dist/sinks/dispatcher.js +0 -12
  91. package/dist/sinks/file.js +0 -19
  92. package/dist/sinks/format.js +0 -56
  93. package/dist/sinks/homeassistant.js +0 -44
  94. package/dist/sinks/openclaw.js +0 -33
  95. package/dist/sinks/stdout.js +0 -5
  96. package/dist/sinks/telegram.js +0 -28
  97. package/dist/sinks/types.js +0 -1
  98. package/dist/sinks/webhook.js +0 -22
  99. package/dist/status-sync/manager.js +0 -268
  100. package/dist/utils/arg-parsers.js +0 -66
  101. package/dist/utils/audit.js +0 -117
  102. package/dist/utils/filter.js +0 -189
  103. package/dist/utils/flags.js +0 -186
  104. package/dist/utils/format.js +0 -117
  105. package/dist/utils/health.js +0 -101
  106. package/dist/utils/help-json.js +0 -54
  107. package/dist/utils/name-resolver.js +0 -137
  108. package/dist/utils/output.js +0 -404
  109. package/dist/utils/quota.js +0 -227
  110. package/dist/utils/redact.js +0 -68
  111. package/dist/utils/retry.js +0 -140
  112. package/dist/utils/string.js +0 -22
  113. package/dist/version.js +0 -4
@@ -1,320 +0,0 @@
1
- import path from 'node:path';
2
- import os from 'node:os';
3
- import { intArg, stringArg } from '../utils/arg-parsers.js';
4
- import { printJson, isJsonMode, handleError, UsageError, exitWithError } from '../utils/output.js';
5
- import { readAudit, verifyAudit } from '../utils/audit.js';
6
- import { executeCommand } from '../lib/devices.js';
7
- import { queryDeviceHistory, queryDeviceHistoryStats, } from '../devices/history-query.js';
8
- import { aggregateDeviceHistory, ALL_AGG_FNS, } from '../devices/history-agg.js';
9
- const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
10
- export function registerHistoryCommand(program) {
11
- const history = program
12
- .command('history')
13
- .description('View and replay SwitchBot commands recorded via --audit-log')
14
- .addHelpText('after', `
15
- Every 'devices command' run with --audit-log is appended as JSONL to the
16
- audit file (default ~/.switchbot/audit.log). 'history show' prints the file,
17
- 'history replay <n>' re-runs the Nth entry (1-indexed, most-recent last).
18
-
19
- Examples:
20
- $ switchbot --audit-log devices command <id> turnOff
21
- $ switchbot history show --limit 10
22
- $ switchbot history replay 3
23
- `);
24
- history
25
- .command('show')
26
- .description('Print recent audit entries')
27
- .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
28
- .option('--limit <n>', 'Show only the last N entries', intArg('--limit', { min: 1 }))
29
- .action((options) => {
30
- const file = options.file ?? DEFAULT_AUDIT;
31
- const entries = readAudit(file);
32
- const limited = options.limit !== undefined
33
- ? entries.slice(-Math.max(1, Number(options.limit) || 1))
34
- : entries;
35
- if (isJsonMode()) {
36
- printJson({ file, total: entries.length, entries: limited });
37
- return;
38
- }
39
- if (entries.length === 0) {
40
- console.log(`(no entries in ${file})`);
41
- return;
42
- }
43
- const startIdx = entries.length - limited.length;
44
- limited.forEach((e, i) => {
45
- const idx = startIdx + i + 1;
46
- const mark = e.result === 'error' ? '✗' : e.dryRun ? '◦' : '✓';
47
- const param = e.parameter !== undefined && e.parameter !== 'default'
48
- ? ` ${JSON.stringify(e.parameter)}`
49
- : '';
50
- const err = e.error ? ` [err: ${e.error}]` : '';
51
- console.log(`${String(idx).padStart(4)} ${mark} ${e.t} ${e.deviceId} ${e.command}${param}${err}`);
52
- });
53
- });
54
- history
55
- .command('replay')
56
- .description('Re-run a recorded command by its 1-indexed position')
57
- .argument('<index>', 'Entry index (1 = oldest; as shown by "history show")')
58
- .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
59
- .addHelpText('after', `
60
- Dry-run-honouring: pass --dry-run on the parent command to preview without
61
- sending the actual call. Errors from the recorded entry are NOT replayed —
62
- replay always attempts the command fresh.
63
-
64
- Examples:
65
- $ switchbot history replay 3
66
- $ switchbot --dry-run history replay 3
67
- `)
68
- .action(async (indexArg, options) => {
69
- const file = options.file ?? DEFAULT_AUDIT;
70
- const entries = readAudit(file);
71
- const idx = Number(indexArg);
72
- if (!Number.isInteger(idx) || idx < 1 || idx > entries.length) {
73
- exitWithError({
74
- code: 2,
75
- kind: 'usage',
76
- message: `Invalid index ${indexArg}. Log has ${entries.length} entries.`,
77
- });
78
- }
79
- const entry = entries[idx - 1];
80
- if (entry.kind !== 'command') {
81
- exitWithError({
82
- code: 2,
83
- kind: 'usage',
84
- message: `Entry ${idx} is not a command (kind=${entry.kind}).`,
85
- });
86
- }
87
- try {
88
- const result = await executeCommand(entry.deviceId, entry.command, entry.parameter, entry.commandType);
89
- if (isJsonMode()) {
90
- printJson({ replayed: entry, result });
91
- }
92
- else {
93
- console.log(`✓ replayed ${entry.command} on ${entry.deviceId}`);
94
- }
95
- }
96
- catch (err) {
97
- handleError(err);
98
- }
99
- });
100
- history
101
- .command('range')
102
- .description('Query time-ranged device history from JSONL storage (populated by events mqtt-tail / MCP)')
103
- .argument('<deviceId>', 'Device ID to query')
104
- .option('--since <duration>', 'Relative window ending now, e.g. "30s", "15m", "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
105
- .option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
106
- .option('--to <iso>', 'Range end (ISO-8601)', stringArg('--to'))
107
- .option('--field <name>', 'Project a payload field (repeat to keep multiple)', (v, acc = []) => acc.concat(v), [])
108
- .option('--limit <n>', 'Maximum records to return (default 1000)', intArg('--limit', { min: 1 }))
109
- .addHelpText('after', `
110
- History is the append-only JSONL mirror of the per-device ring buffer: every
111
- 'events mqtt-tail' event and every MCP tool status-refresh is written to
112
- ~/.switchbot/device-history/<deviceId>.jsonl (rotates at 50MB × 3 files).
113
-
114
- Examples:
115
- $ switchbot history range <id> --since 7d --json
116
- $ switchbot history range <id> --since 1h --field temperature --field humidity
117
- $ switchbot history range <id> --from 2026-04-18T00:00:00Z --to 2026-04-19T00:00:00Z
118
- `)
119
- .action(async (deviceId, options) => {
120
- // Usage-level validation: keep synchronous and pre-query so handleError
121
- // maps these to exit 2 (via UsageError) rather than runtime exit 1.
122
- if (options.since && (options.from || options.to)) {
123
- handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
124
- }
125
- try {
126
- const records = await queryDeviceHistory(deviceId, {
127
- since: options.since,
128
- from: options.from,
129
- to: options.to,
130
- fields: options.field ?? [],
131
- limit: options.limit !== undefined ? Number(options.limit) : undefined,
132
- });
133
- if (isJsonMode()) {
134
- printJson({ deviceId, count: records.length, records });
135
- return;
136
- }
137
- if (records.length === 0) {
138
- console.log(`(no history records for ${deviceId} in requested range)`);
139
- return;
140
- }
141
- for (const r of records) {
142
- const payloadStr = JSON.stringify(r.payload);
143
- console.log(`${r.t} ${r.topic} ${payloadStr}`);
144
- }
145
- }
146
- catch (err) {
147
- // Convert history-query's plain Error range messages into UsageError so
148
- // they exit 2 instead of 1.
149
- if (err instanceof Error && /^(Invalid --|--from|--since)/i.test(err.message)) {
150
- handleError(new UsageError(err.message));
151
- }
152
- handleError(err);
153
- }
154
- });
155
- history
156
- .command('stats')
157
- .description('Show on-disk size + record counts for a device history')
158
- .argument('<deviceId>', 'Device ID to inspect')
159
- .action((deviceId) => {
160
- try {
161
- const stats = queryDeviceHistoryStats(deviceId);
162
- if (isJsonMode()) {
163
- printJson(stats);
164
- return;
165
- }
166
- console.log(`Device: ${stats.deviceId}`);
167
- console.log(`History dir: ${stats.historyDir}`);
168
- console.log(`JSONL files: ${stats.fileCount} (${stats.jsonlFiles.join(', ') || '—'})`);
169
- console.log(`Total size: ${stats.totalBytes.toLocaleString()} bytes`);
170
- console.log(`Record count: ${stats.recordCount}`);
171
- console.log(`Oldest: ${stats.oldest ?? '—'}`);
172
- console.log(`Newest: ${stats.newest ?? '—'}`);
173
- }
174
- catch (err) {
175
- handleError(err);
176
- }
177
- });
178
- history
179
- .command('verify')
180
- .description('Check the audit log for malformed lines and schema-version drift')
181
- .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
182
- .addHelpText('after', `
183
- See docs/audit-log.md for the audit log format. Exit code:
184
- 0 every line parses and carries the current auditVersion, or file is missing (warn)
185
- 1 one or more lines are malformed or schema drift detected
186
- 2 (usage) — not emitted by this subcommand
187
-
188
- Examples:
189
- $ switchbot history verify
190
- $ switchbot history verify --file ./custom.log --json
191
- `)
192
- .action((options) => {
193
- const file = options.file ?? DEFAULT_AUDIT;
194
- const report = verifyAudit(file);
195
- // Determine status and exit code
196
- let status = 'ok';
197
- let exitCode = 0;
198
- if (report.fileMissing) {
199
- status = 'warn';
200
- }
201
- else if (report.malformedLines > 0 || report.unversionedEntries > 0) {
202
- status = 'fail';
203
- exitCode = 1;
204
- }
205
- if (isJsonMode()) {
206
- const output = {
207
- status,
208
- fileMissing: report.fileMissing === true,
209
- parsed: report.parsedLines,
210
- malformed: report.malformedLines,
211
- unversioned: report.unversionedEntries,
212
- message: report.fileMissing
213
- ? 'Audit log file not found (fresh install)'
214
- : report.malformedLines > 0 || report.unversionedEntries > 0
215
- ? 'Audit log has malformed or unversioned entries'
216
- : 'Audit log is valid',
217
- };
218
- printJson(output);
219
- }
220
- else {
221
- if (report.fileMissing) {
222
- console.log(`Audit log: ${report.file} (missing — fresh install)`);
223
- console.log(`Status: ✓ warn (expected for new accounts)`);
224
- }
225
- else {
226
- console.log(`Audit log: ${report.file}`);
227
- console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`);
228
- console.log(`Malformed: ${report.malformedLines}`);
229
- console.log(`Unversioned: ${report.unversionedEntries}`);
230
- const versions = Object.entries(report.versionCounts)
231
- .map(([v, n]) => `${v}:${n}`)
232
- .join(', ');
233
- console.log(`Version counts: ${versions || '—'}`);
234
- if (report.earliest)
235
- console.log(`Earliest: ${report.earliest}`);
236
- if (report.latest)
237
- console.log(`Latest: ${report.latest}`);
238
- if (report.problems.length > 0) {
239
- console.log('\nProblems:');
240
- for (const p of report.problems) {
241
- console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`);
242
- }
243
- }
244
- }
245
- }
246
- process.exit(exitCode);
247
- });
248
- history
249
- .command('aggregate')
250
- .description('Aggregate time-ranged device history metrics into buckets')
251
- .argument('<deviceId>', 'Device ID to aggregate')
252
- .option('--since <duration>', 'Relative window ending now, e.g. "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
253
- .option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
254
- .option('--to <iso>', 'Range end (ISO-8601)', stringArg('--to'))
255
- .requiredOption('--metric <name>', 'Payload field to aggregate (repeat for multiple; required)', (v, acc = []) => acc.concat(v))
256
- .option('--agg <csv>', 'Comma-separated aggregation functions (count,min,max,avg,sum,p50,p95)', stringArg('--agg'))
257
- .option('--bucket <duration>', 'Bucket width, e.g. "15m", "1h", "1d"', stringArg('--bucket'))
258
- .option('--max-bucket-samples <n>', 'Max samples per bucket for quantiles (1–100000)', intArg('--max-bucket-samples', { min: 1, max: 100_000 }))
259
- .action(async (deviceId, options) => {
260
- const metrics = options.metric ?? [];
261
- if (options.since && (options.from || options.to)) {
262
- handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
263
- }
264
- let aggs;
265
- if (options.agg !== undefined) {
266
- const parts = options.agg.split(',').map((s) => s.trim()).filter(Boolean);
267
- const unknown = parts.filter((p) => !ALL_AGG_FNS.includes(p));
268
- if (unknown.length > 0) {
269
- handleError(new UsageError(`Unknown aggregation function(s): ${unknown.join(', ')}. Legal values: ${ALL_AGG_FNS.join(', ')}.`));
270
- }
271
- aggs = parts;
272
- }
273
- const aggOpts = {
274
- metrics,
275
- aggs,
276
- since: options.since,
277
- from: options.from,
278
- to: options.to,
279
- bucket: options.bucket,
280
- maxBucketSamples: options.maxBucketSamples !== undefined ? Number(options.maxBucketSamples) : undefined,
281
- };
282
- try {
283
- const res = await aggregateDeviceHistory(deviceId, aggOpts);
284
- if (isJsonMode()) {
285
- printJson(res);
286
- return;
287
- }
288
- if (res.buckets.length === 0) {
289
- console.log(`(no history records for ${deviceId} in requested range)`);
290
- return;
291
- }
292
- const aggCols = res.aggs;
293
- const cols = ['t', ...res.metrics.flatMap((m) => aggCols.map((a) => `${m}.${a}`))];
294
- console.log(cols.join('\t'));
295
- for (const bkt of res.buckets) {
296
- const row = cols.map((col) => {
297
- if (col === 't')
298
- return bkt.t;
299
- const [metric, agg] = col.split('.');
300
- const val = bkt.metrics[metric]?.[agg];
301
- return val !== undefined ? String(val) : '\u2014';
302
- });
303
- console.log(row.join('\t'));
304
- }
305
- if (res.partial) {
306
- for (const note of res.notes) {
307
- console.error('note: ' + note);
308
- }
309
- }
310
- }
311
- catch (err) {
312
- if (err instanceof Error) {
313
- if (/bucket/i.test(err.message) || /--since/i.test(err.message) || /--from/i.test(err.message) || /--to/i.test(err.message)) {
314
- handleError(new UsageError(err.message));
315
- }
316
- }
317
- handleError(err);
318
- }
319
- });
320
- }
@@ -1,59 +0,0 @@
1
- /**
2
- * Single source of truth for SwitchBot product identity.
3
- *
4
- * Consumed by:
5
- * - `program.description()` / `--help` (via PRODUCT_TAGLINE in src/index.ts)
6
- * - `--help --json` root (via src/utils/help-json.ts)
7
- * - `switchbot capabilities` / `--json` (identity block)
8
- * - `switchbot agent-bootstrap --json` (identity block)
9
- *
10
- * Keeping this in one file prevents drift between those four surfaces.
11
- *
12
- * IMPORTANT: the SwitchBot CLI only talks to the SwitchBot Cloud API over
13
- * HTTPS. It does NOT drive BLE radios directly — BLE-only devices are
14
- * reached by going through a SwitchBot Hub, which the Cloud API already
15
- * handles transparently. Please do not reintroduce the word "BLE" into the
16
- * tagline / README: it is misleading for AI agents reading `--help`.
17
- */
18
- export const IDENTITY = {
19
- product: 'SwitchBot',
20
- domain: 'IoT smart home device control',
21
- vendor: 'Wonderlabs, Inc.',
22
- apiVersion: 'v1.1',
23
- apiDocs: 'https://github.com/OpenWonderLabs/SwitchBotAPI',
24
- // Product category keywords. AI agents scan these to judge scope
25
- // ("does SwitchBot control door locks? air conditioners?") without
26
- // parsing the full device catalog.
27
- productCategories: [
28
- 'lights (bulbs / strips / color)',
29
- 'locks / keypads',
30
- 'curtains / blinds / shades',
31
- 'sensors (motion / contact / climate / water-leak)',
32
- 'plugs / strips',
33
- 'bots / mechanical pushers',
34
- 'robot vacuums',
35
- 'IR appliances via Hub (TV / AC / fan / projector)',
36
- ],
37
- deviceCategories: {
38
- physical: 'Wi-Fi-connected and Hub-mediated devices — controlled via Cloud API (CLI does not drive BLE directly)',
39
- ir: 'IR remote devices learned by a SwitchBot Hub (TV, AC, fan, etc.)',
40
- },
41
- constraints: {
42
- quotaPerDay: 10000,
43
- hubRequiredForBle: true,
44
- transport: 'Cloud API v1.1 (HTTPS)',
45
- authMethod: 'HMAC-SHA256 token+secret',
46
- },
47
- agentGuide: 'docs/agent-guide.md',
48
- };
49
- /**
50
- * One-line product description used for `program.description()` (the first
51
- * line an AI agent sees when running `switchbot --help`).
52
- *
53
- * Structure: "SwitchBot smart home CLI — <product categories> via <transport>;
54
- * <verbs: scenes, events, MCP>." Keep categories in sync with
55
- * IDENTITY.productCategories above.
56
- */
57
- export const PRODUCT_TAGLINE = 'SwitchBot smart home CLI — control lights, locks, curtains, sensors, plugs, ' +
58
- 'and IR appliances (TV/AC/fan) via Cloud API v1.1; run scenes, stream real-time ' +
59
- 'events, and integrate AI agents via MCP.';
@@ -1,246 +0,0 @@
1
- /**
2
- * `switchbot install` — one-command bootstrap (Phase 3B in-repo).
3
- *
4
- * Collapses the 7-step Quickstart (credentials → policy → skill link →
5
- * doctor verify) into a single orchestrated command with automatic
6
- * rollback on any step failure. The step library
7
- * (`src/install/default-steps.ts`) does the heavy lifting; this file
8
- * composes the steps based on user flags, drives the step runner, and
9
- * formats the outcome.
10
- *
11
- * Design notes:
12
- * - `switchbot install` assumes the CLI is already on PATH (the user
13
- * ran `npm i -g @switchbot/openapi-cli` to get here). We do not
14
- * re-install the CLI from inside itself.
15
- * - Doctor verification is NOT a step — if it failed, an automatic
16
- * rollback would destroy good state. Instead we print a "next: run
17
- * `switchbot doctor`" hint after success.
18
- */
19
- import { InvalidArgumentError } from 'commander';
20
- import fs from 'node:fs';
21
- import path from 'node:path';
22
- import { resolvePolicyPath } from '../policy/load.js';
23
- import { runInstall } from '../install/steps.js';
24
- import { runPreflight } from '../install/preflight.js';
25
- import { stepPromptCredentials, stepWriteKeychain, stepScaffoldPolicy, stepSymlinkSkill, stepDoctorVerify, } from '../install/default-steps.js';
26
- import { isJsonMode, printJson } from '../utils/output.js';
27
- import { getActiveProfile } from '../lib/request-context.js';
28
- import chalk from 'chalk';
29
- const AGENT_VALUES = ['claude-code', 'cursor', 'copilot', 'none'];
30
- function parseAgent(value) {
31
- if (!value)
32
- return 'claude-code';
33
- if (!AGENT_VALUES.includes(value)) {
34
- throw new InvalidArgumentError(`--agent must be one of ${AGENT_VALUES.join(', ')} (got "${value}")`);
35
- }
36
- return value;
37
- }
38
- function parseSkipList(value) {
39
- if (!value)
40
- return new Set();
41
- return new Set(value
42
- .split(',')
43
- .map((s) => s.trim())
44
- .filter(Boolean));
45
- }
46
- function printRecipe(ctx) {
47
- if (!ctx.skillRecipePrinted)
48
- return;
49
- const lines = [];
50
- lines.push('');
51
- lines.push(chalk.bold(`Skill-install recipe for agent=${ctx.agent}:`));
52
- switch (ctx.agent) {
53
- case 'claude-code':
54
- lines.push(' # re-run with --skill-path pointing at your local clone of openclaw-switchbot-skill', ' switchbot install --agent claude-code --skill-path /path/to/openclaw-switchbot-skill');
55
- break;
56
- case 'cursor':
57
- lines.push(' # Cursor expects a rules file, not a skill directory. See:', ' # openclaw-switchbot-skill/docs/agents/cursor.md');
58
- break;
59
- case 'copilot':
60
- lines.push(' # Copilot merges instructions into .github/copilot-instructions.md. See:', ' # openclaw-switchbot-skill/docs/agents/copilot.md');
61
- break;
62
- case 'none':
63
- lines.push(' (none — skill step skipped)');
64
- break;
65
- }
66
- console.error(lines.join('\n'));
67
- }
68
- function printDryRun(steps, ctx) {
69
- if (isJsonMode()) {
70
- printJson({
71
- dryRun: true,
72
- profile: ctx.profile,
73
- agent: ctx.agent,
74
- skillPath: ctx.skillPath ?? null,
75
- policyPath: ctx.policyPath,
76
- steps: steps.map((s) => ({ name: s.name, description: s.description })),
77
- });
78
- return;
79
- }
80
- console.log(chalk.bold('switchbot install — dry run'));
81
- console.log(` profile: ${ctx.profile}`);
82
- console.log(` agent: ${ctx.agent}`);
83
- console.log(` skill: ${ctx.skillPath ?? '(none — recipe will be printed)'}`);
84
- console.log(` policy: ${ctx.policyPath}`);
85
- console.log('');
86
- console.log(chalk.bold('Steps that would run (in order):'));
87
- for (const s of steps) {
88
- console.log(` • ${s.name}${s.description ? ` — ${s.description}` : ''}`);
89
- }
90
- console.log('');
91
- console.log(chalk.dim('No changes made. Re-run without --dry-run to apply.'));
92
- }
93
- export function registerInstallCommand(program) {
94
- program
95
- .command('install')
96
- .description('One-command bootstrap: credentials + policy + skill link (rolls back on failure)')
97
- .option('--agent <name>', `target agent: ${AGENT_VALUES.join(' | ')} (default: claude-code)`)
98
- .option('--skill-path <dir>', 'local clone of openclaw-switchbot-skill (enables auto-link)')
99
- .option('--token-file <path>', 'two-line credential file (token, secret); read once and deleted on success')
100
- .option('--skip <names>', 'comma-separated list of step names to skip (e.g. "scaffold-policy,symlink-skill")')
101
- .option('--force', 'replace an existing skill symlink pointing at a different path; allow link even without SKILL.md')
102
- .option('--verify', 'after a successful install, run `switchbot doctor --json` as a warn-only post-check')
103
- .addHelpText('after', `
104
- The global --dry-run flag previews the step list without making changes.
105
- Global --json emits the install report as JSON to stdout.
106
-
107
- Exit codes:
108
- 0 success
109
- 2 preflight check failed (nothing changed)
110
- 3 step failed; rollback completed
111
- 4 step failed; rollback had residue (see output)
112
-
113
- Examples:
114
- # Interactive install, Claude Code skill not linked (recipe printed):
115
- switchbot install
116
-
117
- # Full install with skill link:
118
- switchbot install --skill-path ../openclaw-switchbot-skill
119
-
120
- # Non-interactive (CI) install:
121
- printf '%s\\n%s\\n' "$TOKEN" "$SECRET" > /tmp/sb-creds
122
- switchbot install --token-file /tmp/sb-creds --skill-path ./skill
123
- `)
124
- .action(async (opts, command) => {
125
- const agent = parseAgent(opts.agent);
126
- const profile = getActiveProfile() ?? 'default';
127
- const skip = parseSkipList(opts.skip);
128
- const skillPath = opts.skillPath ? path.resolve(opts.skillPath) : undefined;
129
- const tokenFile = opts.tokenFile ? path.resolve(opts.tokenFile) : undefined;
130
- const force = Boolean(opts.force);
131
- const verify = Boolean(opts.verify);
132
- const globalOpts = command.parent?.opts() ?? {};
133
- const dryRun = Boolean(globalOpts.dryRun);
134
- // Pre-flight: read-only checks, never mutate anything.
135
- const pf = await runPreflight({
136
- agent,
137
- expectSkillLink: agent === 'claude-code' && Boolean(skillPath),
138
- });
139
- if (!pf.ok) {
140
- if (isJsonMode()) {
141
- printJson({ ok: false, stage: 'preflight', preflight: pf });
142
- }
143
- else {
144
- console.error(chalk.red('✗ preflight failed — nothing changed'));
145
- for (const c of pf.checks) {
146
- const mark = c.status === 'fail' ? chalk.red('✗') : c.status === 'warn' ? chalk.yellow('!') : chalk.green('✓');
147
- console.error(` ${mark} ${c.name}: ${c.message}`);
148
- if (c.hint)
149
- console.error(` hint: ${c.hint}`);
150
- }
151
- }
152
- process.exit(2);
153
- }
154
- const ctx = {
155
- profile,
156
- agent,
157
- skillPath,
158
- tokenFile,
159
- policyPath: resolvePolicyPath(),
160
- nonInteractive: !process.stdin.isTTY && !tokenFile,
161
- };
162
- const allSteps = [
163
- stepPromptCredentials(),
164
- stepWriteKeychain(),
165
- stepScaffoldPolicy(),
166
- stepSymlinkSkill({ force }),
167
- ];
168
- const steps = allSteps.filter((s) => !skip.has(s.name));
169
- if (dryRun) {
170
- printDryRun(steps, ctx);
171
- return;
172
- }
173
- const report = await runInstall(steps, { context: ctx });
174
- // Delete the token file now that credentials are committed.
175
- if (report.ok && tokenFile) {
176
- try {
177
- fs.unlinkSync(tokenFile);
178
- }
179
- catch {
180
- // non-fatal: credentials are already in the keychain
181
- }
182
- }
183
- // A7: opt-in post-install verification. Doctor is NEVER part of the
184
- // rollback chain — a failing doctor after a good install would
185
- // destroy working state. So we run it AFTER runInstall resolves, as
186
- // a warn-only check. The outcome is reported but never flips the
187
- // command's exit code.
188
- if (report.ok && verify) {
189
- const cliPath = process.argv[1] ?? '';
190
- const step = stepDoctorVerify({ cliPath });
191
- await step.execute(ctx);
192
- }
193
- if (isJsonMode()) {
194
- printJson({
195
- ok: report.ok,
196
- profile: ctx.profile,
197
- agent: ctx.agent,
198
- report,
199
- preflight: pf,
200
- policyPath: ctx.policyPath,
201
- policyScaffolded: ctx.policyScaffoldResult && !ctx.policyScaffoldResult.skipped,
202
- skillLinkPath: ctx.skillLinkPath,
203
- skillLinkCreated: Boolean(ctx.skillLinkCreated),
204
- verify: verify ? { ok: ctx.doctorOk ?? null, report: ctx.doctorReport ?? null } : undefined,
205
- });
206
- }
207
- else if (report.ok) {
208
- console.log(chalk.green('✓ install complete'));
209
- if (ctx.skillLinkCreated)
210
- console.log(` linked skill: ${ctx.skillLinkPath}`);
211
- if (ctx.policyScaffoldResult?.skipped === false)
212
- console.log(` wrote policy: ${ctx.policyScaffoldResult.policyPath}`);
213
- printRecipe(ctx);
214
- if (verify) {
215
- if (ctx.doctorOk) {
216
- console.log(chalk.green('✓ doctor --json: all green'));
217
- }
218
- else {
219
- console.log(chalk.yellow('! doctor --json reported issues — install is committed; run `switchbot doctor` to inspect'));
220
- }
221
- }
222
- console.log('');
223
- console.log(chalk.bold('Next:'));
224
- console.log(' switchbot doctor # verify the setup');
225
- console.log(' switchbot devices list # smoke test');
226
- }
227
- else {
228
- console.error(chalk.red(`✗ install failed at step: ${report.failedAt}`));
229
- const residue = report.outcomes.some((o) => o.status === 'rollback-failed');
230
- for (const o of report.outcomes) {
231
- const tag = o.status === 'succeeded' ? chalk.green('✓') :
232
- o.status === 'failed' ? chalk.red('✗') :
233
- o.status === 'rolled-back' ? chalk.yellow('↺') :
234
- o.status === 'rollback-failed' ? chalk.red('!!') :
235
- chalk.dim('·');
236
- const msg = o.status === 'failed' || o.status === 'rollback-failed' ? ` — ${o.error}` : '';
237
- console.error(` ${tag} ${o.step} [${o.status}]${msg}`);
238
- }
239
- if (residue) {
240
- console.error(chalk.red('Rollback left residue. Run `switchbot uninstall` to clean up or review output above.'));
241
- process.exit(4);
242
- }
243
- process.exit(3);
244
- }
245
- });
246
- }