@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
@@ -1,6 +1,7 @@
1
1
  import { enumArg, stringArg } from '../utils/arg-parsers.js';
2
2
  import { printJson } from '../utils/output.js';
3
- import { getEffectiveCatalog } from '../devices/catalog.js';
3
+ import { getEffectiveCatalog, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
4
+ import { RESOURCE_CATALOG } from '../devices/resources.js';
4
5
  import { loadCache } from '../devices/cache.js';
5
6
  function toSchemaEntry(e) {
6
7
  return {
@@ -10,18 +11,21 @@ function toSchemaEntry(e) {
10
11
  aliases: e.aliases ?? [],
11
12
  role: e.role ?? null,
12
13
  readOnly: e.readOnly ?? false,
13
- commands: e.commands.map(toSchemaCommand),
14
+ commands: e.commands.map((c) => toSchemaCommand(c, e)),
14
15
  statusFields: e.statusFields ?? [],
15
16
  };
16
17
  }
17
- function toSchemaCommand(c) {
18
+ function toSchemaCommand(c, entry) {
19
+ const tier = deriveSafetyTier(c, entry);
20
+ const reason = getCommandSafetyReason(c);
18
21
  return {
19
22
  command: c.command,
20
23
  parameter: c.parameter,
21
24
  description: c.description,
22
25
  commandType: (c.commandType ?? 'command'),
23
26
  idempotent: Boolean(c.idempotent),
24
- destructive: Boolean(c.destructive),
27
+ safetyTier: tier,
28
+ ...(reason ? { safetyReason: reason } : {}),
25
29
  ...(c.exampleParams ? { exampleParams: c.exampleParams } : {}),
26
30
  };
27
31
  }
@@ -31,13 +35,16 @@ function toCompactEntry(e) {
31
35
  category: e.category,
32
36
  role: e.role ?? null,
33
37
  readOnly: e.readOnly ?? false,
34
- commands: e.commands.map((c) => ({
35
- command: c.command,
36
- parameter: c.parameter,
37
- commandType: (c.commandType ?? 'command'),
38
- idempotent: Boolean(c.idempotent),
39
- destructive: Boolean(c.destructive),
40
- })),
38
+ commands: e.commands.map((c) => {
39
+ const tier = deriveSafetyTier(c, e);
40
+ return {
41
+ command: c.command,
42
+ parameter: c.parameter,
43
+ commandType: (c.commandType ?? 'command'),
44
+ idempotent: Boolean(c.idempotent),
45
+ safetyTier: tier,
46
+ };
47
+ }),
41
48
  statusFields: e.statusFields ?? [],
42
49
  };
43
50
  }
@@ -54,7 +61,7 @@ export function registerSchemaCommand(program) {
54
61
  const CATEGORIES = ['physical', 'ir'];
55
62
  const schema = program
56
63
  .command('schema')
57
- .description('Export the device catalog as structured JSON (for agent prompts / tooling)');
64
+ .description('Export the SwitchBot device catalog as structured JSON (for AI agent prompts / tooling)');
58
65
  schema
59
66
  .command('export')
60
67
  .description('Print the catalog as structured JSON (one object per type)')
@@ -137,6 +144,7 @@ Examples:
137
144
  };
138
145
  if (!options.compact) {
139
146
  payload.generatedAt = new Date().toISOString();
147
+ payload.resources = RESOURCE_CATALOG;
140
148
  payload.cliAddedFields = [
141
149
  {
142
150
  field: '_fetchedAt',
@@ -0,0 +1,131 @@
1
+ import { stringArg } from '../utils/arg-parsers.js';
2
+ import { handleError, isJsonMode, printJson } from '../utils/output.js';
3
+ import { getStatusSyncStatus, runStatusSyncForeground, startStatusSync, stopStatusSync, } from '../status-sync/manager.js';
4
+ function printHumanStatus(status) {
5
+ if (!status.running) {
6
+ console.log('status-sync is not running');
7
+ console.log(`state: ${status.stateDir}`);
8
+ console.log(`stdout: ${status.stdoutLog}`);
9
+ console.log(`stderr: ${status.stderrLog}`);
10
+ return;
11
+ }
12
+ console.log(`status-sync is running (PID ${status.pid})`);
13
+ console.log(`started: ${status.startedAt}`);
14
+ console.log(`state: ${status.stateDir}`);
15
+ console.log(`stdout: ${status.stdoutLog}`);
16
+ console.log(`stderr: ${status.stderrLog}`);
17
+ }
18
+ export function registerStatusSyncCommand(program) {
19
+ const statusSync = program
20
+ .command('status-sync')
21
+ .description('Manage a background MQTT -> OpenClaw status-sync bridge powered by events mqtt-tail');
22
+ statusSync
23
+ .command('run')
24
+ .description('Run the status-sync bridge in the foreground for a supervisor or terminal session')
25
+ .option('--openclaw-url <url>', 'OpenClaw gateway URL (default: http://localhost:18789)', stringArg('--openclaw-url'))
26
+ .option('--openclaw-token <token>', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)', stringArg('--openclaw-token'))
27
+ .option('--openclaw-model <id>', 'OpenClaw agent model ID to route events to (or env OPENCLAW_MODEL)', stringArg('--openclaw-model'))
28
+ .option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)', stringArg('--topic'))
29
+ .addHelpText('after', `
30
+ Runs the same MQTT -> OpenClaw bridge logic as \'status-sync start\',
31
+ but keeps the process attached to the current terminal. This is the best fit
32
+ for agent supervisors, service managers, or container entrypoints that want
33
+ foreground process semantics.
34
+
35
+ Examples:
36
+ $ switchbot status-sync run --openclaw-model home-agent
37
+ $ OPENCLAW_TOKEN=abc OPENCLAW_MODEL=home-agent switchbot status-sync run
38
+ `)
39
+ .action(async (options) => {
40
+ try {
41
+ const exitCode = await runStatusSyncForeground(options);
42
+ if (exitCode !== 0) {
43
+ process.exit(exitCode);
44
+ }
45
+ }
46
+ catch (error) {
47
+ handleError(error);
48
+ }
49
+ });
50
+ statusSync
51
+ .command('start')
52
+ .description('Start the background status-sync bridge')
53
+ .option('--openclaw-url <url>', 'OpenClaw gateway URL (default: http://localhost:18789)', stringArg('--openclaw-url'))
54
+ .option('--openclaw-token <token>', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)', stringArg('--openclaw-token'))
55
+ .option('--openclaw-model <id>', 'OpenClaw agent model ID to route events to (or env OPENCLAW_MODEL)', stringArg('--openclaw-model'))
56
+ .option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)', stringArg('--topic'))
57
+ .option('--state-dir <path>', 'Override the status-sync state directory (or env SWITCHBOT_STATUS_SYNC_HOME)', stringArg('--state-dir'))
58
+ .option('--force', 'Stop any existing status-sync bridge before starting a new one')
59
+ .addHelpText('after', `
60
+ Starts a detached child process that runs:
61
+ switchbot status-sync run ...
62
+
63
+ State files:
64
+ state.json process metadata (pid, startedAt, command)
65
+ stdout.log redirected stdout from the child process
66
+ stderr.log redirected stderr from the child process
67
+
68
+ Examples:
69
+ $ switchbot status-sync start --openclaw-model home-agent
70
+ $ OPENCLAW_TOKEN=abc OPENCLAW_MODEL=home-agent switchbot status-sync start
71
+ $ switchbot status-sync start --state-dir ~/.switchbot/custom-status-sync --force
72
+ `)
73
+ .action((options) => {
74
+ try {
75
+ const status = startStatusSync(options);
76
+ if (isJsonMode()) {
77
+ printJson(status);
78
+ return;
79
+ }
80
+ console.log(`Started status-sync (PID ${status.pid}).`);
81
+ console.log(`state: ${status.stateDir}`);
82
+ console.log(`stdout: ${status.stdoutLog}`);
83
+ console.log(`stderr: ${status.stderrLog}`);
84
+ }
85
+ catch (error) {
86
+ handleError(error);
87
+ }
88
+ });
89
+ statusSync
90
+ .command('stop')
91
+ .description('Stop the background status-sync bridge')
92
+ .option('--state-dir <path>', 'Override the status-sync state directory (or env SWITCHBOT_STATUS_SYNC_HOME)', stringArg('--state-dir'))
93
+ .action((options) => {
94
+ try {
95
+ const result = stopStatusSync(options);
96
+ if (isJsonMode()) {
97
+ printJson(result);
98
+ return;
99
+ }
100
+ if (result.stopped) {
101
+ console.log(`Stopped status-sync (PID ${result.pid}).`);
102
+ }
103
+ else if (result.stale) {
104
+ console.log(`Removed stale status-sync state for PID ${result.pid}.`);
105
+ }
106
+ else {
107
+ console.log('status-sync is not running');
108
+ }
109
+ }
110
+ catch (error) {
111
+ handleError(error);
112
+ }
113
+ });
114
+ statusSync
115
+ .command('status')
116
+ .description('Inspect the current status-sync bridge state')
117
+ .option('--state-dir <path>', 'Override the status-sync state directory (or env SWITCHBOT_STATUS_SYNC_HOME)', stringArg('--state-dir'))
118
+ .action((options) => {
119
+ try {
120
+ const status = getStatusSyncStatus(options);
121
+ if (isJsonMode()) {
122
+ printJson(status);
123
+ return;
124
+ }
125
+ printHumanStatus(status);
126
+ }
127
+ catch (error) {
128
+ handleError(error);
129
+ }
130
+ });
131
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * `switchbot uninstall` — reverse of `switchbot install`.
3
+ *
4
+ * Unlike install, uninstall is not rollback-safe (there's nothing to
5
+ * roll back to). It removes individual pieces independently and keeps
6
+ * going if any single removal fails — the user gets a report and can
7
+ * clean up leftovers manually. Every destructive step defaults to
8
+ * confirmation; `--yes` skips the prompt.
9
+ *
10
+ * What it removes, from least to most destructive:
11
+ * 1. skill symlink (~/.claude/skills/switchbot) — default: yes
12
+ * 2. credentials (keychain entry for the profile) — default: yes (requires --remove-creds OR --yes)
13
+ * 3. policy.yaml (only on --remove-policy) — default: no (user edits may live here)
14
+ *
15
+ * The CLI itself is never uninstalled: install did not install it,
16
+ * and yanking your own binary mid-run is impolite. Users who want it
17
+ * gone run `npm rm -g @switchbot/openapi-cli`.
18
+ */
19
+ import { InvalidArgumentError } from 'commander';
20
+ import fs from 'node:fs';
21
+ import readline from 'node:readline';
22
+ import { resolvePolicyPath } from '../policy/load.js';
23
+ import { skillLinkPathFor } from '../install/default-steps.js';
24
+ import { selectCredentialStore } from '../credentials/keychain.js';
25
+ import { isJsonMode, printJson } from '../utils/output.js';
26
+ import { getActiveProfile } from '../lib/request-context.js';
27
+ import chalk from 'chalk';
28
+ const AGENT_VALUES = ['claude-code', 'cursor', 'copilot', 'none'];
29
+ function parseAgent(value) {
30
+ if (!value)
31
+ return 'claude-code';
32
+ if (!AGENT_VALUES.includes(value)) {
33
+ throw new InvalidArgumentError(`--agent must be one of ${AGENT_VALUES.join(', ')} (got "${value}")`);
34
+ }
35
+ return value;
36
+ }
37
+ async function prompt(question, defaultYes) {
38
+ if (!process.stdin.isTTY)
39
+ return defaultYes;
40
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
41
+ return new Promise((resolve) => {
42
+ const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] ';
43
+ rl.question(question + suffix, (ans) => {
44
+ rl.close();
45
+ const a = ans.trim().toLowerCase();
46
+ if (!a)
47
+ return resolve(defaultYes);
48
+ resolve(a === 'y' || a === 'yes');
49
+ });
50
+ });
51
+ }
52
+ export function registerUninstallCommand(program) {
53
+ program
54
+ .command('uninstall')
55
+ .description('Reverse of `switchbot install`: remove skill link, credentials, (optionally) policy')
56
+ .option('--agent <name>', `target agent: ${AGENT_VALUES.join(' | ')} (default: claude-code)`)
57
+ .option('--remove-creds', 'delete credentials from the OS keychain (default: prompt)')
58
+ .option('--remove-policy', 'also delete policy.yaml (default: keep — user edits may live there)')
59
+ .option('-y, --yes', 'assume yes to every confirmation prompt (non-interactive)')
60
+ .option('--purge', 'shorthand for --yes --remove-creds --remove-policy: remove everything without prompting')
61
+ .addHelpText('after', `
62
+ The global --dry-run flag previews what would be removed.
63
+ Global --json emits a structured removal report.
64
+
65
+ What is never removed here:
66
+ - the CLI itself (use: npm rm -g @switchbot/openapi-cli)
67
+ - audit.log (it's your receipt; delete by hand if you want)
68
+
69
+ Examples:
70
+ # Interactive: prompts before each destructive step
71
+ switchbot uninstall
72
+
73
+ # Non-interactive, remove everything including the policy
74
+ switchbot uninstall --yes --remove-policy
75
+
76
+ # One-shot: remove absolutely everything without prompting
77
+ switchbot uninstall --purge
78
+ `)
79
+ .action(async (opts, command) => {
80
+ const agent = parseAgent(opts.agent);
81
+ const profile = getActiveProfile() ?? 'default';
82
+ const purge = Boolean(opts.purge);
83
+ const yes = Boolean(opts.yes) || purge;
84
+ const removePolicy = Boolean(opts.removePolicy) || purge;
85
+ const removeCreds = Boolean(opts.removeCreds) || yes;
86
+ const globalOpts = command.parent?.opts() ?? {};
87
+ const dryRun = Boolean(globalOpts.dryRun);
88
+ const policyPath = resolvePolicyPath();
89
+ const skillLink = skillLinkPathFor(agent);
90
+ const plan = [];
91
+ // --- Plan: skill symlink removal (default yes) ---
92
+ if (skillLink) {
93
+ plan.push({
94
+ action: 'remove-skill-link',
95
+ detail: skillLink,
96
+ run: async () => {
97
+ if (!fs.existsSync(skillLink)) {
98
+ return { action: 'remove-skill-link', status: 'absent', detail: skillLink };
99
+ }
100
+ const stat = fs.lstatSync(skillLink);
101
+ if (!stat.isSymbolicLink()) {
102
+ return {
103
+ action: 'remove-skill-link',
104
+ status: 'skipped',
105
+ detail: `${skillLink} exists but is not a symlink — leaving it alone`,
106
+ };
107
+ }
108
+ const ok = yes ? true : await prompt(`Remove skill link ${skillLink}?`, true);
109
+ if (!ok)
110
+ return { action: 'remove-skill-link', status: 'skipped', detail: skillLink };
111
+ try {
112
+ fs.unlinkSync(skillLink);
113
+ return { action: 'remove-skill-link', status: 'removed', detail: skillLink };
114
+ }
115
+ catch (err) {
116
+ return {
117
+ action: 'remove-skill-link',
118
+ status: 'failed',
119
+ detail: skillLink,
120
+ error: err instanceof Error ? err.message : String(err),
121
+ };
122
+ }
123
+ },
124
+ });
125
+ }
126
+ // --- Plan: credential removal (requires --remove-creds OR --yes) ---
127
+ plan.push({
128
+ action: 'remove-credentials',
129
+ detail: `profile=${profile}`,
130
+ run: async () => {
131
+ if (!removeCreds) {
132
+ return {
133
+ action: 'remove-credentials',
134
+ status: 'skipped',
135
+ detail: 'pass --remove-creds to delete keychain entry',
136
+ };
137
+ }
138
+ const ok = yes ? true : await prompt(`Delete credentials for profile "${profile}" from the keychain?`, false);
139
+ if (!ok)
140
+ return { action: 'remove-credentials', status: 'skipped', detail: `profile=${profile}` };
141
+ try {
142
+ const store = await selectCredentialStore();
143
+ await store.delete(profile);
144
+ return {
145
+ action: 'remove-credentials',
146
+ status: 'removed',
147
+ detail: `profile=${profile} (backend=${store.describe().tag})`,
148
+ };
149
+ }
150
+ catch (err) {
151
+ return {
152
+ action: 'remove-credentials',
153
+ status: 'failed',
154
+ detail: `profile=${profile}`,
155
+ error: err instanceof Error ? err.message : String(err),
156
+ };
157
+ }
158
+ },
159
+ });
160
+ // --- Plan: policy.yaml removal (opt-in) ---
161
+ plan.push({
162
+ action: 'remove-policy',
163
+ detail: policyPath,
164
+ run: async () => {
165
+ if (!removePolicy) {
166
+ return {
167
+ action: 'remove-policy',
168
+ status: 'skipped',
169
+ detail: 'pass --remove-policy to delete policy.yaml',
170
+ };
171
+ }
172
+ if (!fs.existsSync(policyPath)) {
173
+ return { action: 'remove-policy', status: 'absent', detail: policyPath };
174
+ }
175
+ const ok = yes ? true : await prompt(`Delete policy file ${policyPath}?`, false);
176
+ if (!ok)
177
+ return { action: 'remove-policy', status: 'skipped', detail: policyPath };
178
+ try {
179
+ fs.unlinkSync(policyPath);
180
+ return { action: 'remove-policy', status: 'removed', detail: policyPath };
181
+ }
182
+ catch (err) {
183
+ return {
184
+ action: 'remove-policy',
185
+ status: 'failed',
186
+ detail: policyPath,
187
+ error: err instanceof Error ? err.message : String(err),
188
+ };
189
+ }
190
+ },
191
+ });
192
+ if (dryRun) {
193
+ if (isJsonMode()) {
194
+ printJson({
195
+ dryRun: true,
196
+ profile,
197
+ agent,
198
+ plan: plan.map(({ action, detail }) => ({ action, detail })),
199
+ });
200
+ }
201
+ else {
202
+ console.log(chalk.bold('switchbot uninstall — dry run'));
203
+ console.log(` profile: ${profile}`);
204
+ console.log(` agent: ${agent}`);
205
+ console.log('');
206
+ console.log(chalk.bold('Would run:'));
207
+ for (const p of plan)
208
+ console.log(` • ${p.action} — ${p.detail}`);
209
+ console.log('');
210
+ console.log(chalk.dim('No changes made. Re-run without --dry-run (add --yes to skip prompts).'));
211
+ }
212
+ return;
213
+ }
214
+ const outcomes = [];
215
+ for (const p of plan) {
216
+ outcomes.push(await p.run());
217
+ }
218
+ const anyFailed = outcomes.some((o) => o.status === 'failed');
219
+ if (isJsonMode()) {
220
+ printJson({ ok: !anyFailed, profile, agent, outcomes });
221
+ }
222
+ else {
223
+ console.log(chalk.bold('switchbot uninstall'));
224
+ for (const o of outcomes) {
225
+ const tag = o.status === 'removed' ? chalk.green('✓') :
226
+ o.status === 'absent' ? chalk.dim('·') :
227
+ o.status === 'skipped' ? chalk.yellow('↷') :
228
+ chalk.red('✗');
229
+ console.log(` ${tag} ${o.action} [${o.status}] ${o.detail ?? ''}`);
230
+ if (o.error)
231
+ console.log(` ${chalk.red(o.error)}`);
232
+ }
233
+ }
234
+ if (anyFailed)
235
+ process.exit(3);
236
+ });
237
+ }
@@ -1,10 +1,11 @@
1
- import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
1
+ import { printJson, isJsonMode, handleError, UsageError, emitStreamHeader } from '../utils/output.js';
2
2
  import { fetchDeviceStatus } from '../lib/devices.js';
3
3
  import { getCachedDevice } from '../devices/cache.js';
4
4
  import { parseDurationToMs, getFields } from '../utils/flags.js';
5
5
  import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js';
6
6
  import { createClient } from '../api/client.js';
7
7
  import { resolveDeviceId } from '../utils/name-resolver.js';
8
+ import { resolveFieldList, listAllCanonical } from '../schema/field-aliases.js';
8
9
  const DEFAULT_INTERVAL_MS = 30_000;
9
10
  const MIN_INTERVAL_MS = 1_000;
10
11
  function diff(prev, next, fields) {
@@ -101,7 +102,15 @@ Examples:
101
102
  maxTicks = Math.floor(n);
102
103
  }
103
104
  const forMs = options.for ? parseDurationToMs(options.for) : null;
104
- const fields = getFields() ?? null;
105
+ const rawFields = getFields() ?? null;
106
+ // Resolve aliases upfront against the static canonical registry.
107
+ // Validating here lets UsageError exit the command before any
108
+ // polling starts, and keeps mid-loop error handling free of
109
+ // "misuse" concerns. Unknown fields that are not registered as
110
+ // aliases but happen to match an API key pass through unchanged.
111
+ const fields = rawFields
112
+ ? resolveFieldList(rawFields, listAllCanonical())
113
+ : null;
105
114
  const ac = new AbortController();
106
115
  const onSig = () => ac.abort();
107
116
  process.on('SIGINT', onSig);
@@ -109,6 +118,10 @@ Examples:
109
118
  const forTimer = forMs !== null && forMs > 0
110
119
  ? setTimeout(() => ac.abort(), forMs)
111
120
  : null;
121
+ // P7: streaming JSON contract — first line under --json is the
122
+ // stream header so consumers can route by eventKind/cadence.
123
+ if (isJsonMode())
124
+ emitStreamHeader({ eventKind: 'tick', cadence: 'poll' });
112
125
  try {
113
126
  const prev = new Map();
114
127
  const client = createClient();
package/dist/config.js CHANGED
@@ -4,6 +4,7 @@ import os from 'node:os';
4
4
  import { getConfigPath } from './utils/flags.js';
5
5
  import { getActiveProfile } from './lib/request-context.js';
6
6
  import { emitJsonError, isJsonMode } from './utils/output.js';
7
+ import { getPrimedCredentials } from './credentials/prime.js';
7
8
  function sanitizeOptionalString(v) {
8
9
  if (typeof v !== 'string')
9
10
  return undefined;
@@ -46,6 +47,14 @@ export function loadConfig() {
46
47
  if (envToken && envSecret) {
47
48
  return { token: envToken, secret: envSecret };
48
49
  }
50
+ // After env, try the OS keychain (via the priming cache populated at
51
+ // command start). When --config is passed we skip the keychain so the
52
+ // override remains authoritative.
53
+ if (!getConfigPath()) {
54
+ const primed = getPrimedCredentials(getActiveProfile() ?? 'default');
55
+ if (primed)
56
+ return primed;
57
+ }
49
58
  const file = configFilePath();
50
59
  if (!fs.existsSync(file)) {
51
60
  const profile = getActiveProfile();
@@ -94,6 +103,11 @@ export function tryLoadConfig() {
94
103
  const envSecret = process.env.SWITCHBOT_SECRET;
95
104
  if (envToken && envSecret)
96
105
  return { token: envToken, secret: envSecret };
106
+ if (!getConfigPath()) {
107
+ const primed = getPrimedCredentials(getActiveProfile() ?? 'default');
108
+ if (primed)
109
+ return primed;
110
+ }
97
111
  const file = configFilePath();
98
112
  if (!fs.existsSync(file))
99
113
  return null;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * File-backed credential store.
3
+ *
4
+ * Reads/writes the same `~/.switchbot/config.json` shape the CLI has
5
+ * used since v1.0, so a fresh install on a machine without a keychain
6
+ * still works and legacy users can migrate in-place via
7
+ * `switchbot auth keychain migrate` without data loss.
8
+ *
9
+ * Profile layout (inherited from `src/config.ts`):
10
+ * - default profile → `~/.switchbot/config.json`
11
+ * - named profile → `~/.switchbot/profiles/<name>.json`
12
+ *
13
+ * This backend only owns the `token` and `secret` fields — label /
14
+ * description / limits / defaults are preserved on write by merging
15
+ * with the existing JSON, keeping parity with `saveConfig()`.
16
+ */
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { KeychainError, } from '../keychain.js';
21
+ function profilePath(profile) {
22
+ if (profile === 'default') {
23
+ return path.join(os.homedir(), '.switchbot', 'config.json');
24
+ }
25
+ return path.join(os.homedir(), '.switchbot', 'profiles', `${profile}.json`);
26
+ }
27
+ function readJson(file) {
28
+ if (!fs.existsSync(file))
29
+ return null;
30
+ try {
31
+ const raw = fs.readFileSync(file, 'utf-8');
32
+ const parsed = JSON.parse(raw);
33
+ return parsed && typeof parsed === 'object' ? parsed : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ export function createFileBackend() {
40
+ return {
41
+ name: 'file',
42
+ async get(profile) {
43
+ const file = profilePath(profile);
44
+ const data = readJson(file);
45
+ if (!data)
46
+ return null;
47
+ const token = typeof data.token === 'string' ? data.token : '';
48
+ const secret = typeof data.secret === 'string' ? data.secret : '';
49
+ if (!token || !secret)
50
+ return null;
51
+ return { token, secret };
52
+ },
53
+ async set(profile, creds) {
54
+ const file = profilePath(profile);
55
+ const dir = path.dirname(file);
56
+ try {
57
+ fs.mkdirSync(dir, { recursive: true });
58
+ const existing = readJson(file) ?? {};
59
+ const next = { ...existing, token: creds.token, secret: creds.secret };
60
+ fs.writeFileSync(file, JSON.stringify(next, null, 2), { mode: 0o600 });
61
+ }
62
+ catch (err) {
63
+ const msg = err instanceof Error ? err.message : String(err);
64
+ throw new KeychainError('file', 'set', msg);
65
+ }
66
+ },
67
+ async delete(profile) {
68
+ const file = profilePath(profile);
69
+ try {
70
+ if (!fs.existsSync(file))
71
+ return;
72
+ const existing = readJson(file);
73
+ if (existing) {
74
+ delete existing.token;
75
+ delete existing.secret;
76
+ if (Object.keys(existing).length === 0) {
77
+ fs.unlinkSync(file);
78
+ }
79
+ else {
80
+ fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 });
81
+ }
82
+ }
83
+ else {
84
+ fs.unlinkSync(file);
85
+ }
86
+ }
87
+ catch (err) {
88
+ const msg = err instanceof Error ? err.message : String(err);
89
+ throw new KeychainError('file', 'delete', msg);
90
+ }
91
+ },
92
+ describe() {
93
+ return {
94
+ backend: 'File (~/.switchbot/)',
95
+ tag: 'file',
96
+ writable: true,
97
+ notes: 'Last-resort fallback; credentials stored in a 0600 JSON file.',
98
+ };
99
+ },
100
+ };
101
+ }