@switchbot/openapi-cli 2.3.0 → 2.5.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/dist/api/client.js +33 -1
- package/dist/commands/agent-bootstrap.js +128 -0
- package/dist/commands/batch.js +109 -15
- package/dist/commands/cache.js +1 -0
- package/dist/commands/capabilities.js +203 -62
- package/dist/commands/config.js +132 -9
- package/dist/commands/devices.js +43 -5
- package/dist/commands/doctor.js +105 -14
- package/dist/commands/events.js +60 -4
- package/dist/commands/history.js +227 -2
- package/dist/commands/mcp.js +203 -35
- package/dist/commands/plan.js +6 -1
- package/dist/commands/quota.js +4 -2
- package/dist/commands/scenes.js +43 -1
- package/dist/commands/schema.js +101 -5
- package/dist/config.js +71 -2
- package/dist/devices/history-agg.js +138 -0
- package/dist/devices/history-query.js +181 -0
- package/dist/index.js +19 -2
- package/dist/lib/devices.js +8 -1
- package/dist/lib/idempotency.js +56 -22
- package/dist/mcp/device-history.js +86 -7
- package/dist/utils/audit.js +66 -1
- package/dist/utils/flags.js +18 -0
- package/dist/utils/format.js +9 -1
- package/dist/utils/name-resolver.js +85 -29
- package/dist/utils/output.js +116 -20
- package/dist/utils/quota.js +14 -0
- package/dist/utils/redact.js +68 -0
- package/dist/version.js +4 -0
- package/package.json +1 -1
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
2
2
|
import { printJson } from '../utils/output.js';
|
|
3
|
+
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
4
|
+
const AGENT_GUIDE = {
|
|
5
|
+
safetyTiers: {
|
|
6
|
+
read: 'No state mutation; safe to call freely — does not consume quota unless noted.',
|
|
7
|
+
action: 'Mutates device or cloud state but is reversible and routine (turnOn, setColor).',
|
|
8
|
+
destructive: 'Hard to reverse / physical-world side effects (unlock, garage open, delete key). Requires explicit user confirmation.',
|
|
9
|
+
},
|
|
10
|
+
verifiability: {
|
|
11
|
+
local: 'Result is fully verifiable from the CLI return value itself.',
|
|
12
|
+
deviceConfirmed: 'Device returns an ack with an observable state field.',
|
|
13
|
+
deviceDependent: 'Verifiability depends on the specific device (IR is never verifiable).',
|
|
14
|
+
none: 'No feedback — e.g. IR transmission. Pair with an external sensor to confirm.',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const COMMAND_META = {
|
|
18
|
+
// devices: reads
|
|
19
|
+
'devices list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 },
|
|
20
|
+
'devices status': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
21
|
+
'devices describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 },
|
|
22
|
+
'devices types': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
23
|
+
'devices commands': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
24
|
+
'devices watch': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
25
|
+
// devices: actions
|
|
26
|
+
'devices command': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 800 },
|
|
27
|
+
'devices batch': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1200 },
|
|
28
|
+
// scenes
|
|
29
|
+
'scenes list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
30
|
+
'scenes execute': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1500 },
|
|
31
|
+
'scenes describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
32
|
+
// webhook
|
|
33
|
+
'webhook setup': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 500 },
|
|
34
|
+
'webhook query': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
35
|
+
'webhook delete': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 500 },
|
|
36
|
+
// quota
|
|
37
|
+
'quota status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
38
|
+
'quota show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
39
|
+
'quota reset': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 10 },
|
|
40
|
+
// doctor / schema / capabilities / catalog / config / cache / events / history / plan
|
|
41
|
+
'doctor': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 900 },
|
|
42
|
+
'schema export': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
43
|
+
'capabilities': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 },
|
|
44
|
+
'catalog': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 },
|
|
45
|
+
'config set-token': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 5 },
|
|
46
|
+
'config show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
47
|
+
'config list-profiles': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
48
|
+
'cache status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
49
|
+
'cache clear': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
|
|
50
|
+
'events mqtt-tail': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
51
|
+
'history show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
52
|
+
'history replay': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1000 },
|
|
53
|
+
'history range': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 50 },
|
|
54
|
+
'history stats': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
55
|
+
'history aggregate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 80 },
|
|
56
|
+
'plan run': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 2000 },
|
|
57
|
+
'plan validate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
58
|
+
'plan schema': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
59
|
+
'completion': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
60
|
+
'mcp serve': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
61
|
+
};
|
|
62
|
+
function metaFor(command) {
|
|
63
|
+
return COMMAND_META[command] ?? null;
|
|
64
|
+
}
|
|
3
65
|
const IDENTITY = {
|
|
4
66
|
product: 'SwitchBot',
|
|
5
67
|
domain: 'IoT smart home device control',
|
|
@@ -27,84 +89,163 @@ const MCP_TOOLS = [
|
|
|
27
89
|
'search_catalog',
|
|
28
90
|
'account_overview',
|
|
29
91
|
'get_device_history',
|
|
92
|
+
'query_device_history',
|
|
93
|
+
'aggregate_device_history',
|
|
30
94
|
];
|
|
95
|
+
const IDEMPOTENCY_CONTRACT = {
|
|
96
|
+
flag: '--idempotency-key <key>',
|
|
97
|
+
windowSeconds: 60,
|
|
98
|
+
replayBehavior: 'Same (command, parameter, deviceId) within window → returns cached result with replayed:true.',
|
|
99
|
+
conflictBehavior: 'Same key + different (command, parameter) within window → exit 2, error:"idempotency_conflict".',
|
|
100
|
+
keyStorage: 'In-memory SHA-256 fingerprint; raw key never stored, no disk persistence.',
|
|
101
|
+
scope: 'Process-local. Replay + conflict apply within a single long-lived process (MCP session, devices batch, plan run, history replay). Independent CLI invocations do NOT share cache — each fresh `node` process starts empty.',
|
|
102
|
+
mcp: 'MCP send_command accepts the same idempotencyKey field with identical semantics.',
|
|
103
|
+
};
|
|
104
|
+
function enumerateLeaves(program, prefix = '') {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const cmd of program.commands) {
|
|
107
|
+
const full = prefix ? `${prefix} ${cmd.name()}` : cmd.name();
|
|
108
|
+
if (cmd.commands.length === 0) {
|
|
109
|
+
const meta = metaFor(full);
|
|
110
|
+
if (meta) {
|
|
111
|
+
out.push({ name: full, ...meta });
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
// Unknown leaf → default to read-safe with a warning flag so agents notice.
|
|
115
|
+
out.push({
|
|
116
|
+
name: full,
|
|
117
|
+
mutating: false,
|
|
118
|
+
consumesQuota: false,
|
|
119
|
+
idempotencySupported: false,
|
|
120
|
+
agentSafetyTier: 'read',
|
|
121
|
+
verifiability: 'local',
|
|
122
|
+
typicalLatencyMs: 50,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
out.push(...enumerateLeaves(cmd, full));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function projectObject(obj, fields) {
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const f of fields) {
|
|
135
|
+
if (f in obj)
|
|
136
|
+
out[f] = obj[f];
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
31
140
|
export function registerCapabilitiesCommand(program) {
|
|
141
|
+
const SURFACES = ['cli', 'mcp', 'plan', 'mqtt', 'all'];
|
|
32
142
|
program
|
|
33
143
|
.command('capabilities')
|
|
34
144
|
.description('Print a machine-readable manifest of CLI capabilities (for agent bootstrap)')
|
|
35
|
-
.option('--minimal', 'Omit per-subcommand flag details to reduce output size')
|
|
145
|
+
.option('--minimal', 'Omit per-subcommand flag details to reduce output size (alias of --compact)')
|
|
146
|
+
.option('--compact', 'Emit a compact summary: identity + leaf command list with safety metadata only')
|
|
147
|
+
.option('--surface <s>', 'Restrict surfaces block to one of: cli, mcp, plan, mqtt, all (default: all)', enumArg('--surface', SURFACES))
|
|
148
|
+
.option('--project <csv>', 'Project top-level fields (e.g. --project identity,commands,agentGuide)', stringArg('--project'))
|
|
36
149
|
.action((opts) => {
|
|
150
|
+
const compact = Boolean(opts.minimal || opts.compact);
|
|
37
151
|
const catalog = getEffectiveCatalog();
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
152
|
+
const leaves = enumerateLeaves(program);
|
|
153
|
+
const fullCommands = compact
|
|
154
|
+
? undefined
|
|
155
|
+
: [
|
|
156
|
+
...program.commands,
|
|
157
|
+
{ name: () => 'help', description: () => 'Display help for a command', commands: [], options: [], registeredArguments: [] },
|
|
158
|
+
].map((c) => {
|
|
159
|
+
const full = c.name();
|
|
160
|
+
const entry = {
|
|
161
|
+
name: full,
|
|
162
|
+
description: c.description(),
|
|
163
|
+
};
|
|
164
|
+
entry.subcommands = c.commands.map((s) => {
|
|
165
|
+
const leafName = `${full} ${s.name()}`;
|
|
166
|
+
const meta = metaFor(leafName);
|
|
167
|
+
return {
|
|
168
|
+
name: s.name(),
|
|
169
|
+
description: s.description(),
|
|
170
|
+
args: s.registeredArguments.map((a) => ({
|
|
171
|
+
name: a.name(),
|
|
172
|
+
required: a.required,
|
|
173
|
+
variadic: a.variadic,
|
|
174
|
+
})),
|
|
175
|
+
flags: s.options.map((o) => ({
|
|
176
|
+
flags: o.flags,
|
|
177
|
+
description: o.description,
|
|
178
|
+
})),
|
|
179
|
+
...(meta ?? {}),
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
const selfMeta = metaFor(full);
|
|
183
|
+
if (selfMeta)
|
|
184
|
+
Object.assign(entry, selfMeta);
|
|
185
|
+
return entry;
|
|
186
|
+
});
|
|
187
|
+
const globalFlags = compact
|
|
188
|
+
? undefined
|
|
189
|
+
: program.options.map((opt) => ({ flags: opt.flags, description: opt.description }));
|
|
190
|
+
const surfaces = {
|
|
191
|
+
mcp: {
|
|
192
|
+
entry: 'mcp serve',
|
|
193
|
+
protocol: 'stdio (default) or --port <n> for HTTP',
|
|
194
|
+
tools: MCP_TOOLS,
|
|
195
|
+
resources: ['switchbot://events'],
|
|
196
|
+
toolMeta: 'Each MCP tool mirrors the CLI leaf command metadata (mutating, consumesQuota, agentSafetyTier, idempotencySupported).',
|
|
197
|
+
},
|
|
198
|
+
mqtt: {
|
|
199
|
+
mode: 'consumer',
|
|
200
|
+
authSource: 'SWITCHBOT_TOKEN + SWITCHBOT_SECRET (auto-provisioned via POST /v1.1/iot/credential)',
|
|
201
|
+
cliCmd: 'events mqtt-tail',
|
|
202
|
+
mcpResource: 'switchbot://events',
|
|
203
|
+
protocol: 'MQTTS with TLS client certificates (AWS IoT)',
|
|
204
|
+
},
|
|
205
|
+
plan: {
|
|
206
|
+
schemaCmd: 'plan schema',
|
|
207
|
+
validateCmd: 'plan validate -',
|
|
208
|
+
runCmd: 'plan run -',
|
|
209
|
+
},
|
|
210
|
+
cli: {
|
|
211
|
+
catalogCmd: 'schema export',
|
|
212
|
+
discoveryCmd: 'capabilities',
|
|
213
|
+
healthCmd: 'doctor --json',
|
|
214
|
+
healthCmdSchemaVersion: 1,
|
|
215
|
+
helpFlag: '--help',
|
|
216
|
+
idempotencyContract: IDEMPOTENCY_CONTRACT,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
const filteredSurfaces = (() => {
|
|
220
|
+
if (!opts.surface || opts.surface === 'all')
|
|
221
|
+
return surfaces;
|
|
222
|
+
const picked = {};
|
|
223
|
+
if (opts.surface in surfaces) {
|
|
224
|
+
picked[opts.surface] = surfaces[opts.surface];
|
|
62
225
|
}
|
|
63
|
-
return
|
|
64
|
-
});
|
|
65
|
-
const globalFlags = program.options.map((opt) => ({
|
|
66
|
-
flags: opt.flags,
|
|
67
|
-
description: opt.description,
|
|
68
|
-
}));
|
|
226
|
+
return picked;
|
|
227
|
+
})();
|
|
69
228
|
const roles = [...new Set(catalog.map((e) => e.role ?? 'other'))].sort();
|
|
70
|
-
|
|
229
|
+
const payload = {
|
|
71
230
|
version: program.version(),
|
|
72
|
-
|
|
231
|
+
schemaVersion: '2',
|
|
232
|
+
agentGuide: AGENT_GUIDE,
|
|
73
233
|
identity: IDENTITY,
|
|
74
|
-
surfaces:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
protocol: 'stdio (default) or --port <n> for HTTP',
|
|
78
|
-
tools: MCP_TOOLS,
|
|
79
|
-
resources: ['switchbot://events'],
|
|
80
|
-
},
|
|
81
|
-
mqtt: {
|
|
82
|
-
mode: 'consumer',
|
|
83
|
-
authSource: 'SWITCHBOT_TOKEN + SWITCHBOT_SECRET (auto-provisioned via POST /v1.1/iot/credential)',
|
|
84
|
-
cliCmd: 'events mqtt-tail',
|
|
85
|
-
mcpResource: 'switchbot://events',
|
|
86
|
-
protocol: 'MQTTS with TLS client certificates (AWS IoT)',
|
|
87
|
-
},
|
|
88
|
-
plan: {
|
|
89
|
-
schemaCmd: 'plan schema',
|
|
90
|
-
validateCmd: 'plan validate -',
|
|
91
|
-
runCmd: 'plan run -',
|
|
92
|
-
},
|
|
93
|
-
cli: {
|
|
94
|
-
catalogCmd: 'schema export',
|
|
95
|
-
discoveryCmd: 'capabilities',
|
|
96
|
-
healthCmd: 'doctor --json',
|
|
97
|
-
helpFlag: '--help',
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
commands,
|
|
101
|
-
globalFlags,
|
|
234
|
+
surfaces: filteredSurfaces,
|
|
235
|
+
commands: compact ? leaves : fullCommands,
|
|
236
|
+
...(globalFlags ? { globalFlags } : {}),
|
|
102
237
|
catalog: {
|
|
103
238
|
typeCount: catalog.length,
|
|
104
239
|
roles,
|
|
105
240
|
destructiveCommandCount: catalog.reduce((n, e) => n + e.commands.filter((c) => c.destructive).length, 0),
|
|
106
241
|
readOnlyTypeCount: catalog.filter((e) => e.readOnly).length,
|
|
107
242
|
},
|
|
108
|
-
}
|
|
243
|
+
};
|
|
244
|
+
if (!compact)
|
|
245
|
+
payload.generatedAt = new Date().toISOString();
|
|
246
|
+
const projected = opts.project
|
|
247
|
+
? projectObject(payload, opts.project.split(',').map((s) => s.trim()).filter(Boolean))
|
|
248
|
+
: payload;
|
|
249
|
+
printJson(projected);
|
|
109
250
|
});
|
|
110
251
|
}
|
package/dist/commands/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import readline from 'node:readline';
|
|
2
3
|
import { execFileSync } from 'node:child_process';
|
|
3
4
|
import { stringArg } from '../utils/arg-parsers.js';
|
|
4
|
-
import {
|
|
5
|
+
import { intArg } from '../utils/arg-parsers.js';
|
|
6
|
+
import { saveConfig, showConfig, listProfiles, readProfileMeta } from '../config.js';
|
|
5
7
|
import { isJsonMode, printJson } from '../utils/output.js';
|
|
6
8
|
import chalk from 'chalk';
|
|
7
9
|
function parseEnvFile(file) {
|
|
@@ -31,6 +33,62 @@ function readFromOp(ref) {
|
|
|
31
33
|
const stdout = execFileSync('op', ['read', ref], { encoding: 'utf-8' });
|
|
32
34
|
return stdout.trim();
|
|
33
35
|
}
|
|
36
|
+
// Replace raw token/secret positional slots in process.argv with "***" so
|
|
37
|
+
// neither verbose traces nor crash dumps nor any later inspector observe them.
|
|
38
|
+
function scrubArgvCredentials() {
|
|
39
|
+
const argv = process.argv;
|
|
40
|
+
for (let i = 2; i < argv.length - 2; i++) {
|
|
41
|
+
if (argv[i] === 'config' && argv[i + 1] === 'set-token') {
|
|
42
|
+
// Slots i+2 and i+3 (if not option flags) are token/secret.
|
|
43
|
+
for (const off of [2, 3]) {
|
|
44
|
+
const slot = i + off;
|
|
45
|
+
if (slot < argv.length && !argv[slot].startsWith('-')) {
|
|
46
|
+
argv[slot] = '***';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function promptSecret(question) {
|
|
54
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
55
|
+
const stdoutAny = process.stdout;
|
|
56
|
+
const mutableStdout = process.stderr;
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
process.stderr.write(question);
|
|
59
|
+
const stdin = process.stdin;
|
|
60
|
+
let answer = '';
|
|
61
|
+
const onData = (chunk) => {
|
|
62
|
+
const s = chunk.toString('utf-8');
|
|
63
|
+
for (const ch of s) {
|
|
64
|
+
if (ch === '\r' || ch === '\n') {
|
|
65
|
+
stdin.removeListener('data', onData);
|
|
66
|
+
if (stdin.setRawMode)
|
|
67
|
+
stdin.setRawMode(false);
|
|
68
|
+
stdin.pause();
|
|
69
|
+
process.stderr.write('\n');
|
|
70
|
+
rl.close();
|
|
71
|
+
resolve(answer);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (ch === '\u0003') {
|
|
75
|
+
process.exit(130);
|
|
76
|
+
}
|
|
77
|
+
if (ch === '\u007f' || ch === '\b') {
|
|
78
|
+
answer = answer.slice(0, -1);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
answer += ch;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
if (stdin.setRawMode)
|
|
85
|
+
stdin.setRawMode(true);
|
|
86
|
+
stdin.resume();
|
|
87
|
+
stdin.on('data', onData);
|
|
88
|
+
void stdoutAny;
|
|
89
|
+
void mutableStdout;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
34
92
|
export function registerConfigCommand(program) {
|
|
35
93
|
const config = program
|
|
36
94
|
.command('config')
|
|
@@ -53,18 +111,38 @@ Obtain your token/secret from the SwitchBot mobile app:
|
|
|
53
111
|
.option('--from-env-file <path>', 'Read SWITCHBOT_TOKEN and SWITCHBOT_SECRET from a dotenv file', stringArg('--from-env-file'))
|
|
54
112
|
.option('--from-op <tokenRef>', 'Read token via 1Password CLI (op read). Pair with --op-secret <ref>', stringArg('--from-op'))
|
|
55
113
|
.option('--op-secret <secretRef>', '1Password reference for the secret, used with --from-op', stringArg('--op-secret'))
|
|
114
|
+
.option('--label <text>', 'Human-friendly label for this profile (shown in config show / list-profiles)', stringArg('--label'))
|
|
115
|
+
.option('--description <text>', 'Longer description, e.g. "home account" or "work devices"', stringArg('--description'))
|
|
116
|
+
.option('--daily-cap <n>', 'Local cap on SwitchBot API calls per UTC day for this profile', intArg('--daily-cap', { min: 1 }))
|
|
117
|
+
.option('--default-flags <csv>', 'Comma-separated flags auto-applied for this profile (e.g. "--audit-log")', stringArg('--default-flags'))
|
|
56
118
|
.addHelpText('after', `
|
|
57
119
|
Examples:
|
|
58
|
-
|
|
59
|
-
$ switchbot
|
|
120
|
+
# Interactive (recommended) — credentials never touch shell history / ps listing
|
|
121
|
+
$ switchbot config set-token
|
|
122
|
+
Token: ****
|
|
123
|
+
Secret: ****
|
|
124
|
+
|
|
125
|
+
# Import from dotenv / 1Password (non-interactive, still safe)
|
|
60
126
|
$ switchbot config set-token --from-env-file ./.env
|
|
61
127
|
$ switchbot config set-token --from-op op://vault/switchbot/token --op-secret op://vault/switchbot/secret
|
|
62
128
|
|
|
129
|
+
# Advanced / non-interactive (DISCOURAGED — leaks to shell history)
|
|
130
|
+
$ switchbot config set-token <token> <secret>
|
|
131
|
+
$ switchbot --profile work config set-token <token> <secret>
|
|
132
|
+
|
|
63
133
|
Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<name>.json.
|
|
64
134
|
`)
|
|
65
135
|
.action(async (tokenArg, secretArg, options) => {
|
|
66
136
|
let token = tokenArg;
|
|
67
137
|
let secret = secretArg;
|
|
138
|
+
const hadPositional = tokenArg !== undefined && secretArg !== undefined;
|
|
139
|
+
// Scrub early: commander has already parsed the values, so we can safely
|
|
140
|
+
// rewrite argv before anything else (verbose trace, crash dumps, …) sees it.
|
|
141
|
+
if (hadPositional) {
|
|
142
|
+
scrubArgvCredentials();
|
|
143
|
+
console.error('⚠ Passing token/secret as positional arguments is discouraged — they may be persisted in shell history, process listings, and agent logs.');
|
|
144
|
+
console.error(' Prefer: switchbot config set-token (interactive), --from-env-file, or --from-op.');
|
|
145
|
+
}
|
|
68
146
|
if (options.fromEnvFile) {
|
|
69
147
|
if (!fs.existsSync(options.fromEnvFile)) {
|
|
70
148
|
const msg = `--from-env-file: file not found: ${options.fromEnvFile}`;
|
|
@@ -107,8 +185,26 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
107
185
|
process.exit(1);
|
|
108
186
|
}
|
|
109
187
|
}
|
|
188
|
+
// No credentials yet and stdin is a TTY → interactive prompt (safest path).
|
|
189
|
+
if ((!token || !secret) && !options.fromEnvFile && !options.fromOp && process.stdin.isTTY) {
|
|
190
|
+
if (isJsonMode()) {
|
|
191
|
+
const msg = 'Interactive mode cannot run under --json. Provide token/secret via --from-env-file, --from-op, or positional args.';
|
|
192
|
+
console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
|
|
193
|
+
process.exit(2);
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
if (!token)
|
|
197
|
+
token = (await promptSecret('Token: ')).trim();
|
|
198
|
+
if (!secret)
|
|
199
|
+
secret = (await promptSecret('Secret: ')).trim();
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
console.error('Interactive prompt failed.');
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
110
206
|
if (!token || !secret) {
|
|
111
|
-
const msg = 'Missing token/secret.
|
|
207
|
+
const msg = 'Missing token/secret. Run interactively, or use --from-env-file / --from-op, or pass positional arguments (discouraged).';
|
|
112
208
|
if (isJsonMode()) {
|
|
113
209
|
console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
|
|
114
210
|
}
|
|
@@ -117,7 +213,19 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
117
213
|
}
|
|
118
214
|
process.exit(2);
|
|
119
215
|
}
|
|
120
|
-
saveConfig(token, secret
|
|
216
|
+
saveConfig(token, secret, {
|
|
217
|
+
label: options.label,
|
|
218
|
+
description: options.description,
|
|
219
|
+
limits: options.dailyCap ? { dailyCap: Number.parseInt(options.dailyCap, 10) } : undefined,
|
|
220
|
+
defaults: options.defaultFlags
|
|
221
|
+
? {
|
|
222
|
+
flags: options.defaultFlags
|
|
223
|
+
.split(',')
|
|
224
|
+
.map((s) => s.trim())
|
|
225
|
+
.filter(Boolean),
|
|
226
|
+
}
|
|
227
|
+
: undefined,
|
|
228
|
+
});
|
|
121
229
|
if (isJsonMode()) {
|
|
122
230
|
printJson({ ok: true, message: 'credentials saved' });
|
|
123
231
|
}
|
|
@@ -133,18 +241,33 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
133
241
|
});
|
|
134
242
|
config
|
|
135
243
|
.command('list-profiles')
|
|
136
|
-
.description('List named profiles under ~/.switchbot/profiles/')
|
|
244
|
+
.description('List named profiles under ~/.switchbot/profiles/ (with labels and daily caps)')
|
|
137
245
|
.action(() => {
|
|
138
246
|
const profiles = listProfiles();
|
|
247
|
+
const enriched = profiles.map((p) => {
|
|
248
|
+
const meta = readProfileMeta(p);
|
|
249
|
+
return {
|
|
250
|
+
name: p,
|
|
251
|
+
label: meta?.label,
|
|
252
|
+
description: meta?.description,
|
|
253
|
+
dailyCap: meta?.limits?.dailyCap,
|
|
254
|
+
};
|
|
255
|
+
});
|
|
139
256
|
if (isJsonMode()) {
|
|
140
|
-
printJson({ profiles });
|
|
257
|
+
printJson({ profiles: enriched });
|
|
141
258
|
return;
|
|
142
259
|
}
|
|
143
260
|
if (profiles.length === 0) {
|
|
144
261
|
console.log('No profiles. Create one with: switchbot --profile <name> config set-token ...');
|
|
145
262
|
return;
|
|
146
263
|
}
|
|
147
|
-
for (const p of
|
|
148
|
-
|
|
264
|
+
for (const p of enriched) {
|
|
265
|
+
const bits = [p.name];
|
|
266
|
+
if (p.label)
|
|
267
|
+
bits.push(`— ${p.label}`);
|
|
268
|
+
if (p.dailyCap)
|
|
269
|
+
bits.push(`[dailyCap=${p.dailyCap}]`);
|
|
270
|
+
console.log(bits.join(' '));
|
|
271
|
+
}
|
|
149
272
|
});
|
|
150
273
|
}
|
package/dist/commands/devices.js
CHANGED
|
@@ -200,6 +200,10 @@ Examples:
|
|
|
200
200
|
.description('Query the real-time status of a specific device')
|
|
201
201
|
.argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)')
|
|
202
202
|
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
|
|
203
|
+
.option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy'))
|
|
204
|
+
.option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
|
|
205
|
+
.option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
|
|
206
|
+
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
|
|
203
207
|
.option('--ids <list>', 'Comma-separated device IDs for batch status (incompatible with --name)', stringArg('--ids'))
|
|
204
208
|
.addHelpText('after', `
|
|
205
209
|
Status fields vary by device type. To discover them without a live call:
|
|
@@ -261,7 +265,12 @@ Examples:
|
|
|
261
265
|
}
|
|
262
266
|
return;
|
|
263
267
|
}
|
|
264
|
-
const deviceId = resolveDeviceId(deviceIdArg, options.name
|
|
268
|
+
const deviceId = resolveDeviceId(deviceIdArg, options.name, {
|
|
269
|
+
strategy: options.nameStrategy ?? 'fuzzy',
|
|
270
|
+
type: options.nameType,
|
|
271
|
+
category: options.nameCategory,
|
|
272
|
+
room: options.nameRoom,
|
|
273
|
+
});
|
|
265
274
|
const body = await fetchDeviceStatus(deviceId);
|
|
266
275
|
const fetchedAt = new Date().toISOString();
|
|
267
276
|
const fmt = resolveFormat();
|
|
@@ -292,9 +301,13 @@ Examples:
|
|
|
292
301
|
.argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
|
|
293
302
|
.argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).')
|
|
294
303
|
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
|
|
304
|
+
.option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default for command: require-unique)', stringArg('--name-strategy'))
|
|
305
|
+
.option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
|
|
306
|
+
.option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
|
|
307
|
+
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
|
|
295
308
|
.option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
|
|
296
309
|
.option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
|
|
297
|
-
.option('--idempotency-key <key>', '
|
|
310
|
+
.option('--idempotency-key <key>', 'Client-supplied key to dedupe retries. process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key'))
|
|
298
311
|
.addHelpText('after', `
|
|
299
312
|
────────────────────────────────────────────────────────────────────────
|
|
300
313
|
For the full list of commands a specific device supports — and their
|
|
@@ -367,7 +380,13 @@ Examples:
|
|
|
367
380
|
cmd = cmdArg;
|
|
368
381
|
effectiveDeviceIdArg = deviceIdArg;
|
|
369
382
|
}
|
|
370
|
-
const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name
|
|
383
|
+
const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name, {
|
|
384
|
+
// Mutating command → default require-unique (never silently pick between ambiguous matches).
|
|
385
|
+
strategy: options.nameStrategy ?? 'require-unique',
|
|
386
|
+
type: options.nameType,
|
|
387
|
+
category: options.nameCategory,
|
|
388
|
+
room: options.nameRoom,
|
|
389
|
+
});
|
|
371
390
|
if (!getCachedDevice(deviceId)) {
|
|
372
391
|
console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`);
|
|
373
392
|
}
|
|
@@ -469,10 +488,19 @@ Examples:
|
|
|
469
488
|
}
|
|
470
489
|
const body = await executeCommand(deviceId, cmd, parsedParam, options.type, undefined, { idempotencyKey: options.idempotencyKey });
|
|
471
490
|
const isIr = getCachedDevice(deviceId)?.category === 'ir';
|
|
491
|
+
const verification = isIr
|
|
492
|
+
? {
|
|
493
|
+
verifiable: false,
|
|
494
|
+
reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
|
|
495
|
+
suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
|
|
496
|
+
}
|
|
497
|
+
: null;
|
|
472
498
|
if (isJsonMode()) {
|
|
473
499
|
const result = { ok: true, command: cmd, deviceId };
|
|
474
|
-
if (isIr)
|
|
500
|
+
if (isIr) {
|
|
475
501
|
result.subKind = 'ir-no-feedback';
|
|
502
|
+
result.verification = verification;
|
|
503
|
+
}
|
|
476
504
|
if (body && typeof body === 'object' && Object.keys(body).length > 0) {
|
|
477
505
|
Object.assign(result, body);
|
|
478
506
|
}
|
|
@@ -481,6 +509,7 @@ Examples:
|
|
|
481
509
|
}
|
|
482
510
|
if (isIr) {
|
|
483
511
|
console.log(`→ IR signal sent: ${cmd} (no feedback — fire-and-forget)`);
|
|
512
|
+
console.error('⚠ IR (unverifiable) — no receipt acknowledgment. Confirm state manually.');
|
|
484
513
|
}
|
|
485
514
|
else {
|
|
486
515
|
console.log(`✓ Command sent: ${cmd}`);
|
|
@@ -581,6 +610,10 @@ Examples:
|
|
|
581
610
|
.description('Describe a device by ID: metadata + supported commands + status fields (1 API call)')
|
|
582
611
|
.argument('[deviceId]', 'Target device ID (or use --name)')
|
|
583
612
|
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
|
|
613
|
+
.option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy'))
|
|
614
|
+
.option('--name-type <type>', 'Narrow --name by device type', stringArg('--name-type'))
|
|
615
|
+
.option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
|
|
616
|
+
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
|
|
584
617
|
.option('--live', 'Also fetch live status values and merge them into capabilities (costs 1 extra API call)')
|
|
585
618
|
.addHelpText('after', `
|
|
586
619
|
Makes a GET /v1.1/devices call to look up the device's type, then prints its
|
|
@@ -612,7 +645,12 @@ Examples:
|
|
|
612
645
|
`)
|
|
613
646
|
.action(async (deviceIdArg, options) => {
|
|
614
647
|
try {
|
|
615
|
-
const deviceId = resolveDeviceId(deviceIdArg, options.name
|
|
648
|
+
const deviceId = resolveDeviceId(deviceIdArg, options.name, {
|
|
649
|
+
strategy: options.nameStrategy ?? 'fuzzy',
|
|
650
|
+
type: options.nameType,
|
|
651
|
+
category: options.nameCategory,
|
|
652
|
+
room: options.nameRoom,
|
|
653
|
+
});
|
|
616
654
|
const result = await describeDevice(deviceId, options);
|
|
617
655
|
const { device, isPhysical, typeName, controlType, catalog, capabilities, source, suggestedActions: picks } = result;
|
|
618
656
|
if (isJsonMode()) {
|