@switchbot/openapi-cli 2.2.1 → 2.4.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 +2 -0
- package/dist/api/client.js +33 -1
- package/dist/commands/agent-bootstrap.js +125 -0
- package/dist/commands/batch.js +108 -14
- package/dist/commands/capabilities.js +200 -62
- package/dist/commands/config.js +132 -9
- package/dist/commands/devices.js +85 -12
- package/dist/commands/doctor.js +105 -14
- package/dist/commands/events.js +53 -4
- package/dist/commands/expand.js +1 -77
- package/dist/commands/history.js +124 -2
- package/dist/commands/mcp.js +81 -2
- package/dist/commands/quota.js +4 -2
- package/dist/commands/schema.js +95 -5
- package/dist/config.js +71 -2
- package/dist/devices/history-query.js +181 -0
- package/dist/devices/param-validator.js +263 -0
- package/dist/index.js +12 -2
- package/dist/lib/devices.js +34 -15
- package/dist/lib/idempotency.js +56 -22
- package/dist/mcp/device-history.js +75 -7
- package/dist/utils/arg-parsers.js +4 -1
- package/dist/utils/audit.js +66 -1
- package/dist/utils/flags.js +18 -0
- package/dist/utils/name-resolver.js +76 -28
- package/dist/utils/output.js +115 -19
- package/dist/utils/quota.js +14 -0
- package/dist/utils/redact.js +68 -0
- package/package.json +1 -1
package/dist/commands/history.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
4
|
-
import { printJson, isJsonMode, handleError } from '../utils/output.js';
|
|
5
|
-
import { readAudit } from '../utils/audit.js';
|
|
4
|
+
import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
5
|
+
import { readAudit, verifyAudit } from '../utils/audit.js';
|
|
6
6
|
import { executeCommand } from '../lib/devices.js';
|
|
7
|
+
import { queryDeviceHistory, queryDeviceHistoryStats, } from '../devices/history-query.js';
|
|
7
8
|
const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
|
|
8
9
|
export function registerHistoryCommand(program) {
|
|
9
10
|
const history = program
|
|
@@ -101,4 +102,125 @@ Examples:
|
|
|
101
102
|
handleError(err);
|
|
102
103
|
}
|
|
103
104
|
});
|
|
105
|
+
history
|
|
106
|
+
.command('range')
|
|
107
|
+
.description('Query time-ranged device history from JSONL storage (populated by events mqtt-tail / MCP)')
|
|
108
|
+
.argument('<deviceId>', 'Device ID to query')
|
|
109
|
+
.option('--since <duration>', 'Relative window ending now, e.g. "30s", "15m", "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
|
|
110
|
+
.option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
|
|
111
|
+
.option('--to <iso>', 'Range end (ISO-8601)', stringArg('--to'))
|
|
112
|
+
.option('--field <name>', 'Project a payload field (repeat to keep multiple)', (v, acc = []) => acc.concat(v), [])
|
|
113
|
+
.option('--limit <n>', 'Maximum records to return (default 1000)', intArg('--limit', { min: 1 }))
|
|
114
|
+
.addHelpText('after', `
|
|
115
|
+
History is the append-only JSONL mirror of the per-device ring buffer: every
|
|
116
|
+
'events mqtt-tail' event and every MCP tool status-refresh is written to
|
|
117
|
+
~/.switchbot/device-history/<deviceId>.jsonl (rotates at 50MB × 3 files).
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
$ switchbot history range <id> --since 7d --json
|
|
121
|
+
$ switchbot history range <id> --since 1h --field temperature --field humidity
|
|
122
|
+
$ switchbot history range <id> --from 2026-04-18T00:00:00Z --to 2026-04-19T00:00:00Z
|
|
123
|
+
`)
|
|
124
|
+
.action(async (deviceId, options) => {
|
|
125
|
+
// Usage-level validation: keep synchronous and pre-query so handleError
|
|
126
|
+
// maps these to exit 2 (via UsageError) rather than runtime exit 1.
|
|
127
|
+
if (options.since && (options.from || options.to)) {
|
|
128
|
+
handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const records = await queryDeviceHistory(deviceId, {
|
|
132
|
+
since: options.since,
|
|
133
|
+
from: options.from,
|
|
134
|
+
to: options.to,
|
|
135
|
+
fields: options.field ?? [],
|
|
136
|
+
limit: options.limit !== undefined ? Number(options.limit) : undefined,
|
|
137
|
+
});
|
|
138
|
+
if (isJsonMode()) {
|
|
139
|
+
printJson({ deviceId, count: records.length, records });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (records.length === 0) {
|
|
143
|
+
console.log(`(no history records for ${deviceId} in requested range)`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const r of records) {
|
|
147
|
+
const payloadStr = JSON.stringify(r.payload);
|
|
148
|
+
console.log(`${r.t} ${r.topic} ${payloadStr}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
// Convert history-query's plain Error range messages into UsageError so
|
|
153
|
+
// they exit 2 instead of 1.
|
|
154
|
+
if (err instanceof Error && /^(Invalid --|--from|--since)/i.test(err.message)) {
|
|
155
|
+
handleError(new UsageError(err.message));
|
|
156
|
+
}
|
|
157
|
+
handleError(err);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
history
|
|
161
|
+
.command('stats')
|
|
162
|
+
.description('Show on-disk size + record counts for a device history')
|
|
163
|
+
.argument('<deviceId>', 'Device ID to inspect')
|
|
164
|
+
.action((deviceId) => {
|
|
165
|
+
try {
|
|
166
|
+
const stats = queryDeviceHistoryStats(deviceId);
|
|
167
|
+
if (isJsonMode()) {
|
|
168
|
+
printJson(stats);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
console.log(`Device: ${stats.deviceId}`);
|
|
172
|
+
console.log(`History dir: ${stats.historyDir}`);
|
|
173
|
+
console.log(`JSONL files: ${stats.fileCount} (${stats.jsonlFiles.join(', ') || '—'})`);
|
|
174
|
+
console.log(`Total size: ${stats.totalBytes.toLocaleString()} bytes`);
|
|
175
|
+
console.log(`Record count: ${stats.recordCount}`);
|
|
176
|
+
console.log(`Oldest: ${stats.oldest ?? '—'}`);
|
|
177
|
+
console.log(`Newest: ${stats.newest ?? '—'}`);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
handleError(err);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
history
|
|
184
|
+
.command('verify')
|
|
185
|
+
.description('Check the audit log for malformed lines and schema-version drift')
|
|
186
|
+
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
|
|
187
|
+
.addHelpText('after', `
|
|
188
|
+
See docs/audit-log.md for the audit log format. Exit code:
|
|
189
|
+
0 every line parses and carries the current auditVersion
|
|
190
|
+
1 one or more lines are malformed OR the file is missing
|
|
191
|
+
2 (usage) — not emitted by this subcommand
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
$ switchbot history verify
|
|
195
|
+
$ switchbot history verify --file ./custom.log --json
|
|
196
|
+
`)
|
|
197
|
+
.action((options) => {
|
|
198
|
+
const file = options.file ?? DEFAULT_AUDIT;
|
|
199
|
+
const report = verifyAudit(file);
|
|
200
|
+
if (isJsonMode()) {
|
|
201
|
+
printJson(report);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
console.log(`Audit log: ${report.file}`);
|
|
205
|
+
console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`);
|
|
206
|
+
console.log(`Malformed: ${report.malformedLines}`);
|
|
207
|
+
console.log(`Unversioned: ${report.unversionedEntries}`);
|
|
208
|
+
const versions = Object.entries(report.versionCounts)
|
|
209
|
+
.map(([v, n]) => `${v}:${n}`)
|
|
210
|
+
.join(', ');
|
|
211
|
+
console.log(`Version counts: ${versions || '—'}`);
|
|
212
|
+
if (report.earliest)
|
|
213
|
+
console.log(`Earliest: ${report.earliest}`);
|
|
214
|
+
if (report.latest)
|
|
215
|
+
console.log(`Latest: ${report.latest}`);
|
|
216
|
+
if (report.problems.length > 0) {
|
|
217
|
+
console.log('\nProblems:');
|
|
218
|
+
for (const p of report.problems) {
|
|
219
|
+
console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const ok = report.malformedLines === 0 && report.problems.length === 0;
|
|
224
|
+
process.exit(ok ? 0 : 1);
|
|
225
|
+
});
|
|
104
226
|
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -10,6 +10,7 @@ import { findCatalogEntry } from '../devices/catalog.js';
|
|
|
10
10
|
import { getCachedDevice } from '../devices/cache.js';
|
|
11
11
|
import { EventSubscriptionManager } from '../mcp/events-subscription.js';
|
|
12
12
|
import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
13
|
+
import { queryDeviceHistory } from '../devices/history-query.js';
|
|
13
14
|
import { todayUsage } from '../utils/quota.js';
|
|
14
15
|
import { describeCache } from '../devices/cache.js';
|
|
15
16
|
import { withRequestContext } from '../lib/request-context.js';
|
|
@@ -146,6 +147,47 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
146
147
|
structuredContent: result,
|
|
147
148
|
};
|
|
148
149
|
});
|
|
150
|
+
// ---- query_device_history --------------------------------------------------
|
|
151
|
+
server.registerTool('query_device_history', {
|
|
152
|
+
title: 'Query time-ranged device history',
|
|
153
|
+
description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) ' +
|
|
154
|
+
'filtered by a relative duration (since) or absolute ISO-8601 range (from/to). ' +
|
|
155
|
+
'No API call — zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
|
|
156
|
+
inputSchema: {
|
|
157
|
+
deviceId: z.string().describe('Device ID to query'),
|
|
158
|
+
since: z.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
|
|
159
|
+
from: z.string().optional().describe('Range start (ISO-8601).'),
|
|
160
|
+
to: z.string().optional().describe('Range end (ISO-8601).'),
|
|
161
|
+
fields: z.array(z.string()).optional().describe('Project these payload fields; omit for the full payload.'),
|
|
162
|
+
limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000).'),
|
|
163
|
+
},
|
|
164
|
+
outputSchema: {
|
|
165
|
+
deviceId: z.string(),
|
|
166
|
+
count: z.number().int(),
|
|
167
|
+
records: z.array(z.object({
|
|
168
|
+
t: z.string(),
|
|
169
|
+
topic: z.string(),
|
|
170
|
+
deviceType: z.string().optional(),
|
|
171
|
+
payload: z.unknown(),
|
|
172
|
+
})),
|
|
173
|
+
},
|
|
174
|
+
}, async ({ deviceId, since, from, to, fields, limit }) => {
|
|
175
|
+
if (since && (from || to)) {
|
|
176
|
+
return mcpError('usage', 2, '--since is mutually exclusive with --from/--to.');
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit });
|
|
180
|
+
const result = { deviceId, count: records.length, records };
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
183
|
+
structuredContent: result,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
const msg = err instanceof Error ? err.message : 'history query failed';
|
|
188
|
+
return mcpError('usage', 2, msg);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
149
191
|
// ---- send_command ---------------------------------------------------------
|
|
150
192
|
server.registerTool('send_command', {
|
|
151
193
|
title: 'Send a control command to a device',
|
|
@@ -167,14 +209,26 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
167
209
|
.optional()
|
|
168
210
|
.default(false)
|
|
169
211
|
.describe('Required true for destructive commands (unlock, garage open, createKey, ...)'),
|
|
212
|
+
idempotencyKey: z
|
|
213
|
+
.string()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe('Deduplication key — repeat calls with the same key within 60s replay the first result (adds replayed:true). Same key + different (command, parameter) within 60s returns an idempotency_conflict guard error.'),
|
|
170
216
|
},
|
|
171
217
|
outputSchema: {
|
|
172
218
|
ok: z.literal(true),
|
|
173
219
|
command: z.string(),
|
|
174
220
|
deviceId: z.string(),
|
|
175
221
|
result: z.unknown().describe('API response body from SwitchBot'),
|
|
222
|
+
verification: z
|
|
223
|
+
.object({
|
|
224
|
+
verifiable: z.boolean(),
|
|
225
|
+
reason: z.string(),
|
|
226
|
+
suggestedFollowup: z.string(),
|
|
227
|
+
})
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('Present when the target is an IR device. IR is unidirectional — agents should treat the success as "signal sent" not "state changed".'),
|
|
176
230
|
},
|
|
177
|
-
}, async ({ deviceId, command, parameter, commandType, confirm }) => {
|
|
231
|
+
}, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey }) => {
|
|
178
232
|
const effectiveType = commandType ?? 'command';
|
|
179
233
|
// Resolve the device's catalog type via cache or a fresh lookup so we
|
|
180
234
|
// can evaluate destructive/validation without an extra round-trip if
|
|
@@ -217,8 +271,33 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
217
271
|
if (!validation.ok) {
|
|
218
272
|
return mcpError('usage', 2, validation.error.message, { hint: validation.error.hint, context: { validationKind: validation.error.kind } });
|
|
219
273
|
}
|
|
220
|
-
|
|
274
|
+
let result;
|
|
275
|
+
try {
|
|
276
|
+
result = await executeCommand(deviceId, command, parameter, effectiveType, undefined, {
|
|
277
|
+
idempotencyKey,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
if (err instanceof Error && err.name === 'IdempotencyConflictError') {
|
|
282
|
+
return mcpError('guard', 2, err.message, {
|
|
283
|
+
hint: 'Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).',
|
|
284
|
+
context: {
|
|
285
|
+
existingShape: err.existingShape,
|
|
286
|
+
newShape: err.newShape,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
const isIr = getCachedDevice(deviceId)?.category === 'ir';
|
|
221
293
|
const structured = { ok: true, command, deviceId, result };
|
|
294
|
+
if (isIr) {
|
|
295
|
+
structured.verification = {
|
|
296
|
+
verifiable: false,
|
|
297
|
+
reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
|
|
298
|
+
suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
222
301
|
return {
|
|
223
302
|
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
224
303
|
structuredContent: structured,
|
package/dist/commands/quota.js
CHANGED
|
@@ -11,17 +11,19 @@ is a best-effort mirror of the SwitchBot 10,000/day limit — it does not
|
|
|
11
11
|
include requests made outside this CLI (mobile app, other scripts).
|
|
12
12
|
|
|
13
13
|
Subcommands:
|
|
14
|
-
status Show today's usage and the last 7 days
|
|
14
|
+
status Show today's usage and the last 7 days (alias: show)
|
|
15
15
|
reset Delete the local counter file
|
|
16
16
|
|
|
17
17
|
Examples:
|
|
18
18
|
$ switchbot quota status
|
|
19
|
+
$ switchbot quota show # alias of 'status'
|
|
19
20
|
$ switchbot quota status --json
|
|
20
21
|
$ switchbot quota reset
|
|
21
22
|
`);
|
|
22
23
|
quota
|
|
23
24
|
.command('status')
|
|
24
|
-
.
|
|
25
|
+
.alias('show')
|
|
26
|
+
.description("Show today's usage and the last 7 days (alias: show)")
|
|
25
27
|
.action(() => {
|
|
26
28
|
const usage = todayUsage();
|
|
27
29
|
const history = loadQuota();
|
package/dist/commands/schema.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
2
2
|
import { printJson } from '../utils/output.js';
|
|
3
3
|
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
4
|
+
import { loadCache } from '../devices/cache.js';
|
|
4
5
|
function toSchemaEntry(e) {
|
|
5
6
|
return {
|
|
6
7
|
type: e.type,
|
|
@@ -24,6 +25,30 @@ function toSchemaCommand(c) {
|
|
|
24
25
|
...(c.exampleParams ? { exampleParams: c.exampleParams } : {}),
|
|
25
26
|
};
|
|
26
27
|
}
|
|
28
|
+
function toCompactEntry(e) {
|
|
29
|
+
return {
|
|
30
|
+
type: e.type,
|
|
31
|
+
category: e.category,
|
|
32
|
+
role: e.role ?? null,
|
|
33
|
+
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
|
+
})),
|
|
41
|
+
statusFields: e.statusFields ?? [],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function projectFields(entry, fields) {
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const f of fields) {
|
|
47
|
+
if (f in entry)
|
|
48
|
+
out[f] = entry[f];
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
27
52
|
export function registerSchemaCommand(program) {
|
|
28
53
|
const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'];
|
|
29
54
|
const CATEGORIES = ['physical', 'ir'];
|
|
@@ -32,20 +57,41 @@ export function registerSchemaCommand(program) {
|
|
|
32
57
|
.description('Export the device catalog as structured JSON (for agent prompts / tooling)');
|
|
33
58
|
schema
|
|
34
59
|
.command('export')
|
|
35
|
-
.description('Print the
|
|
60
|
+
.description('Print the catalog as structured JSON (one object per type)')
|
|
36
61
|
.option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type'))
|
|
62
|
+
.option('--types <csv>', 'Restrict to multiple device types (comma-separated)', stringArg('--types'))
|
|
37
63
|
.option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES))
|
|
38
64
|
.option('--category <cat>', 'Restrict to "physical" or "ir"', enumArg('--category', CATEGORIES))
|
|
65
|
+
.option('--compact', 'Drop descriptions/aliases/example params — emit ~60% smaller payload. Useful for agent prompts.')
|
|
66
|
+
.option('--used', 'Restrict to device types present in the local devices cache (run "devices list" first)')
|
|
67
|
+
.option('--project <csv>', 'Project per-type fields (e.g. --project type,commands,statusFields)', stringArg('--project'))
|
|
39
68
|
.addHelpText('after', `
|
|
40
69
|
Output is always JSON (this command ignores --format). The output is a
|
|
41
70
|
catalog export — not a formal JSON Schema standard document — suitable for
|
|
42
71
|
pre-baking LLM prompts or regenerating docs when the catalog changes.
|
|
43
72
|
|
|
73
|
+
Size tips:
|
|
74
|
+
--compact --used Smallest realistic payload for a given account
|
|
75
|
+
(< 15 KB on most accounts).
|
|
76
|
+
--fields type,commands Strip statusFields / role / etc. when only
|
|
77
|
+
commands are needed.
|
|
78
|
+
--type + --compact Inspect one type with minimum footprint.
|
|
79
|
+
|
|
80
|
+
Common top-level fields:
|
|
81
|
+
schemaVersion CLI schema version (stable for agent contracts)
|
|
82
|
+
data.version Catalog schema version
|
|
83
|
+
data.types Array of SchemaEntry (or CompactSchemaEntry with --compact)
|
|
84
|
+
data._fetchedAt CLI-added; present on live-query responses ('devices status'),
|
|
85
|
+
not on this offline export.
|
|
86
|
+
|
|
44
87
|
Examples:
|
|
45
88
|
$ switchbot schema export > catalog.json
|
|
46
|
-
$ switchbot schema export --
|
|
47
|
-
$ switchbot schema export --
|
|
89
|
+
$ switchbot schema export --compact --used | wc -c # small prompt-ready payload
|
|
90
|
+
$ switchbot schema export --type Bot | jq '.data.types[0].commands'
|
|
91
|
+
$ switchbot schema export --types "Bot,Curtain,Color Bulb"
|
|
92
|
+
$ switchbot schema export --role lighting | jq '[.data.types[].type]'
|
|
48
93
|
$ switchbot schema export --role security --category physical
|
|
94
|
+
$ switchbot schema export --project type,commands,statusFields
|
|
49
95
|
`)
|
|
50
96
|
.action((options) => {
|
|
51
97
|
const catalog = getEffectiveCatalog();
|
|
@@ -55,6 +101,11 @@ Examples:
|
|
|
55
101
|
filtered = filtered.filter((e) => e.type.toLowerCase() === q ||
|
|
56
102
|
(e.aliases ?? []).some((a) => a.toLowerCase() === q));
|
|
57
103
|
}
|
|
104
|
+
if (options.types) {
|
|
105
|
+
const set = new Set(options.types.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean));
|
|
106
|
+
filtered = filtered.filter((e) => set.has(e.type.toLowerCase()) ||
|
|
107
|
+
(e.aliases ?? []).some((a) => set.has(a.toLowerCase())));
|
|
108
|
+
}
|
|
58
109
|
if (options.role) {
|
|
59
110
|
const q = options.role.toLowerCase();
|
|
60
111
|
filtered = filtered.filter((e) => (e.role ?? 'other') === q);
|
|
@@ -63,11 +114,50 @@ Examples:
|
|
|
63
114
|
const q = options.category.toLowerCase();
|
|
64
115
|
filtered = filtered.filter((e) => e.category === q);
|
|
65
116
|
}
|
|
117
|
+
if (options.used) {
|
|
118
|
+
const cache = loadCache();
|
|
119
|
+
if (cache) {
|
|
120
|
+
const usedTypes = new Set(Object.values(cache.devices).map((d) => d.type.toLowerCase()));
|
|
121
|
+
filtered = filtered.filter((e) => usedTypes.has(e.type.toLowerCase()) ||
|
|
122
|
+
(e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase())));
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
filtered = [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const mapped = options.compact
|
|
129
|
+
? filtered.map(toCompactEntry)
|
|
130
|
+
: filtered.map(toSchemaEntry);
|
|
131
|
+
const projected = options.project
|
|
132
|
+
? mapped.map((e) => projectFields(e, options.project.split(',').map((s) => s.trim()).filter(Boolean)))
|
|
133
|
+
: mapped;
|
|
66
134
|
const payload = {
|
|
67
135
|
version: '1.0',
|
|
68
|
-
|
|
69
|
-
types: filtered.map(toSchemaEntry),
|
|
136
|
+
types: projected,
|
|
70
137
|
};
|
|
138
|
+
if (!options.compact) {
|
|
139
|
+
payload.generatedAt = new Date().toISOString();
|
|
140
|
+
payload.cliAddedFields = [
|
|
141
|
+
{
|
|
142
|
+
field: '_fetchedAt',
|
|
143
|
+
appliesTo: ['devices status', 'devices describe'],
|
|
144
|
+
type: 'string (ISO-8601)',
|
|
145
|
+
description: 'CLI-synthesized timestamp indicating when this status response was fetched or served from the cache. Not part of the upstream SwitchBot API.',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
field: 'replayed',
|
|
149
|
+
appliesTo: ['devices command (with --idempotency-key)'],
|
|
150
|
+
type: 'boolean',
|
|
151
|
+
description: 'CLI-synthesized flag — true when the response was served from the idempotency cache instead of re-executing the command.',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
field: 'verification',
|
|
155
|
+
appliesTo: ['devices command'],
|
|
156
|
+
type: 'object',
|
|
157
|
+
description: 'CLI-synthesized receipt-acknowledgment metadata. For IR devices, verifiable:false signals that no device-side confirmation is possible.',
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
}
|
|
71
161
|
printJson(payload);
|
|
72
162
|
});
|
|
73
163
|
}
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { getConfigPath } from './utils/flags.js';
|
|
5
5
|
import { getActiveProfile } from './lib/request-context.js';
|
|
6
|
+
function sanitizeOptionalString(v) {
|
|
7
|
+
if (typeof v !== 'string')
|
|
8
|
+
return undefined;
|
|
9
|
+
const trimmed = v.trim();
|
|
10
|
+
return trimmed ? trimmed : undefined;
|
|
11
|
+
}
|
|
6
12
|
/**
|
|
7
13
|
* Credential file resolution priority:
|
|
8
14
|
* 1. --config <path> (absolute override — wins over everything)
|
|
@@ -85,15 +91,70 @@ export function tryLoadConfig() {
|
|
|
85
91
|
return null;
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
|
-
export function saveConfig(token, secret) {
|
|
94
|
+
export function saveConfig(token, secret, extras) {
|
|
89
95
|
const file = configFilePath();
|
|
90
96
|
const dir = path.dirname(file);
|
|
91
97
|
if (!fs.existsSync(dir)) {
|
|
92
98
|
fs.mkdirSync(dir, { recursive: true });
|
|
93
99
|
}
|
|
94
|
-
|
|
100
|
+
// Merge with existing file so label/limits/defaults aren't dropped when the
|
|
101
|
+
// user just rotates the token.
|
|
102
|
+
let existing = {};
|
|
103
|
+
if (fs.existsSync(file)) {
|
|
104
|
+
try {
|
|
105
|
+
existing = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
existing = {};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const cfg = {
|
|
112
|
+
token,
|
|
113
|
+
secret,
|
|
114
|
+
...(existing.label ? { label: existing.label } : {}),
|
|
115
|
+
...(existing.description ? { description: existing.description } : {}),
|
|
116
|
+
...(existing.limits ? { limits: existing.limits } : {}),
|
|
117
|
+
...(existing.defaults ? { defaults: existing.defaults } : {}),
|
|
118
|
+
};
|
|
119
|
+
if (extras) {
|
|
120
|
+
const label = sanitizeOptionalString(extras.label);
|
|
121
|
+
const description = sanitizeOptionalString(extras.description);
|
|
122
|
+
if (label !== undefined)
|
|
123
|
+
cfg.label = label;
|
|
124
|
+
if (description !== undefined)
|
|
125
|
+
cfg.description = description;
|
|
126
|
+
if (extras.limits)
|
|
127
|
+
cfg.limits = { ...(cfg.limits ?? {}), ...extras.limits };
|
|
128
|
+
if (extras.defaults)
|
|
129
|
+
cfg.defaults = { ...(cfg.defaults ?? {}), ...extras.defaults };
|
|
130
|
+
}
|
|
95
131
|
fs.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
96
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Read a profile's metadata (label / description / limits / defaults) without
|
|
135
|
+
* exposing the token/secret. Returns null when the file is missing or invalid.
|
|
136
|
+
*/
|
|
137
|
+
export function readProfileMeta(profile) {
|
|
138
|
+
const file = profile
|
|
139
|
+
? profileFilePath(profile)
|
|
140
|
+
: path.join(os.homedir(), '.switchbot', 'config.json');
|
|
141
|
+
if (!fs.existsSync(file))
|
|
142
|
+
return null;
|
|
143
|
+
try {
|
|
144
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
145
|
+
const cfg = JSON.parse(raw);
|
|
146
|
+
return {
|
|
147
|
+
label: cfg.label,
|
|
148
|
+
description: cfg.description,
|
|
149
|
+
limits: cfg.limits,
|
|
150
|
+
defaults: cfg.defaults,
|
|
151
|
+
path: file,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
97
158
|
export function showConfig() {
|
|
98
159
|
const envToken = process.env.SWITCHBOT_TOKEN;
|
|
99
160
|
const envSecret = process.env.SWITCHBOT_SECRET;
|
|
@@ -112,8 +173,16 @@ export function showConfig() {
|
|
|
112
173
|
const raw = fs.readFileSync(file, 'utf-8');
|
|
113
174
|
const cfg = JSON.parse(raw);
|
|
114
175
|
console.log(`Credential source: ${file}`);
|
|
176
|
+
if (cfg.label)
|
|
177
|
+
console.log(`label : ${cfg.label}`);
|
|
178
|
+
if (cfg.description)
|
|
179
|
+
console.log(`desc : ${cfg.description}`);
|
|
115
180
|
console.log(`token : ${maskCredential(cfg.token)}`);
|
|
116
181
|
console.log(`secret: ${maskSecret(cfg.secret)}`);
|
|
182
|
+
if (cfg.limits?.dailyCap)
|
|
183
|
+
console.log(`limits: dailyCap=${cfg.limits.dailyCap}`);
|
|
184
|
+
if (cfg.defaults?.flags?.length)
|
|
185
|
+
console.log(`defaults: ${cfg.defaults.flags.join(' ')}`);
|
|
117
186
|
}
|
|
118
187
|
catch {
|
|
119
188
|
console.error('Failed to read config file');
|