@switchbot/openapi-cli 2.7.2 → 3.1.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.
- package/README.md +481 -103
- package/dist/api/client.js +23 -1
- package/dist/commands/agent-bootstrap.js +47 -2
- package/dist/commands/auth.js +354 -0
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +109 -0
- package/dist/commands/daemon.js +367 -0
- package/dist/commands/devices.js +62 -11
- package/dist/commands/doctor.js +417 -8
- package/dist/commands/events.js +3 -3
- package/dist/commands/explain.js +1 -2
- package/dist/commands/health.js +113 -0
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +888 -7
- package/dist/commands/plan.js +379 -103
- package/dist/commands/policy.js +586 -0
- package/dist/commands/rules.js +875 -0
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/schema.js +0 -2
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/commands/upgrade-check.js +88 -0
- package/dist/config.js +14 -0
- package/dist/credentials/backends/file.js +101 -0
- package/dist/credentials/backends/linux.js +129 -0
- package/dist/credentials/backends/macos.js +129 -0
- package/dist/credentials/backends/windows.js +215 -0
- package/dist/credentials/keychain.js +88 -0
- package/dist/credentials/prime.js +52 -0
- package/dist/devices/catalog.js +4 -10
- package/dist/index.js +30 -1
- package/dist/install/default-steps.js +257 -0
- package/dist/install/preflight.js +212 -0
- package/dist/install/steps.js +67 -0
- package/dist/lib/command-keywords.js +17 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -1
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/add-rule.js +124 -0
- package/dist/policy/diff.js +91 -0
- package/dist/policy/examples/policy.example.yaml +99 -0
- package/dist/policy/format.js +57 -0
- package/dist/policy/load.js +61 -0
- package/dist/policy/migrate.js +67 -0
- package/dist/policy/schema/v0.2.json +331 -0
- package/dist/policy/schema.js +18 -0
- package/dist/policy/validate.js +262 -0
- package/dist/rules/action.js +205 -0
- package/dist/rules/audit-query.js +89 -0
- package/dist/rules/conflict-analyzer.js +203 -0
- package/dist/rules/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +757 -0
- package/dist/rules/matcher.js +230 -0
- package/dist/rules/pid-file.js +95 -0
- package/dist/rules/quiet-hours.js +45 -0
- package/dist/rules/suggest.js +95 -0
- package/dist/rules/throttle.js +116 -0
- package/dist/rules/types.js +34 -0
- package/dist/rules/webhook-listener.js +223 -0
- package/dist/rules/webhook-token.js +90 -0
- package/dist/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +12 -4
package/dist/commands/scenes.js
CHANGED
|
@@ -121,4 +121,144 @@ Example:
|
|
|
121
121
|
handleError(error);
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
|
+
// switchbot scenes validate [sceneId...]
|
|
125
|
+
scenes
|
|
126
|
+
.command('validate')
|
|
127
|
+
.description('Verify that one or more scenes exist. If no IDs are given, validates all scenes are reachable.')
|
|
128
|
+
.argument('[sceneId...]', 'Scene IDs to validate (default: all scenes)')
|
|
129
|
+
.addHelpText('after', `
|
|
130
|
+
Note: SwitchBot API v1.1 does not expose scene steps; validation only confirms
|
|
131
|
+
the scene IDs exist in your account.
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
$ switchbot scenes validate
|
|
135
|
+
$ switchbot scenes validate T12345678 T87654321
|
|
136
|
+
`)
|
|
137
|
+
.action(async (sceneIds) => {
|
|
138
|
+
try {
|
|
139
|
+
const sceneList = await fetchScenes();
|
|
140
|
+
const sceneMap = new Map(sceneList.map((s) => [s.sceneId, s.sceneName]));
|
|
141
|
+
const targets = sceneIds.length > 0 ? sceneIds : sceneList.map((s) => s.sceneId);
|
|
142
|
+
const results = targets.map((id) => ({
|
|
143
|
+
sceneId: id,
|
|
144
|
+
sceneName: sceneMap.get(id) ?? null,
|
|
145
|
+
valid: sceneMap.has(id),
|
|
146
|
+
}));
|
|
147
|
+
const allValid = results.every((r) => r.valid);
|
|
148
|
+
if (isJsonMode()) {
|
|
149
|
+
printJson({ ok: allValid, results });
|
|
150
|
+
if (!allValid)
|
|
151
|
+
process.exit(1);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const r of results) {
|
|
155
|
+
const icon = r.valid ? '✓' : '✗';
|
|
156
|
+
const label = r.valid ? r.sceneName : '(not found)';
|
|
157
|
+
console.log(`${icon} ${r.sceneId} ${label}`);
|
|
158
|
+
}
|
|
159
|
+
if (!allValid)
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
handleError(error);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
// switchbot scenes simulate <sceneId>
|
|
167
|
+
scenes
|
|
168
|
+
.command('simulate')
|
|
169
|
+
.description('Show what `scenes execute` would do without actually executing the scene.')
|
|
170
|
+
.argument('<sceneId>', 'Scene ID from "scenes list"')
|
|
171
|
+
.addHelpText('after', `
|
|
172
|
+
Note: SwitchBot API v1.1 does not expose scene step details. Simulation reports
|
|
173
|
+
the scene name, confirms it exists, and shows the POST that would be issued.
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
$ switchbot scenes simulate T12345678
|
|
177
|
+
`)
|
|
178
|
+
.action(async (sceneId) => {
|
|
179
|
+
try {
|
|
180
|
+
const sceneList = await fetchScenes();
|
|
181
|
+
const found = sceneList.find((s) => s.sceneId === sceneId);
|
|
182
|
+
if (!found) {
|
|
183
|
+
throw new StructuredUsageError(`scene not found: ${sceneId}`, {
|
|
184
|
+
error: 'scene_not_found',
|
|
185
|
+
sceneId,
|
|
186
|
+
candidates: sceneList.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const simulation = {
|
|
190
|
+
sceneId: found.sceneId,
|
|
191
|
+
sceneName: found.sceneName,
|
|
192
|
+
wouldSend: { method: 'POST', url: `/v1.1/scenes/${sceneId}/execute` },
|
|
193
|
+
note: 'SwitchBot API v1.1 does not expose individual scene steps.',
|
|
194
|
+
};
|
|
195
|
+
if (isJsonMode()) {
|
|
196
|
+
printJson({ simulated: true, ...simulation });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
console.log(`sceneId: ${simulation.sceneId}`);
|
|
200
|
+
console.log(`sceneName: ${simulation.sceneName}`);
|
|
201
|
+
console.log(`wouldSend: ${simulation.wouldSend.method} ${simulation.wouldSend.url}`);
|
|
202
|
+
console.log(`note: ${simulation.note}`);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
handleError(error);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
// switchbot scenes explain <sceneId>
|
|
209
|
+
scenes
|
|
210
|
+
.command('explain')
|
|
211
|
+
.description('Explain in plain language what a scene does and how to execute it safely.')
|
|
212
|
+
.argument('<sceneId>', 'Scene ID from "scenes list"')
|
|
213
|
+
.addHelpText('after', `
|
|
214
|
+
Shows the scene name, action description, risk level, and the exact command to
|
|
215
|
+
run. Unlike "simulate" (which shows raw HTTP detail), "explain" is aimed at a
|
|
216
|
+
human or agent deciding whether to proceed.
|
|
217
|
+
|
|
218
|
+
Note: SwitchBot API v1.1 does not expose scene step details; risk is reported
|
|
219
|
+
as "low" because scenes only trigger pre-configured automations in the app.
|
|
220
|
+
|
|
221
|
+
Example:
|
|
222
|
+
$ switchbot scenes explain T12345678
|
|
223
|
+
`)
|
|
224
|
+
.action(async (sceneId) => {
|
|
225
|
+
try {
|
|
226
|
+
const sceneList = await fetchScenes();
|
|
227
|
+
const found = sceneList.find((s) => s.sceneId === sceneId);
|
|
228
|
+
if (!found) {
|
|
229
|
+
throw new StructuredUsageError(`scene not found: ${sceneId}`, {
|
|
230
|
+
error: 'scene_not_found',
|
|
231
|
+
sceneId,
|
|
232
|
+
candidates: sceneList.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const explanation = {
|
|
236
|
+
sceneId: found.sceneId,
|
|
237
|
+
sceneName: found.sceneName,
|
|
238
|
+
action: `Trigger scene (POST /v1.1/scenes/${found.sceneId}/execute)`,
|
|
239
|
+
riskLevel: 'low',
|
|
240
|
+
idempotent: null,
|
|
241
|
+
toExecute: `switchbot scenes execute ${found.sceneId}`,
|
|
242
|
+
dryRun: isDryRun(),
|
|
243
|
+
note: 'SwitchBot API v1.1 does not expose individual scene steps.',
|
|
244
|
+
};
|
|
245
|
+
if (isJsonMode()) {
|
|
246
|
+
printJson(explanation);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
console.log(`sceneId: ${explanation.sceneId}`);
|
|
250
|
+
console.log(`sceneName: ${explanation.sceneName}`);
|
|
251
|
+
console.log(`action: ${explanation.action}`);
|
|
252
|
+
console.log(`riskLevel: ${explanation.riskLevel}`);
|
|
253
|
+
console.log(`idempotent: unknown (scene steps not exposed by API)`);
|
|
254
|
+
console.log(`toExecute: ${explanation.toExecute}`);
|
|
255
|
+
if (explanation.dryRun) {
|
|
256
|
+
console.log(`dryRun: true (pass --dry-run to execute would be a no-op)`);
|
|
257
|
+
}
|
|
258
|
+
console.log(`note: ${explanation.note}`);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
handleError(error);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
124
264
|
}
|
package/dist/commands/schema.js
CHANGED
|
@@ -25,7 +25,6 @@ function toSchemaCommand(c, entry) {
|
|
|
25
25
|
commandType: (c.commandType ?? 'command'),
|
|
26
26
|
idempotent: Boolean(c.idempotent),
|
|
27
27
|
safetyTier: tier,
|
|
28
|
-
destructive: tier === 'destructive',
|
|
29
28
|
...(reason ? { safetyReason: reason } : {}),
|
|
30
29
|
...(c.exampleParams ? { exampleParams: c.exampleParams } : {}),
|
|
31
30
|
};
|
|
@@ -44,7 +43,6 @@ function toCompactEntry(e) {
|
|
|
44
43
|
commandType: (c.commandType ?? 'command'),
|
|
45
44
|
idempotent: Boolean(c.idempotent),
|
|
46
45
|
safetyTier: tier,
|
|
47
|
-
destructive: tier === 'destructive',
|
|
48
46
|
};
|
|
49
47
|
}),
|
|
50
48
|
statusFields: e.statusFields ?? [],
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { isJsonMode, printJson } from '../utils/output.js';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const { name: pkgName, version: currentVersion } = require('../../package.json');
|
|
7
|
+
function fetchLatestVersion(packageName, timeoutMs = 8000) {
|
|
8
|
+
const encoded = packageName.replace('/', '%2F');
|
|
9
|
+
const url = `https://registry.npmjs.org/${encoded}/latest`;
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const req = https.get(url, { timeout: timeoutMs }, (res) => {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
res.on('data', (c) => chunks.push(c));
|
|
14
|
+
res.on('end', () => {
|
|
15
|
+
try {
|
|
16
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
17
|
+
if (typeof body.version === 'string')
|
|
18
|
+
resolve(body.version);
|
|
19
|
+
else
|
|
20
|
+
reject(new Error('version field missing from registry response'));
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
reject(err);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
req.on('timeout', () => { req.destroy(); reject(new Error(`registry request timed out after ${timeoutMs}ms`)); });
|
|
28
|
+
req.on('error', reject);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function semverGt(a, b) {
|
|
32
|
+
const numParts = (v) => v.replace(/-.*$/, '').split('.').map((n) => Number.parseInt(n, 10));
|
|
33
|
+
const [aMaj, aMin, aPat] = numParts(a);
|
|
34
|
+
const [bMaj, bMin, bPat] = numParts(b);
|
|
35
|
+
if (aMaj !== bMaj)
|
|
36
|
+
return aMaj > bMaj;
|
|
37
|
+
if (aMin !== bMin)
|
|
38
|
+
return aMin > bMin;
|
|
39
|
+
if (aPat !== bPat)
|
|
40
|
+
return aPat > bPat;
|
|
41
|
+
// Same numeric version: release (no prerelease) > prerelease
|
|
42
|
+
return !a.includes('-') && b.includes('-');
|
|
43
|
+
}
|
|
44
|
+
export function registerUpgradeCheckCommand(program) {
|
|
45
|
+
program
|
|
46
|
+
.command('upgrade-check')
|
|
47
|
+
.description('Check whether a newer version of this CLI is available on npm.')
|
|
48
|
+
.option('--timeout <ms>', 'Registry request timeout in milliseconds (default: 8000)', (v) => Number.parseInt(v, 10))
|
|
49
|
+
.action(async (opts) => {
|
|
50
|
+
let latestVersion;
|
|
51
|
+
try {
|
|
52
|
+
latestVersion = await fetchLatestVersion(pkgName, opts.timeout ?? 8000);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
56
|
+
if (isJsonMode()) {
|
|
57
|
+
printJson({ ok: false, error: msg, current: currentVersion });
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.error(chalk.red(`upgrade-check failed: ${msg}`));
|
|
61
|
+
}
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
const upToDate = !semverGt(latestVersion, currentVersion);
|
|
65
|
+
const currentMajor = Number.parseInt(currentVersion.split('.')[0], 10);
|
|
66
|
+
const latestMajor = Number.parseInt(latestVersion.split('.')[0], 10);
|
|
67
|
+
const result = {
|
|
68
|
+
current: currentVersion,
|
|
69
|
+
latest: latestVersion,
|
|
70
|
+
upToDate,
|
|
71
|
+
updateAvailable: !upToDate,
|
|
72
|
+
breakingChange: latestMajor > currentMajor,
|
|
73
|
+
installCommand: upToDate ? null : `npm install -g ${pkgName}@${latestVersion}`,
|
|
74
|
+
};
|
|
75
|
+
if (isJsonMode()) {
|
|
76
|
+
printJson(result);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (upToDate) {
|
|
80
|
+
console.log(`${chalk.green('✓')} You are running the latest version (${currentVersion}).`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.log(`${chalk.yellow('!')} Update available: ${chalk.bold(currentVersion)} → ${chalk.bold(latestVersion)}`);
|
|
84
|
+
console.log(` Run: ${chalk.cyan(`npm install -g ${pkgName}@${latestVersion}`)}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
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;
|