@switchbot/openapi-cli 2.6.4 → 3.0.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 (67) hide show
  1. package/README.md +385 -103
  2. package/dist/api/client.js +13 -12
  3. package/dist/commands/agent-bootstrap.js +67 -16
  4. package/dist/commands/auth.js +354 -0
  5. package/dist/commands/batch.js +26 -21
  6. package/dist/commands/capabilities.js +29 -21
  7. package/dist/commands/catalog.js +4 -3
  8. package/dist/commands/config.js +57 -37
  9. package/dist/commands/devices.js +63 -37
  10. package/dist/commands/doctor.js +539 -26
  11. package/dist/commands/events.js +115 -26
  12. package/dist/commands/expand.js +7 -15
  13. package/dist/commands/explain.js +10 -7
  14. package/dist/commands/history.js +12 -18
  15. package/dist/commands/identity.js +59 -0
  16. package/dist/commands/install.js +246 -0
  17. package/dist/commands/mcp.js +895 -15
  18. package/dist/commands/plan.js +111 -15
  19. package/dist/commands/policy.js +469 -0
  20. package/dist/commands/rules.js +657 -0
  21. package/dist/commands/schema.js +20 -12
  22. package/dist/commands/status-sync.js +131 -0
  23. package/dist/commands/uninstall.js +237 -0
  24. package/dist/commands/watch.js +15 -2
  25. package/dist/config.js +14 -0
  26. package/dist/credentials/backends/file.js +101 -0
  27. package/dist/credentials/backends/linux.js +129 -0
  28. package/dist/credentials/backends/macos.js +129 -0
  29. package/dist/credentials/backends/windows.js +215 -0
  30. package/dist/credentials/keychain.js +88 -0
  31. package/dist/credentials/prime.js +52 -0
  32. package/dist/devices/catalog.js +118 -11
  33. package/dist/devices/resources.js +270 -0
  34. package/dist/index.js +39 -4
  35. package/dist/install/default-steps.js +257 -0
  36. package/dist/install/preflight.js +212 -0
  37. package/dist/install/steps.js +67 -0
  38. package/dist/lib/command-keywords.js +17 -0
  39. package/dist/lib/devices.js +15 -5
  40. package/dist/policy/add-rule.js +124 -0
  41. package/dist/policy/diff.js +91 -0
  42. package/dist/policy/examples/policy.example.yaml +99 -0
  43. package/dist/policy/format.js +57 -0
  44. package/dist/policy/load.js +61 -0
  45. package/dist/policy/migrate.js +67 -0
  46. package/dist/policy/schema/v0.2.json +302 -0
  47. package/dist/policy/schema.js +18 -0
  48. package/dist/policy/validate.js +262 -0
  49. package/dist/rules/action.js +205 -0
  50. package/dist/rules/audit-query.js +89 -0
  51. package/dist/rules/cron-scheduler.js +186 -0
  52. package/dist/rules/destructive.js +52 -0
  53. package/dist/rules/engine.js +567 -0
  54. package/dist/rules/matcher.js +230 -0
  55. package/dist/rules/pid-file.js +95 -0
  56. package/dist/rules/quiet-hours.js +45 -0
  57. package/dist/rules/suggest.js +95 -0
  58. package/dist/rules/throttle.js +78 -0
  59. package/dist/rules/types.js +34 -0
  60. package/dist/rules/webhook-listener.js +223 -0
  61. package/dist/rules/webhook-token.js +90 -0
  62. package/dist/schema/field-aliases.js +95 -0
  63. package/dist/status-sync/manager.js +268 -0
  64. package/dist/utils/audit.js +12 -2
  65. package/dist/utils/help-json.js +54 -0
  66. package/dist/utils/output.js +17 -0
  67. package/package.json +12 -4
@@ -0,0 +1,268 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { tryLoadConfig } from '../config.js';
6
+ import { getActiveProfile } from '../lib/request-context.js';
7
+ import { UsageError } from '../utils/output.js';
8
+ import { getConfigPath } from '../utils/flags.js';
9
+ const DEFAULT_OPENCLAW_URL = 'http://localhost:18789';
10
+ function resolveStatusSyncRuntime(options) {
11
+ if (!tryLoadConfig()) {
12
+ throw new UsageError('No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.');
13
+ }
14
+ const openclawToken = options.openclawToken ?? process.env.OPENCLAW_TOKEN;
15
+ if (!openclawToken) {
16
+ throw new UsageError('--openclaw-token is required or set OPENCLAW_TOKEN in the environment.');
17
+ }
18
+ const openclawModel = options.openclawModel ?? process.env.OPENCLAW_MODEL;
19
+ if (!openclawModel) {
20
+ throw new UsageError('--openclaw-model is required or set OPENCLAW_MODEL in the environment.');
21
+ }
22
+ return {
23
+ openclawUrl: options.openclawUrl ?? process.env.OPENCLAW_URL ?? DEFAULT_OPENCLAW_URL,
24
+ openclawToken,
25
+ openclawModel,
26
+ ...(options.topic ? { topic: options.topic } : {}),
27
+ };
28
+ }
29
+ export function resolveStatusSyncPaths(explicitStateDir) {
30
+ const stateDir = path.resolve(explicitStateDir
31
+ ?? process.env.SWITCHBOT_STATUS_SYNC_HOME
32
+ ?? path.join(os.homedir(), '.switchbot', 'status-sync'));
33
+ return {
34
+ stateDir,
35
+ stateFile: path.join(stateDir, 'state.json'),
36
+ stdoutLog: path.join(stateDir, 'stdout.log'),
37
+ stderrLog: path.join(stateDir, 'stderr.log'),
38
+ };
39
+ }
40
+ export function buildStatusSyncChildArgs(options) {
41
+ const scriptPath = process.argv[1];
42
+ if (!scriptPath) {
43
+ throw new Error('Cannot determine the current CLI entrypoint path.');
44
+ }
45
+ const args = [path.resolve(scriptPath)];
46
+ const configPath = getConfigPath();
47
+ const profile = getActiveProfile();
48
+ if (configPath) {
49
+ args.push('--config', path.resolve(configPath));
50
+ }
51
+ else if (profile) {
52
+ args.push('--profile', profile);
53
+ }
54
+ args.push('events', 'mqtt-tail', '--sink', 'openclaw', '--openclaw-url', options.openclawUrl, '--openclaw-model', options.openclawModel);
55
+ if (options.topic) {
56
+ args.push('--topic', options.topic);
57
+ }
58
+ return args;
59
+ }
60
+ function safeUnlink(filePath) {
61
+ try {
62
+ fs.unlinkSync(filePath);
63
+ }
64
+ catch {
65
+ // best-effort cleanup
66
+ }
67
+ }
68
+ function isProcessRunning(pid) {
69
+ try {
70
+ process.kill(pid, 0);
71
+ return true;
72
+ }
73
+ catch (err) {
74
+ const code = err.code;
75
+ if (code === 'EPERM')
76
+ return true;
77
+ return false;
78
+ }
79
+ }
80
+ function readStateFile(paths) {
81
+ if (!fs.existsSync(paths.stateFile))
82
+ return null;
83
+ try {
84
+ const raw = JSON.parse(fs.readFileSync(paths.stateFile, 'utf-8'));
85
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
86
+ safeUnlink(paths.stateFile);
87
+ return null;
88
+ }
89
+ const parsed = raw;
90
+ if (typeof parsed.pid !== 'number' ||
91
+ !Number.isInteger(parsed.pid) ||
92
+ parsed.pid < 1 ||
93
+ typeof parsed.startedAt !== 'string' ||
94
+ !Array.isArray(parsed.command) ||
95
+ typeof parsed.stdoutLog !== 'string' ||
96
+ typeof parsed.stderrLog !== 'string') {
97
+ safeUnlink(paths.stateFile);
98
+ return null;
99
+ }
100
+ return {
101
+ pid: parsed.pid,
102
+ startedAt: parsed.startedAt,
103
+ command: parsed.command.map(String),
104
+ openclawUrl: typeof parsed.openclawUrl === 'string' ? parsed.openclawUrl : DEFAULT_OPENCLAW_URL,
105
+ openclawModel: typeof parsed.openclawModel === 'string' ? parsed.openclawModel : '',
106
+ topic: typeof parsed.topic === 'string' ? parsed.topic : null,
107
+ configPath: typeof parsed.configPath === 'string' ? parsed.configPath : null,
108
+ profile: typeof parsed.profile === 'string' ? parsed.profile : null,
109
+ stdoutLog: parsed.stdoutLog,
110
+ stderrLog: parsed.stderrLog,
111
+ };
112
+ }
113
+ catch {
114
+ safeUnlink(paths.stateFile);
115
+ return null;
116
+ }
117
+ }
118
+ function toStatus(paths, state, running) {
119
+ return {
120
+ running,
121
+ pid: running && state ? state.pid : null,
122
+ startedAt: running && state ? state.startedAt : null,
123
+ stateDir: paths.stateDir,
124
+ stateFile: paths.stateFile,
125
+ stdoutLog: state?.stdoutLog ?? paths.stdoutLog,
126
+ stderrLog: state?.stderrLog ?? paths.stderrLog,
127
+ command: running && state ? state.command : null,
128
+ openclawUrl: running && state ? state.openclawUrl : null,
129
+ openclawModel: running && state ? state.openclawModel : null,
130
+ topic: running && state ? state.topic : null,
131
+ configPath: running && state ? state.configPath : null,
132
+ profile: running && state ? state.profile : null,
133
+ };
134
+ }
135
+ function killProcessTree(pid) {
136
+ if (process.platform === 'win32') {
137
+ const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
138
+ if (result.error)
139
+ throw result.error;
140
+ if (result.status !== 0 && isProcessRunning(pid)) {
141
+ throw new Error(`Failed to stop status-sync process tree (PID ${pid}).`);
142
+ }
143
+ return;
144
+ }
145
+ try {
146
+ process.kill(-pid, 'SIGTERM');
147
+ }
148
+ catch (err) {
149
+ const code = err.code;
150
+ if (code === 'ESRCH') {
151
+ return;
152
+ }
153
+ process.kill(pid, 'SIGTERM');
154
+ }
155
+ }
156
+ export function getStatusSyncStatus(options = {}) {
157
+ const paths = resolveStatusSyncPaths(options.stateDir);
158
+ const state = readStateFile(paths);
159
+ if (!state) {
160
+ return toStatus(paths, null, false);
161
+ }
162
+ if (!isProcessRunning(state.pid)) {
163
+ safeUnlink(paths.stateFile);
164
+ return toStatus(paths, null, false);
165
+ }
166
+ return toStatus(paths, state, true);
167
+ }
168
+ export function stopStatusSync(options = {}) {
169
+ const paths = resolveStatusSyncPaths(options.stateDir);
170
+ const state = readStateFile(paths);
171
+ if (!state) {
172
+ return {
173
+ stopped: false,
174
+ stale: false,
175
+ pid: null,
176
+ status: toStatus(paths, null, false),
177
+ };
178
+ }
179
+ if (!isProcessRunning(state.pid)) {
180
+ safeUnlink(paths.stateFile);
181
+ return {
182
+ stopped: false,
183
+ stale: true,
184
+ pid: state.pid,
185
+ status: toStatus(paths, null, false),
186
+ };
187
+ }
188
+ killProcessTree(state.pid);
189
+ if (isProcessRunning(state.pid)) {
190
+ throw new Error(`Failed to stop status-sync process (PID ${state.pid}); process is still running.`);
191
+ }
192
+ safeUnlink(paths.stateFile);
193
+ return {
194
+ stopped: true,
195
+ stale: false,
196
+ pid: state.pid,
197
+ status: toStatus(paths, null, false),
198
+ };
199
+ }
200
+ export function startStatusSync(options = {}) {
201
+ const runtime = resolveStatusSyncRuntime(options);
202
+ const paths = resolveStatusSyncPaths(options.stateDir);
203
+ const existing = getStatusSyncStatus({ stateDir: paths.stateDir });
204
+ if (existing.running) {
205
+ if (!options.force) {
206
+ throw new UsageError(`status-sync is already running (PID ${existing.pid}). Run 'switchbot status-sync stop' first or re-run with --force.`);
207
+ }
208
+ stopStatusSync({ stateDir: paths.stateDir });
209
+ }
210
+ fs.mkdirSync(paths.stateDir, { recursive: true });
211
+ const configPath = getConfigPath();
212
+ const command = buildStatusSyncChildArgs(runtime);
213
+ let stdoutFd = null;
214
+ let stderrFd = null;
215
+ try {
216
+ stdoutFd = fs.openSync(paths.stdoutLog, 'a');
217
+ stderrFd = fs.openSync(paths.stderrLog, 'a');
218
+ const child = spawn(process.execPath, command, {
219
+ detached: true,
220
+ stdio: ['ignore', stdoutFd, stderrFd],
221
+ windowsHide: true,
222
+ env: { ...process.env, OPENCLAW_TOKEN: runtime.openclawToken },
223
+ });
224
+ if (!child.pid) {
225
+ throw new Error('Failed to start status-sync child process.');
226
+ }
227
+ child.unref();
228
+ const state = {
229
+ pid: child.pid,
230
+ startedAt: new Date().toISOString(),
231
+ command: [process.execPath, ...command],
232
+ openclawUrl: runtime.openclawUrl,
233
+ openclawModel: runtime.openclawModel,
234
+ topic: runtime.topic ?? null,
235
+ configPath: configPath ? path.resolve(configPath) : null,
236
+ profile: configPath ? null : (getActiveProfile() ?? null),
237
+ stdoutLog: paths.stdoutLog,
238
+ stderrLog: paths.stderrLog,
239
+ };
240
+ fs.writeFileSync(paths.stateFile, JSON.stringify(state, null, 2), { mode: 0o600 });
241
+ return toStatus(paths, state, true);
242
+ }
243
+ finally {
244
+ if (stdoutFd !== null)
245
+ fs.closeSync(stdoutFd);
246
+ if (stderrFd !== null)
247
+ fs.closeSync(stderrFd);
248
+ }
249
+ }
250
+ export async function runStatusSyncForeground(options = {}) {
251
+ const runtime = resolveStatusSyncRuntime(options);
252
+ const command = buildStatusSyncChildArgs(runtime);
253
+ return await new Promise((resolve, reject) => {
254
+ const child = spawn(process.execPath, command, {
255
+ stdio: 'inherit',
256
+ windowsHide: true,
257
+ env: { ...process.env, OPENCLAW_TOKEN: runtime.openclawToken },
258
+ });
259
+ child.once('error', reject);
260
+ child.once('exit', (code, signal) => {
261
+ if (signal) {
262
+ resolve(1);
263
+ return;
264
+ }
265
+ resolve(code ?? 0);
266
+ });
267
+ });
268
+ }
@@ -1,8 +1,18 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { getAuditLog } from './flags.js';
4
- /** Bump when breaking changes to the audit line shape land. */
5
- export const AUDIT_VERSION = 1;
4
+ /**
5
+ * Bump when breaking changes to the audit line shape land.
6
+ *
7
+ * History:
8
+ * 1 — initial command audit (kind: 'command' only).
9
+ * 2 — adds rule-engine kinds ('rule-fire', 'rule-fire-dry',
10
+ * 'rule-throttled', 'rule-webhook-rejected') and a sibling `rule`
11
+ * block describing which rule fired and why. Reader stays backwards
12
+ * compatible: v1 lines parse as command entries with `rule`
13
+ * undefined.
14
+ */
15
+ export const AUDIT_VERSION = 2;
6
16
  function resolveAuditPath() {
7
17
  const flag = getAuditLog();
8
18
  if (flag === null)
@@ -0,0 +1,54 @@
1
+ import { IDENTITY } from '../commands/identity.js';
2
+ export function commandToJson(cmd, opts = {}) {
3
+ const args = cmd.registeredArguments.map((a) => ({
4
+ name: a.name(),
5
+ required: a.required,
6
+ variadic: a.variadic,
7
+ description: a.description ?? '',
8
+ }));
9
+ const options = cmd.options
10
+ .filter((o) => o.long !== '--help' && o.long !== '--version')
11
+ .map((o) => {
12
+ const entry = { flags: o.flags, description: o.description ?? '' };
13
+ if (o.defaultValue !== undefined)
14
+ entry.defaultValue = o.defaultValue;
15
+ if (o.argChoices && o.argChoices.length > 0)
16
+ entry.choices = o.argChoices;
17
+ return entry;
18
+ });
19
+ const subcommands = cmd.commands
20
+ .filter((c) => !c.name().startsWith('_'))
21
+ .map((c) => ({ name: c.name(), description: c.description() }));
22
+ const out = {
23
+ name: cmd.name(),
24
+ description: cmd.description(),
25
+ arguments: args,
26
+ options,
27
+ subcommands,
28
+ };
29
+ if (opts.includeIdentity) {
30
+ out.product = IDENTITY.product;
31
+ out.domain = IDENTITY.domain;
32
+ out.vendor = IDENTITY.vendor;
33
+ out.apiVersion = IDENTITY.apiVersion;
34
+ out.apiDocs = IDENTITY.apiDocs;
35
+ out.productCategories = IDENTITY.productCategories;
36
+ }
37
+ return out;
38
+ }
39
+ /** Walk argv tokens (skipping flags) to find the deepest matching subcommand. */
40
+ export function resolveTargetCommand(root, argv) {
41
+ let cmd = root;
42
+ for (const token of argv) {
43
+ if (token.startsWith('-'))
44
+ continue;
45
+ const sub = cmd.commands.find((c) => c.name() === token || c.aliases().includes(token));
46
+ if (sub) {
47
+ cmd = sub;
48
+ }
49
+ else {
50
+ break;
51
+ }
52
+ }
53
+ return cmd;
54
+ }
@@ -28,6 +28,23 @@ export function emitJsonError(errorPayload) {
28
28
  console.error(chalk.red(msg));
29
29
  }
30
30
  }
31
+ /**
32
+ * P7: emit the stream-header first line for any NDJSON/streaming command
33
+ * running under `--json`. Downstream JSON consumers can key on
34
+ * `{ stream: true }` to distinguish the header from subsequent event
35
+ * lines, and on `eventKind` / `cadence` to pick a parser strategy.
36
+ *
37
+ * Non-streaming commands (single-object / array output) do NOT emit this
38
+ * header — only watch / events tail / events mqtt-tail.
39
+ */
40
+ export function emitStreamHeader(opts) {
41
+ console.log(JSON.stringify({
42
+ schemaVersion: '1',
43
+ stream: true,
44
+ eventKind: opts.eventKind,
45
+ cadence: opts.cadence,
46
+ }));
47
+ }
31
48
  export function exitWithError(messageOrOpts) {
32
49
  const opts = typeof messageOrOpts === 'string' ? { message: messageOrOpts } : messageOrOpts;
33
50
  const { message, kind = 'usage', code = 2, hint, context, extra } = opts;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchbot/openapi-cli",
3
- "version": "2.6.4",
3
+ "version": "3.0.0",
4
4
  "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
5
5
  "keywords": [
6
6
  "switchbot",
@@ -36,10 +36,12 @@
36
36
  "access": "public"
37
37
  },
38
38
  "scripts": {
39
- "build": "tsc",
40
- "build:prod": "tsc -p tsconfig.build.json",
39
+ "build": "tsc && node scripts/copy-assets.mjs",
40
+ "build:prod": "tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
41
41
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
42
42
  "dev": "tsx src/index.ts",
43
+ "lint:md": "markdownlint \"**/*.md\"",
44
+ "lint:md:changelog": "markdownlint CHANGELOG.md",
43
45
  "start": "node dist/index.js",
44
46
  "test": "vitest run",
45
47
  "test:watch": "vitest",
@@ -48,20 +50,26 @@
48
50
  },
49
51
  "dependencies": {
50
52
  "@modelcontextprotocol/sdk": "^1.29.0",
53
+ "ajv": "^8.18.0",
54
+ "ajv-formats": "^3.0.1",
51
55
  "axios": "^1.7.9",
52
56
  "chalk": "^5.4.1",
53
57
  "cli-table3": "^0.6.5",
54
58
  "commander": "^12.1.0",
59
+ "croner": "^10.0.1",
55
60
  "js-yaml": "^4.1.1",
56
61
  "mqtt": "^5.3.0",
57
62
  "pino": "^9.0.0",
58
- "uuid": "^11.0.5"
63
+ "uuid": "^11.0.5",
64
+ "yaml": "^2.8.3",
65
+ "zod": "^4.3.6"
59
66
  },
60
67
  "devDependencies": {
61
68
  "@types/js-yaml": "^4.0.9",
62
69
  "@types/node": "^22.10.7",
63
70
  "@types/uuid": "^10.0.0",
64
71
  "@vitest/coverage-v8": "^2.1.9",
72
+ "markdownlint-cli": "^0.48.0",
65
73
  "tsx": "^4.19.2",
66
74
  "typescript": "^5.7.3",
67
75
  "vitest": "^2.1.9"