@switchbot/openapi-cli 2.7.2 → 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 (55) hide show
  1. package/README.md +383 -101
  2. package/dist/commands/agent-bootstrap.js +47 -2
  3. package/dist/commands/auth.js +354 -0
  4. package/dist/commands/config.js +30 -0
  5. package/dist/commands/devices.js +0 -1
  6. package/dist/commands/doctor.js +184 -7
  7. package/dist/commands/events.js +3 -3
  8. package/dist/commands/explain.js +1 -2
  9. package/dist/commands/install.js +246 -0
  10. package/dist/commands/mcp.js +796 -3
  11. package/dist/commands/plan.js +110 -14
  12. package/dist/commands/policy.js +469 -0
  13. package/dist/commands/rules.js +657 -0
  14. package/dist/commands/schema.js +0 -2
  15. package/dist/commands/status-sync.js +131 -0
  16. package/dist/commands/uninstall.js +237 -0
  17. package/dist/config.js +14 -0
  18. package/dist/credentials/backends/file.js +101 -0
  19. package/dist/credentials/backends/linux.js +129 -0
  20. package/dist/credentials/backends/macos.js +129 -0
  21. package/dist/credentials/backends/windows.js +215 -0
  22. package/dist/credentials/keychain.js +88 -0
  23. package/dist/credentials/prime.js +52 -0
  24. package/dist/devices/catalog.js +4 -10
  25. package/dist/index.js +23 -1
  26. package/dist/install/default-steps.js +257 -0
  27. package/dist/install/preflight.js +212 -0
  28. package/dist/install/steps.js +67 -0
  29. package/dist/lib/command-keywords.js +17 -0
  30. package/dist/lib/devices.js +0 -1
  31. package/dist/policy/add-rule.js +124 -0
  32. package/dist/policy/diff.js +91 -0
  33. package/dist/policy/examples/policy.example.yaml +99 -0
  34. package/dist/policy/format.js +57 -0
  35. package/dist/policy/load.js +61 -0
  36. package/dist/policy/migrate.js +67 -0
  37. package/dist/policy/schema/v0.2.json +302 -0
  38. package/dist/policy/schema.js +18 -0
  39. package/dist/policy/validate.js +262 -0
  40. package/dist/rules/action.js +205 -0
  41. package/dist/rules/audit-query.js +89 -0
  42. package/dist/rules/cron-scheduler.js +186 -0
  43. package/dist/rules/destructive.js +52 -0
  44. package/dist/rules/engine.js +567 -0
  45. package/dist/rules/matcher.js +230 -0
  46. package/dist/rules/pid-file.js +95 -0
  47. package/dist/rules/quiet-hours.js +45 -0
  48. package/dist/rules/suggest.js +95 -0
  49. package/dist/rules/throttle.js +78 -0
  50. package/dist/rules/types.js +34 -0
  51. package/dist/rules/webhook-listener.js +223 -0
  52. package/dist/rules/webhook-token.js +90 -0
  53. package/dist/status-sync/manager.js +268 -0
  54. package/dist/utils/audit.js +12 -2
  55. package/package.json +12 -4
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Default install steps used by `switchbot install` (Phase 3B in-repo).
3
+ *
4
+ * Each factory returns an `InstallStep<InstallContext>` whose `execute`
5
+ * and `undo` both operate on the shared context. Steps are intentionally
6
+ * small — each one either mutates one system (keychain / filesystem /
7
+ * symlink) or captures input, never a mix. The orchestrator composes
8
+ * them in `src/commands/install.ts`.
9
+ *
10
+ * The step runner (`src/install/steps.ts`) handles rollback on failure;
11
+ * these factories just make sure every `execute` records what it needs
12
+ * into the context so the matching `undo` can unwind it.
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import os from 'node:os';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { scaffoldPolicyFile, PolicyFileExistsError, } from '../commands/policy.js';
19
+ import { promptTokenAndSecret, readCredentialsFile } from '../commands/config.js';
20
+ import { selectCredentialStore } from '../credentials/keychain.js';
21
+ // ---------------------------------------------------------------------------
22
+ // Step 1: capture credentials (memory only — no side effects until step 2)
23
+ // ---------------------------------------------------------------------------
24
+ export function stepPromptCredentials() {
25
+ return {
26
+ name: 'prompt-credentials',
27
+ description: 'Collect SwitchBot token + secret (interactive unless --token-file)',
28
+ async execute(ctx) {
29
+ if (ctx.credentials)
30
+ return; // already provided via API consumer
31
+ if (ctx.tokenFile) {
32
+ const creds = readCredentialsFile(ctx.tokenFile);
33
+ ctx.credentials = creds;
34
+ return;
35
+ }
36
+ if (ctx.nonInteractive) {
37
+ throw new Error('no --token-file and stdin is not a TTY; pass --token-file <path> to install non-interactively');
38
+ }
39
+ ctx.credentials = await promptTokenAndSecret();
40
+ },
41
+ undo() {
42
+ // No disk state created; clearing memory is enough.
43
+ // The calling process will exit shortly after rollback, but null
44
+ // the field for defence-in-depth.
45
+ return;
46
+ },
47
+ };
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Step 2: write credentials to keychain (or file fallback)
51
+ // ---------------------------------------------------------------------------
52
+ export function stepWriteKeychain() {
53
+ return {
54
+ name: 'write-keychain',
55
+ description: 'Store credentials in the OS keychain (falls back to ~/.switchbot/config.json)',
56
+ async execute(ctx) {
57
+ if (!ctx.credentials) {
58
+ throw new Error('internal: credentials missing at write-keychain; prompt step must run first');
59
+ }
60
+ const store = await selectCredentialStore();
61
+ const previous = await store.get(ctx.profile);
62
+ ctx.previousCredentials = previous;
63
+ await store.set(ctx.profile, ctx.credentials);
64
+ ctx.credentialStore = store;
65
+ ctx.credentialsWereStored = true;
66
+ },
67
+ async undo(ctx) {
68
+ if (!ctx.credentialsWereStored || !ctx.credentialStore)
69
+ return;
70
+ try {
71
+ if (ctx.previousCredentials) {
72
+ await ctx.credentialStore.set(ctx.profile, ctx.previousCredentials);
73
+ }
74
+ else {
75
+ await ctx.credentialStore.delete(ctx.profile);
76
+ }
77
+ }
78
+ finally {
79
+ ctx.credentialsWereStored = false;
80
+ ctx.previousCredentials = undefined;
81
+ }
82
+ },
83
+ };
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Step 3: scaffold policy.yaml if missing (skip if present, don't clobber)
87
+ // ---------------------------------------------------------------------------
88
+ export function stepScaffoldPolicy() {
89
+ return {
90
+ name: 'scaffold-policy',
91
+ description: 'Create a starter policy.yaml (only if none exists)',
92
+ execute(ctx) {
93
+ try {
94
+ const result = scaffoldPolicyFile(ctx.policyPath, { skipExisting: true });
95
+ ctx.policyScaffoldResult = result;
96
+ }
97
+ catch (err) {
98
+ if (err instanceof PolicyFileExistsError) {
99
+ // skipExisting is true → this branch is unreachable, but be
100
+ // defensive against future changes.
101
+ return;
102
+ }
103
+ throw err;
104
+ }
105
+ },
106
+ undo(ctx) {
107
+ const r = ctx.policyScaffoldResult;
108
+ if (!r || r.skipped)
109
+ return;
110
+ // Only remove the file if WE created it (skipped === false means
111
+ // we wrote fresh content to a path that did not exist before).
112
+ try {
113
+ fs.unlinkSync(r.policyPath);
114
+ }
115
+ catch {
116
+ // best-effort; do not fail rollback on cleanup
117
+ }
118
+ },
119
+ };
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Step 4: install skill into the agent's skills directory
123
+ // ---------------------------------------------------------------------------
124
+ /**
125
+ * Compute the on-disk location where an agent expects to find this skill.
126
+ * Only `claude-code` has an automation path today; others are informational
127
+ * (the installer will print a recipe instead of creating anything).
128
+ */
129
+ export function skillLinkPathFor(agent, home = os.homedir()) {
130
+ if (agent === 'claude-code') {
131
+ return path.join(home, '.claude', 'skills', 'switchbot');
132
+ }
133
+ return null;
134
+ }
135
+ export function stepSymlinkSkill(opts = {}) {
136
+ return {
137
+ name: 'symlink-skill',
138
+ description: 'Link the skill into ~/.claude/skills/switchbot (Claude Code)',
139
+ execute(ctx) {
140
+ if (ctx.agent === 'none')
141
+ return;
142
+ if (!ctx.skillPath) {
143
+ // Informational path: print the recipe, do not fail. Undo can
144
+ // safely no-op in this branch.
145
+ ctx.skillRecipePrinted = true;
146
+ return;
147
+ }
148
+ const target = path.resolve(ctx.skillPath);
149
+ if (!fs.existsSync(target)) {
150
+ throw new Error(`--skill-path does not exist: ${target}`);
151
+ }
152
+ const stat = fs.statSync(target);
153
+ if (!stat.isDirectory()) {
154
+ throw new Error(`--skill-path is not a directory: ${target}`);
155
+ }
156
+ const linkPath = skillLinkPathFor(ctx.agent);
157
+ if (!linkPath) {
158
+ // Non-automating agent: print a recipe instead of creating state.
159
+ ctx.skillRecipePrinted = true;
160
+ return;
161
+ }
162
+ // A2: require a SKILL.md only when we are about to create a link.
163
+ // Non-automating agents (cursor/copilot) print a recipe and return
164
+ // above, so they are never blocked by this check.
165
+ if (!opts.force && !fs.existsSync(path.join(target, 'SKILL.md'))) {
166
+ throw new Error(`${target} does not look like a skill (no SKILL.md at the root). ` +
167
+ 'Pass --force if you really mean to link this directory.');
168
+ }
169
+ if (fs.existsSync(linkPath)) {
170
+ const st = fs.lstatSync(linkPath);
171
+ if (st.isSymbolicLink()) {
172
+ // A3: tolerate an existing link only when it points at the same
173
+ // target; otherwise the user is likely trying to repoint and we
174
+ // should not silently pretend success. --force replaces it.
175
+ let existingTarget = null;
176
+ try {
177
+ existingTarget = path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath));
178
+ }
179
+ catch {
180
+ existingTarget = null;
181
+ }
182
+ if (existingTarget === target) {
183
+ ctx.skillLinkPath = linkPath;
184
+ ctx.skillLinkCreated = false;
185
+ return;
186
+ }
187
+ if (!opts.force) {
188
+ throw new Error(`${linkPath} already links to ${existingTarget ?? '(unreadable)'}; ` +
189
+ 'pass --force to replace it, or run `switchbot uninstall` first.');
190
+ }
191
+ fs.unlinkSync(linkPath);
192
+ }
193
+ else {
194
+ throw new Error(`${linkPath} exists and is not a symlink; refusing to clobber (move it aside and re-run)`);
195
+ }
196
+ }
197
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
198
+ // Windows: regular symlinks require admin or Developer Mode. A
199
+ // directory junction works for any user and is transparent to
200
+ // most tools. Unix: plain symlink.
201
+ const linkType = process.platform === 'win32' ? 'junction' : 'dir';
202
+ fs.symlinkSync(target, linkPath, linkType);
203
+ ctx.skillLinkPath = linkPath;
204
+ ctx.skillLinkCreated = true;
205
+ },
206
+ undo(ctx) {
207
+ if (!ctx.skillLinkCreated || !ctx.skillLinkPath)
208
+ return;
209
+ try {
210
+ fs.unlinkSync(ctx.skillLinkPath);
211
+ }
212
+ catch {
213
+ // best-effort
214
+ }
215
+ },
216
+ };
217
+ }
218
+ function defaultDoctorSpawner(cliPath, profile) {
219
+ const args = profile === 'default' ? [cliPath, 'doctor', '--json'] : [cliPath, '--profile', profile, 'doctor', '--json'];
220
+ const r = spawnSync(process.execPath, args, { encoding: 'utf-8' });
221
+ return {
222
+ ok: r.status === 0,
223
+ exitCode: r.status,
224
+ stdout: r.stdout ?? '',
225
+ stderr: r.stderr ?? '',
226
+ };
227
+ }
228
+ export function stepDoctorVerify(opts = { cliPath: '' }) {
229
+ const spawner = opts.spawner ?? defaultDoctorSpawner;
230
+ const cliPath = opts.cliPath;
231
+ return {
232
+ name: 'doctor-verify',
233
+ description: 'Verify the install with switchbot doctor --json',
234
+ execute(ctx) {
235
+ if (!cliPath) {
236
+ // Fail closed: without a known CLI path we cannot spawn doctor.
237
+ // Mark not-ok but still succeed (no rollback).
238
+ ctx.doctorOk = false;
239
+ ctx.doctorReport = { skipped: true, reason: 'no cliPath provided' };
240
+ return;
241
+ }
242
+ const r = spawner(cliPath, ctx.profile);
243
+ ctx.doctorOk = r.ok;
244
+ try {
245
+ ctx.doctorReport = r.stdout ? JSON.parse(r.stdout) : { exitCode: r.exitCode, stderr: r.stderr };
246
+ }
247
+ catch {
248
+ ctx.doctorReport = { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
249
+ }
250
+ // NOTE: never throw here. Doctor failure is reported; rollback is
251
+ // opt-in by the user via `switchbot uninstall`.
252
+ },
253
+ undo() {
254
+ return;
255
+ },
256
+ };
257
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Install-orchestrator pre-flight (Phase 3A · F5).
3
+ *
4
+ * Pure library — no CLI entry. Consumers (e.g. a future
5
+ * `openclaw plugins install` command) call `runPreflight()` and decide
6
+ * whether to proceed based on the returned result. Nothing here mutates
7
+ * user state: every check is read-only.
8
+ *
9
+ * The check list mirrors `docs/design/phase3-install.md` step 1 minus
10
+ * the bits that require external services (npm registry / SwitchBot API
11
+ * reachability are left for the installer itself to probe when it has
12
+ * a plan to retry, since they are the flakiest of the lot).
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import os from 'node:os';
17
+ import { resolvePolicyPath, loadPolicyFile, PolicyFileNotFoundError } from '../policy/load.js';
18
+ import { validateLoadedPolicy } from '../policy/validate.js';
19
+ import { selectCredentialStore } from '../credentials/keychain.js';
20
+ function parseMajor(version) {
21
+ const m = /^v?(\d+)\./.exec(version);
22
+ if (!m)
23
+ return null;
24
+ const n = Number(m[1]);
25
+ return Number.isFinite(n) ? n : null;
26
+ }
27
+ function checkNodeVersion(opts) {
28
+ const required = opts.minNodeMajor ?? 18;
29
+ const version = opts.nodeVersion ?? process.version;
30
+ const major = parseMajor(version);
31
+ if (major === null) {
32
+ return {
33
+ name: 'node',
34
+ status: 'fail',
35
+ message: `unrecognised Node.js version string: ${version}`,
36
+ hint: 'reinstall Node.js from https://nodejs.org',
37
+ };
38
+ }
39
+ if (major < required) {
40
+ return {
41
+ name: 'node',
42
+ status: 'fail',
43
+ message: `Node.js ${version} < required v${required}`,
44
+ hint: `upgrade Node.js to v${required} or later`,
45
+ };
46
+ }
47
+ return { name: 'node', status: 'ok', message: `Node.js ${version}` };
48
+ }
49
+ function checkPolicy() {
50
+ const policyPath = resolvePolicyPath();
51
+ try {
52
+ const loaded = loadPolicyFile(policyPath);
53
+ const result = validateLoadedPolicy(loaded);
54
+ if (result.valid) {
55
+ return {
56
+ name: 'policy',
57
+ status: 'ok',
58
+ message: `policy at ${policyPath} validates (v${result.schemaVersion ?? '?'})`,
59
+ };
60
+ }
61
+ return {
62
+ name: 'policy',
63
+ status: 'warn',
64
+ message: `policy at ${policyPath} has ${result.errors.length} validation error(s)`,
65
+ hint: 'run "switchbot policy validate" to see details before installing',
66
+ };
67
+ }
68
+ catch (err) {
69
+ if (err instanceof PolicyFileNotFoundError) {
70
+ return {
71
+ name: 'policy',
72
+ status: 'ok',
73
+ message: `no policy at ${policyPath} (installer will scaffold one)`,
74
+ };
75
+ }
76
+ return {
77
+ name: 'policy',
78
+ status: 'warn',
79
+ message: `policy at ${policyPath} is unreadable: ${err instanceof Error ? err.message : String(err)}`,
80
+ hint: 'move the file aside, then re-run — the installer will scaffold a fresh copy',
81
+ };
82
+ }
83
+ }
84
+ async function checkKeychain() {
85
+ try {
86
+ const store = await selectCredentialStore();
87
+ const desc = store.describe();
88
+ if (desc.writable) {
89
+ return {
90
+ name: 'keychain',
91
+ status: 'ok',
92
+ message: `credential backend: ${desc.backend}`,
93
+ };
94
+ }
95
+ return {
96
+ name: 'keychain',
97
+ status: 'warn',
98
+ message: `credential backend ${desc.backend} is not writable — will fall back to file`,
99
+ hint: desc.notes ?? 'install the OS keychain helper to get native credential storage',
100
+ };
101
+ }
102
+ catch (err) {
103
+ return {
104
+ name: 'keychain',
105
+ status: 'warn',
106
+ message: `keychain probe failed: ${err instanceof Error ? err.message : String(err)}`,
107
+ hint: 'the installer will fall back to the file backend',
108
+ };
109
+ }
110
+ }
111
+ function checkHomeDirWritable() {
112
+ const home = os.homedir();
113
+ const switchbotDir = path.join(home, '.switchbot');
114
+ try {
115
+ const homeStat = fs.statSync(home);
116
+ if (!homeStat.isDirectory()) {
117
+ return {
118
+ name: 'home',
119
+ status: 'fail',
120
+ message: `home path is not a directory: ${home}`,
121
+ hint: 'check your HOME/USERPROFILE environment configuration',
122
+ };
123
+ }
124
+ if (fs.existsSync(switchbotDir)) {
125
+ const sbStat = fs.statSync(switchbotDir);
126
+ if (!sbStat.isDirectory()) {
127
+ return {
128
+ name: 'home',
129
+ status: 'fail',
130
+ message: `${switchbotDir} exists but is not a directory`,
131
+ hint: 'move the file aside and re-run install',
132
+ };
133
+ }
134
+ fs.accessSync(switchbotDir, fs.constants.W_OK);
135
+ return { name: 'home', status: 'ok', message: `writable: ${switchbotDir}` };
136
+ }
137
+ fs.accessSync(home, fs.constants.W_OK);
138
+ return { name: 'home', status: 'ok', message: `writable: ${home}` };
139
+ }
140
+ catch (err) {
141
+ return {
142
+ name: 'home',
143
+ status: 'fail',
144
+ message: `cannot write under ${home}: ${err instanceof Error ? err.message : String(err)}`,
145
+ hint: 'check ownership and permissions on your home directory',
146
+ };
147
+ }
148
+ }
149
+ function nearestExistingPath(target) {
150
+ let cur = target;
151
+ while (true) {
152
+ if (fs.existsSync(cur))
153
+ return cur;
154
+ const parent = path.dirname(cur);
155
+ if (parent === cur)
156
+ return null;
157
+ cur = parent;
158
+ }
159
+ }
160
+ function checkAgentSkillDirWritable(opts) {
161
+ const shouldCheck = opts.agent === 'claude-code' && (opts.expectSkillLink ?? true);
162
+ if (!shouldCheck)
163
+ return null;
164
+ const home = os.homedir();
165
+ const target = path.join(home, '.claude', 'skills');
166
+ try {
167
+ const existing = nearestExistingPath(target);
168
+ if (!existing) {
169
+ return {
170
+ name: 'agent-skills-dir',
171
+ status: 'fail',
172
+ message: `cannot resolve an existing parent for ${target}`,
173
+ hint: 'check your home directory path and permissions',
174
+ };
175
+ }
176
+ const stat = fs.statSync(existing);
177
+ if (!stat.isDirectory()) {
178
+ return {
179
+ name: 'agent-skills-dir',
180
+ status: 'fail',
181
+ message: `path component is not a directory: ${existing}`,
182
+ hint: 'move the blocking file aside and re-run install',
183
+ };
184
+ }
185
+ fs.accessSync(existing, fs.constants.W_OK);
186
+ return { name: 'agent-skills-dir', status: 'ok', message: `writable: ${target}` };
187
+ }
188
+ catch (err) {
189
+ return {
190
+ name: 'agent-skills-dir',
191
+ status: 'fail',
192
+ message: `cannot write to ${target}: ${err instanceof Error ? err.message : String(err)}`,
193
+ hint: 'open Claude Code once (it will create ~/.claude) or create the directory manually',
194
+ };
195
+ }
196
+ }
197
+ /**
198
+ * Run every pre-flight check and return a combined result. Safe to
199
+ * call multiple times; no state is cached.
200
+ */
201
+ export async function runPreflight(options = {}) {
202
+ const checks = [];
203
+ checks.push(checkNodeVersion(options));
204
+ checks.push(checkPolicy());
205
+ checks.push(await checkKeychain());
206
+ checks.push(checkHomeDirWritable());
207
+ const agentCheck = checkAgentSkillDirWritable(options);
208
+ if (agentCheck)
209
+ checks.push(agentCheck);
210
+ const ok = checks.every((c) => c.status !== 'fail');
211
+ return { checks, ok };
212
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Install-orchestrator step runner (Phase 3A · F5).
3
+ *
4
+ * Each step has a deterministic `execute` and a matching `undo`. The
5
+ * runner executes steps in order; on any failure it walks the
6
+ * already-completed steps in reverse and invokes their `undo`. If an
7
+ * `undo` itself fails, the error is captured and surfaced — the
8
+ * runner does NOT abort the rollback. The caller gets a full report
9
+ * and can decide how to surface partial cleanup failures.
10
+ *
11
+ * The module is intentionally agnostic of what steps do; consumers
12
+ * (future `openclaw plugins install`) plug in concrete steps like
13
+ * "npm i -g the CLI" or "write the credential to the keychain".
14
+ */
15
+ /**
16
+ * Run the given steps in order. On the first failure, the runner
17
+ * walks already-executed steps in reverse and invokes each step's
18
+ * undo. Returns a report describing every step's fate.
19
+ */
20
+ export async function runInstall(steps, options = {}) {
21
+ const ctx = (options.context ?? {});
22
+ const outcomes = [];
23
+ const executed = [];
24
+ let failedAt;
25
+ for (const step of steps) {
26
+ try {
27
+ await step.execute(ctx);
28
+ outcomes.push({ step: step.name, status: 'succeeded' });
29
+ executed.push(step);
30
+ }
31
+ catch (err) {
32
+ outcomes.push({
33
+ step: step.name,
34
+ status: 'failed',
35
+ error: err instanceof Error ? err.message : String(err),
36
+ });
37
+ failedAt = step.name;
38
+ break;
39
+ }
40
+ if (options.stopAfter === step.name)
41
+ break;
42
+ }
43
+ if (failedAt !== undefined) {
44
+ // Roll back completed steps in reverse. Undo failures are captured
45
+ // but do not abort further rollback attempts — the goal is to
46
+ // leave as little residue as possible.
47
+ for (let i = executed.length - 1; i >= 0; i--) {
48
+ const step = executed[i];
49
+ try {
50
+ await step.undo(ctx);
51
+ outcomes.push({ step: step.name, status: 'rolled-back' });
52
+ }
53
+ catch (err) {
54
+ outcomes.push({
55
+ step: step.name,
56
+ status: 'rollback-failed',
57
+ error: err instanceof Error ? err.message : String(err),
58
+ });
59
+ }
60
+ }
61
+ }
62
+ return {
63
+ ok: failedAt === undefined,
64
+ outcomes,
65
+ ...(failedAt !== undefined ? { failedAt } : {}),
66
+ };
67
+ }
@@ -0,0 +1,17 @@
1
+ export const COMMAND_KEYWORDS = [
2
+ { pattern: /\boff\b|\bturn.?off\b|\bstop\b/i, command: 'turnOff' },
3
+ { pattern: /\bon\b|\bturn.?on\b|\bstart\b/i, command: 'turnOn' },
4
+ { pattern: /\bpress\b|\bclick\b|\btap\b/i, command: 'press' },
5
+ { pattern: /\block\b/i, command: 'lock' },
6
+ { pattern: /\bunlock\b/i, command: 'unlock' },
7
+ { pattern: /\bopen\b|\braise\b|\bup\b/i, command: 'open' },
8
+ { pattern: /\bclose\b|\blower\b|\bdown\b/i, command: 'close' },
9
+ { pattern: /\bpause\b/i, command: 'pause' },
10
+ ];
11
+ export function inferCommandFromIntent(intent) {
12
+ for (const k of COMMAND_KEYWORDS) {
13
+ if (k.pattern.test(intent))
14
+ return k.command;
15
+ }
16
+ return undefined;
17
+ }
@@ -272,7 +272,6 @@ export async function describeDevice(deviceId, options = {}, client) {
272
272
  return {
273
273
  ...c,
274
274
  safetyTier: tier,
275
- destructive: tier === 'destructive',
276
275
  ...(reason ? { safetyReason: reason } : {}),
277
276
  };
278
277
  }),
@@ -0,0 +1,124 @@
1
+ import { parseDocument, isMap, isSeq, isScalar, LineCounter } from 'yaml';
2
+ import { parse as yamlParse } from 'yaml';
3
+ import { loadPolicyFile } from './load.js';
4
+ import { validateLoadedPolicy } from './validate.js';
5
+ import fs from 'node:fs';
6
+ export class AddRuleError extends Error {
7
+ code;
8
+ constructor(message, code) {
9
+ super(message);
10
+ this.code = code;
11
+ this.name = 'AddRuleError';
12
+ }
13
+ }
14
+ function buildDiff(before, after) {
15
+ const beforeLines = before.split('\n');
16
+ const afterLines = after.split('\n');
17
+ const lines = ['--- before', '+++ after'];
18
+ let i = 0;
19
+ let j = 0;
20
+ while (i < beforeLines.length || j < afterLines.length) {
21
+ const b = beforeLines[i];
22
+ const a = afterLines[j];
23
+ if (i < beforeLines.length && j < afterLines.length && b === a) {
24
+ lines.push(` ${b}`);
25
+ i++;
26
+ j++;
27
+ }
28
+ else if (j < afterLines.length && (i >= beforeLines.length || b !== a)) {
29
+ lines.push(`+${a}`);
30
+ j++;
31
+ }
32
+ else {
33
+ lines.push(`-${b}`);
34
+ i++;
35
+ }
36
+ }
37
+ return lines.join('\n');
38
+ }
39
+ function isNullNode(node) {
40
+ return isScalar(node) && node.value === null;
41
+ }
42
+ export function addRuleToPolicySource(opts) {
43
+ const loaded = loadPolicyFile(opts.policyPath);
44
+ const beforeSource = loaded.source;
45
+ // Parse the incoming rule
46
+ let ruleObj;
47
+ try {
48
+ ruleObj = yamlParse(opts.ruleYaml);
49
+ }
50
+ catch (err) {
51
+ throw new AddRuleError(`Could not parse rule YAML: ${err.message}`, 'invalid-rule-yaml');
52
+ }
53
+ if (!ruleObj || typeof ruleObj !== 'object' || Array.isArray(ruleObj)) {
54
+ throw new AddRuleError('Rule YAML must be a single mapping object', 'invalid-rule-shape');
55
+ }
56
+ const ruleName = ruleObj['name'];
57
+ if (typeof ruleName !== 'string' || !ruleName) {
58
+ throw new AddRuleError('Rule must have a non-empty "name" field', 'missing-rule-name');
59
+ }
60
+ // Clone the document using source round-trip (preserves comments)
61
+ const clone = parseDocument(beforeSource, { keepSourceTokens: true });
62
+ if (!isMap(clone.contents)) {
63
+ throw new AddRuleError('Policy root must be a YAML mapping', 'invalid-policy-shape');
64
+ }
65
+ // Ensure automation block exists
66
+ let automationNode = clone.contents.get('automation', true);
67
+ if (!automationNode || isNullNode(automationNode)) {
68
+ clone.setIn(['automation'], clone.createNode({ enabled: false, rules: [] }));
69
+ automationNode = clone.contents.get('automation', true);
70
+ }
71
+ // Ensure automation.rules exists and is a sequence
72
+ const rulesNode = clone.getIn(['automation', 'rules'], true);
73
+ if (!rulesNode || isNullNode(rulesNode)) {
74
+ clone.setIn(['automation', 'rules'], clone.createNode([]));
75
+ }
76
+ else if (!isSeq(rulesNode)) {
77
+ throw new AddRuleError('automation.rules exists but is not a sequence; cannot append', 'invalid-rules-shape');
78
+ }
79
+ // Duplicate name check — use JS conversion for simplicity
80
+ const policyJs = clone.toJS({ maxAliasCount: 100 });
81
+ const existingRulesJs = policyJs['automation']?.['rules'];
82
+ const existingRulesArr = Array.isArray(existingRulesJs) ? existingRulesJs : [];
83
+ const duplicateIdx = existingRulesArr.findIndex((r) => r?.['name'] === ruleName);
84
+ if (duplicateIdx !== -1 && !opts.force) {
85
+ throw new AddRuleError(`Rule named "${ruleName}" already exists. Use --force to overwrite.`, 'duplicate-rule-name');
86
+ }
87
+ if (duplicateIdx !== -1 && opts.force) {
88
+ const rulesSeq = clone.getIn(['automation', 'rules'], true);
89
+ rulesSeq.items.splice(duplicateIdx, 1);
90
+ }
91
+ // Enable automation if requested
92
+ if (opts.enableAutomation) {
93
+ clone.setIn(['automation', 'enabled'], true);
94
+ }
95
+ // Append the rule
96
+ const ruleNode = clone.createNode(ruleObj);
97
+ const rulesSeq = clone.getIn(['automation', 'rules'], true);
98
+ rulesSeq.items.push(ruleNode);
99
+ const nextSource = String(clone);
100
+ // Validate the resulting policy
101
+ const reLC = new LineCounter();
102
+ const reDoc = parseDocument(nextSource, { lineCounter: reLC, keepSourceTokens: true });
103
+ const validation = validateLoadedPolicy({
104
+ path: opts.policyPath,
105
+ source: nextSource,
106
+ doc: reDoc,
107
+ lineCounter: reLC,
108
+ data: reDoc.toJS({ maxAliasCount: 100 }),
109
+ });
110
+ if (!validation.valid) {
111
+ const msgs = validation.errors.map((e) => ` line ${e.line}: ${e.message}`).join('\n');
112
+ throw new AddRuleError(`Policy would be invalid after adding the rule:\n${msgs}`, 'validation-failed');
113
+ }
114
+ const diff = buildDiff(beforeSource, nextSource);
115
+ return { ruleName, diff, nextSource };
116
+ }
117
+ export function addRuleToPolicyFile(opts) {
118
+ const result = addRuleToPolicySource(opts);
119
+ if (!opts.dryRun) {
120
+ fs.writeFileSync(opts.policyPath, result.nextSource, 'utf8');
121
+ return { ...result, written: true };
122
+ }
123
+ return { ...result, written: false };
124
+ }