@switchbot/openapi-cli 2.4.0 → 2.5.1
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 +45 -4
- package/dist/commands/agent-bootstrap.js +6 -0
- package/dist/commands/batch.js +65 -25
- package/dist/commands/cache.js +18 -1
- package/dist/commands/capabilities.js +8 -0
- package/dist/commands/config.js +6 -6
- package/dist/commands/device-meta.js +18 -1
- package/dist/commands/devices.js +125 -53
- package/dist/commands/events.js +70 -32
- package/dist/commands/expand.js +7 -5
- package/dist/commands/history.js +124 -24
- package/dist/commands/mcp.js +178 -43
- package/dist/commands/plan.js +12 -2
- package/dist/commands/scenes.js +52 -1
- package/dist/commands/schema.js +6 -0
- package/dist/commands/watch.js +7 -0
- package/dist/devices/cache.js +61 -26
- package/dist/devices/history-agg.js +138 -0
- package/dist/devices/history-query.js +1 -1
- package/dist/index.js +7 -0
- package/dist/mcp/device-history.js +20 -9
- package/dist/utils/arg-parsers.js +2 -1
- package/dist/utils/audit.js +1 -1
- package/dist/utils/filter.js +102 -39
- package/dist/utils/flags.js +3 -1
- package/dist/utils/format.js +11 -3
- package/dist/utils/name-resolver.js +11 -3
- package/dist/utils/output.js +37 -6
- package/dist/version.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -221,6 +221,12 @@ switchbot devices list --filter category=physical
|
|
|
221
221
|
switchbot devices list --filter type=Bot
|
|
222
222
|
switchbot devices list --filter name=living,category=physical
|
|
223
223
|
|
|
224
|
+
# Filter operators: = (substring; exact for `category`), ~ (substring),
|
|
225
|
+
# =/regex/ (case-insensitive regex). Clauses are AND-ed.
|
|
226
|
+
switchbot devices list --filter 'name~living'
|
|
227
|
+
switchbot devices list --filter 'type=/Hub.*/'
|
|
228
|
+
switchbot devices list --filter 'name~office,type=/Bulb|Strip/'
|
|
229
|
+
|
|
224
230
|
# Filter by family / room (family & room info requires the 'src: OpenClaw'
|
|
225
231
|
# header, which this CLI sends on every request)
|
|
226
232
|
switchbot devices list --json | jq '.deviceList[] | select(.familyName == "Home")'
|
|
@@ -255,6 +261,21 @@ switchbot devices commands "Smart Lock"
|
|
|
255
261
|
switchbot devices commands curtain # Case-insensitive, substring match
|
|
256
262
|
```
|
|
257
263
|
|
|
264
|
+
#### Filter expressions — per-command reference
|
|
265
|
+
|
|
266
|
+
Three commands accept `--filter`. They share one three-operator grammar,
|
|
267
|
+
but each exposes its own key set:
|
|
268
|
+
|
|
269
|
+
| Command | Operators | Supported keys |
|
|
270
|
+
|-------------------------------------|-----------------------------------------------------------------------------------------------|---------------------------------------|
|
|
271
|
+
| `devices list` | `=` (substring; **exact** for `category`), `~` (substring), `=/regex/` (case-insensitive regex) | `type`, `name`, `category`, `room` |
|
|
272
|
+
| `devices batch` | same | `type`, `family`, `room`, `category` |
|
|
273
|
+
| `events tail` / `events mqtt-tail` | same (tail only; mqtt-tail uses `--topic` instead) | `deviceId`, `type` |
|
|
274
|
+
|
|
275
|
+
Clauses are comma-separated and AND-ed. No OR across clauses — use regex
|
|
276
|
+
alternation (`=/A|B/`) for that. `category` is the one key that stays exact
|
|
277
|
+
under `=` to preserve `category=physical` / `category=ir` semantics.
|
|
278
|
+
|
|
258
279
|
#### Parameter formats
|
|
259
280
|
|
|
260
281
|
`parameter` is optional — omit it for commands like `turnOn`/`turnOff` (auto-defaults to `"default"`).
|
|
@@ -280,6 +301,8 @@ Generic parameter shapes (which one applies is decided by the device — see the
|
|
|
280
301
|
|
|
281
302
|
Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), and `setMode` (Relay Switch) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list.
|
|
282
303
|
|
|
304
|
+
Negative numeric parameters (e.g. `setBrightness -1` for a probe) are passed through to the command validator instead of being swallowed by the flag parser as an unknown option.
|
|
305
|
+
|
|
283
306
|
For the complete per-device command reference, see the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands).
|
|
284
307
|
|
|
285
308
|
#### `devices expand` — named flags for packed parameters
|
|
@@ -333,7 +356,7 @@ Stores local annotations (alias, hidden flag, notes) in `~/.switchbot/device-met
|
|
|
333
356
|
```bash
|
|
334
357
|
# Send the same command to every device matching a filter
|
|
335
358
|
switchbot devices batch turnOff --filter 'type=Bot'
|
|
336
|
-
switchbot devices batch setBrightness 50 --filter 'type
|
|
359
|
+
switchbot devices batch setBrightness 50 --filter 'type~Light,family=Living'
|
|
337
360
|
|
|
338
361
|
# Explicit device IDs (comma-separated)
|
|
339
362
|
switchbot devices batch turnOn --ids ID1,ID2,ID3
|
|
@@ -343,9 +366,18 @@ switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch
|
|
|
343
366
|
|
|
344
367
|
# Destructive commands require --yes
|
|
345
368
|
switchbot devices batch unlock --filter 'type=Smart Lock' --yes
|
|
369
|
+
|
|
370
|
+
# Skip devices whose cached status is offline (default: off)
|
|
371
|
+
switchbot devices batch turnOn --ids ID1,ID2 --skip-offline
|
|
372
|
+
|
|
373
|
+
# --idempotency-key is an alias for --idempotency-key-prefix; both append -<deviceId>
|
|
374
|
+
switchbot devices batch turnOn --ids ID1,ID2 --idempotency-key morning-lights
|
|
346
375
|
```
|
|
347
376
|
|
|
348
|
-
Sends the same command to many devices in one run.
|
|
377
|
+
Sends the same command to many devices in one run. Filter grammar matches `devices list` (`=` substring, `~` substring, `=/regex/` regex — clauses AND-ed); supported keys here are `type`, `family`, `room`, `category`. Destructive commands (Smart Lock unlock, Garage Door Opener, etc.) require `--yes` to prevent accidents.
|
|
378
|
+
|
|
379
|
+
`--skip-offline` reads from the local status cache only (no new API calls);
|
|
380
|
+
skipped devices appear under `summary.skipped` with `skippedReason:'offline'`.
|
|
349
381
|
|
|
350
382
|
### `scenes` — run manual scenes
|
|
351
383
|
|
|
@@ -390,6 +422,9 @@ switchbot events tail --filter deviceId=ABC123
|
|
|
390
422
|
# Stop after 5 matching events
|
|
391
423
|
switchbot events tail --filter 'type=WoMeter' --max 5
|
|
392
424
|
|
|
425
|
+
# Stop after 10 minutes regardless of event count
|
|
426
|
+
switchbot events tail --for 10m
|
|
427
|
+
|
|
393
428
|
# Custom port / path
|
|
394
429
|
switchbot events tail --port 8080 --path /hook --json
|
|
395
430
|
```
|
|
@@ -401,7 +436,7 @@ Output (one JSON line per matched event):
|
|
|
401
436
|
{ "t": "2024-01-01T12:00:00.000Z", "remote": "1.2.3.4:54321", "path": "/", "body": {...}, "matched": true }
|
|
402
437
|
```
|
|
403
438
|
|
|
404
|
-
Filter keys: `deviceId
|
|
439
|
+
Filter keys: `deviceId`, `type`. Operators: `=` (substring), `~` (substring), `=/regex/` (case-insensitive regex). Clauses comma-separated and AND-ed.
|
|
405
440
|
|
|
406
441
|
#### `events mqtt-tail` — real-time MQTT stream
|
|
407
442
|
|
|
@@ -414,6 +449,9 @@ switchbot events mqtt-tail --topic 'switchbot/#'
|
|
|
414
449
|
|
|
415
450
|
# Stop after 10 events
|
|
416
451
|
switchbot events mqtt-tail --max 10 --json
|
|
452
|
+
|
|
453
|
+
# Stop after a fixed duration (emits __session_start under --json before connect)
|
|
454
|
+
switchbot events mqtt-tail --for 30s --json
|
|
417
455
|
```
|
|
418
456
|
|
|
419
457
|
Connects to the SwitchBot MQTT service automatically using the same credentials configured for the REST API (`SWITCHBOT_TOKEN` + `SWITCHBOT_SECRET`). No additional MQTT configuration is required — the client certificates are provisioned on first use.
|
|
@@ -514,9 +552,12 @@ switchbot devices watch <deviceId>
|
|
|
514
552
|
|
|
515
553
|
# Custom interval; emit every tick even when nothing changed
|
|
516
554
|
switchbot devices watch <deviceId> --interval 10s --include-unchanged --json
|
|
555
|
+
|
|
556
|
+
# Time-bounded: stop after 5 minutes instead of a fixed tick count
|
|
557
|
+
switchbot devices watch <deviceId> --for 5m
|
|
517
558
|
```
|
|
518
559
|
|
|
519
|
-
Output is a JSONL stream of status-change events (with `--json`) or a refreshed table. Use `--max <n>` to stop after N ticks.
|
|
560
|
+
Output is a JSONL stream of status-change events (with `--json`) or a refreshed table. Use `--max <n>` to stop after N ticks, or `--for <duration>` to stop after an elapsed wall-clock window (e.g. `30s`, `1h`, `2d`). When both are set, whichever limit trips first wins.
|
|
520
561
|
|
|
521
562
|
### `mcp` — Model Context Protocol server
|
|
522
563
|
|
|
@@ -3,6 +3,7 @@ import { loadCache } from '../devices/cache.js';
|
|
|
3
3
|
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
4
4
|
import { readProfileMeta } from '../config.js';
|
|
5
5
|
import { todayUsage, DAILY_QUOTA } from '../utils/quota.js';
|
|
6
|
+
import { ALL_STRATEGIES } from '../utils/name-resolver.js';
|
|
6
7
|
import { createRequire } from 'node:module';
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const { version: pkgVersion } = require('../../package.json');
|
|
@@ -24,6 +25,7 @@ const QUICK_REFERENCE = {
|
|
|
24
25
|
safety: ['--dry-run', '--idempotency-key <k>', '--audit-log', '--no-quota'],
|
|
25
26
|
observability: ['doctor --json', 'quota status', 'cache status', 'events mqtt-tail'],
|
|
26
27
|
history: ['history range <id> --since 7d', 'history stats <id>'],
|
|
28
|
+
meta: ['devices meta set <id> --alias <name>', 'devices meta list', 'devices meta get <id>'],
|
|
27
29
|
};
|
|
28
30
|
export function registerAgentBootstrapCommand(program) {
|
|
29
31
|
program
|
|
@@ -97,6 +99,7 @@ Examples:
|
|
|
97
99
|
identity: IDENTITY,
|
|
98
100
|
quickReference: QUICK_REFERENCE,
|
|
99
101
|
safetyTiers: SAFETY_TIERS,
|
|
102
|
+
nameStrategies: [...ALL_STRATEGIES],
|
|
100
103
|
profile: meta
|
|
101
104
|
? {
|
|
102
105
|
label: meta.label ?? null,
|
|
@@ -116,6 +119,9 @@ Examples:
|
|
|
116
119
|
scope: cachedDevices.length > 0 ? 'used' : 'all',
|
|
117
120
|
types: catalogTypes,
|
|
118
121
|
},
|
|
122
|
+
// hints: empty array means no hints to report; always emitted, never null.
|
|
123
|
+
// An empty array signals "nothing to act on" — agents should not treat
|
|
124
|
+
// it as a disabled or missing field.
|
|
119
125
|
hints: cachedDevices.length === 0
|
|
120
126
|
? ['Run `switchbot devices list` once to populate the device cache for richer bootstrap output.']
|
|
121
127
|
: [],
|
package/dist/commands/batch.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { intArg, enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
2
|
-
import { printJson, isJsonMode, handleError, buildErrorPayload } from '../utils/output.js';
|
|
2
|
+
import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, emitJsonError } from '../utils/output.js';
|
|
3
3
|
import { fetchDeviceList, executeCommand, isDestructiveCommand, buildHubLocationMap, } from '../lib/devices.js';
|
|
4
4
|
import { createClient } from '../api/client.js';
|
|
5
5
|
import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js';
|
|
6
6
|
import { isDryRun } from '../utils/flags.js';
|
|
7
7
|
import { DryRunSignal } from '../api/client.js';
|
|
8
|
-
import { getCachedTypeMap } from '../devices/cache.js';
|
|
8
|
+
import { getCachedTypeMap, getCachedDevice, loadStatusCache } from '../devices/cache.js';
|
|
9
9
|
const DEFAULT_CONCURRENCY = 5;
|
|
10
10
|
const COMMAND_TYPES = ['command', 'customize'];
|
|
11
11
|
/**
|
|
@@ -100,7 +100,9 @@ export function registerBatchCommand(devices) {
|
|
|
100
100
|
.option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
|
|
101
101
|
.option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
|
|
102
102
|
.option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
|
|
103
|
-
.option('--idempotency-key-prefix <prefix>', '
|
|
103
|
+
.option('--idempotency-key-prefix <prefix>', 'Client-supplied prefix for idempotency keys (key per device: <prefix>-<deviceId>). 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-prefix'))
|
|
104
|
+
.option('--idempotency-key <prefix>', 'Alias for --idempotency-key-prefix.', stringArg('--idempotency-key'))
|
|
105
|
+
.option('--skip-offline', 'Skip devices whose cached status is offline (no API call; cache miss → send as usual).')
|
|
104
106
|
.addHelpText('after', `
|
|
105
107
|
Targets are resolved in this priority order:
|
|
106
108
|
1. --ids when present (explicit deviceIds)
|
|
@@ -144,6 +146,14 @@ Examples:
|
|
|
144
146
|
// Trailing "-" sentinel selects stdin mode.
|
|
145
147
|
const extra = commandObj.args ?? [];
|
|
146
148
|
const readStdin = Boolean(options.stdin) || extra.includes('-');
|
|
149
|
+
// Accept --idempotency-key as alias; reject when both forms are supplied.
|
|
150
|
+
if (options.idempotencyKey !== undefined && options.idempotencyKeyPrefix !== undefined) {
|
|
151
|
+
handleError(new UsageError('Use either --idempotency-key or --idempotency-key-prefix, not both.'));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (options.idempotencyKey !== undefined && options.idempotencyKeyPrefix === undefined) {
|
|
155
|
+
options.idempotencyKeyPrefix = options.idempotencyKey;
|
|
156
|
+
}
|
|
147
157
|
let client;
|
|
148
158
|
const getClient = () => (client ??= createClient());
|
|
149
159
|
let resolved;
|
|
@@ -157,7 +167,7 @@ Examples:
|
|
|
157
167
|
catch (error) {
|
|
158
168
|
if (error instanceof FilterSyntaxError) {
|
|
159
169
|
if (isJsonMode()) {
|
|
160
|
-
|
|
170
|
+
emitJsonError({ code: 2, kind: 'usage', message: error.message });
|
|
161
171
|
}
|
|
162
172
|
else {
|
|
163
173
|
console.error(`Error: ${error.message}`);
|
|
@@ -166,7 +176,7 @@ Examples:
|
|
|
166
176
|
}
|
|
167
177
|
if (error instanceof Error && error.message.startsWith('No target devices')) {
|
|
168
178
|
if (isJsonMode()) {
|
|
169
|
-
|
|
179
|
+
emitJsonError({ code: 2, kind: 'usage', message: error.message });
|
|
170
180
|
}
|
|
171
181
|
else {
|
|
172
182
|
console.error(`Error: ${error.message}`);
|
|
@@ -179,7 +189,7 @@ Examples:
|
|
|
179
189
|
const out = {
|
|
180
190
|
succeeded: [],
|
|
181
191
|
failed: [],
|
|
182
|
-
summary: { total: 0, ok: 0, failed: 0, skipped: 0, durationMs: 0 },
|
|
192
|
+
summary: { total: 0, ok: 0, failed: 0, skipped: 0, durationMs: 0, unverifiableCount: 0 },
|
|
183
193
|
};
|
|
184
194
|
if (isJsonMode())
|
|
185
195
|
printJson(out);
|
|
@@ -188,6 +198,24 @@ Examples:
|
|
|
188
198
|
return;
|
|
189
199
|
}
|
|
190
200
|
const effectiveType = (options.type === 'customize' ? 'customize' : 'command');
|
|
201
|
+
// --skip-offline: preflight using the status cache (no network). Cache
|
|
202
|
+
// miss = send as usual; only definite "offline" cached entries skip.
|
|
203
|
+
const preSkipped = [];
|
|
204
|
+
if (options.skipOffline && resolved.ids.length > 0) {
|
|
205
|
+
const statusCache = loadStatusCache();
|
|
206
|
+
const kept = [];
|
|
207
|
+
for (const id of resolved.ids) {
|
|
208
|
+
const entry = statusCache.entries[id];
|
|
209
|
+
const online = entry?.body?.onlineStatus;
|
|
210
|
+
if (online === 'offline') {
|
|
211
|
+
preSkipped.push({ deviceId: id, reason: 'offline' });
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
kept.push(id);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
resolved = { ...resolved, ids: kept };
|
|
218
|
+
}
|
|
191
219
|
// Pre-flight: identify destructive targets before spending API calls.
|
|
192
220
|
const blockedForDestructive = [];
|
|
193
221
|
for (const id of resolved.ids) {
|
|
@@ -202,15 +230,13 @@ Examples:
|
|
|
202
230
|
if (blockedForDestructive.length > 0 && !options.yes) {
|
|
203
231
|
if (isJsonMode()) {
|
|
204
232
|
const deviceIds = blockedForDestructive.map((b) => b.deviceId);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
},
|
|
213
|
-
}));
|
|
233
|
+
emitJsonError({
|
|
234
|
+
code: 2,
|
|
235
|
+
kind: 'guard',
|
|
236
|
+
message: `Destructive command "${cmd}" requires --yes to run on ${blockedForDestructive.length} device(s).`,
|
|
237
|
+
hint: 'Re-issue the call with --yes to proceed.',
|
|
238
|
+
context: { command: cmd, deviceIds },
|
|
239
|
+
});
|
|
214
240
|
}
|
|
215
241
|
else {
|
|
216
242
|
console.error(`Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes:`);
|
|
@@ -324,14 +350,26 @@ Examples:
|
|
|
324
350
|
const failed = outcomes.filter((o) => o.ok === false);
|
|
325
351
|
const dryRunned = outcomes.filter((o) => o.ok === 'dry-run');
|
|
326
352
|
const result = {
|
|
327
|
-
succeeded: succeeded.map((s) =>
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
353
|
+
succeeded: succeeded.map((s) => {
|
|
354
|
+
const isIr = getCachedDevice(s.deviceId)?.category === 'ir';
|
|
355
|
+
const entry = {
|
|
356
|
+
deviceId: s.deviceId,
|
|
357
|
+
result: s.result,
|
|
358
|
+
startedAt: s.startedAt,
|
|
359
|
+
finishedAt: s.finishedAt,
|
|
360
|
+
durationMs: s.durationMs,
|
|
361
|
+
replayed: s.replayed,
|
|
362
|
+
};
|
|
363
|
+
if (isIr) {
|
|
364
|
+
entry.subKind = 'ir-no-feedback';
|
|
365
|
+
entry.verification = {
|
|
366
|
+
verifiable: false,
|
|
367
|
+
reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
|
|
368
|
+
suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return entry;
|
|
372
|
+
}),
|
|
335
373
|
failed: failed.map((f) => ({
|
|
336
374
|
deviceId: f.deviceId,
|
|
337
375
|
error: f.error,
|
|
@@ -339,12 +377,14 @@ Examples:
|
|
|
339
377
|
finishedAt: f.finishedAt,
|
|
340
378
|
durationMs: f.durationMs,
|
|
341
379
|
})),
|
|
380
|
+
...(preSkipped.length > 0 ? { skipped: preSkipped } : {}),
|
|
342
381
|
summary: {
|
|
343
|
-
total: resolved.ids.length,
|
|
382
|
+
total: resolved.ids.length + preSkipped.length,
|
|
344
383
|
ok: succeeded.length,
|
|
345
384
|
failed: failed.length,
|
|
346
|
-
skipped: dryRunned.length,
|
|
385
|
+
skipped: dryRunned.length + preSkipped.length,
|
|
347
386
|
durationMs: Date.now() - startedAt,
|
|
387
|
+
unverifiableCount: succeeded.filter((s) => getCachedDevice(s.deviceId)?.category === 'ir').length,
|
|
348
388
|
schemaVersion: '1.1',
|
|
349
389
|
maxConcurrent: concurrency,
|
|
350
390
|
staggerMs,
|
package/dist/commands/cache.js
CHANGED
|
@@ -46,7 +46,12 @@ Examples:
|
|
|
46
46
|
`);
|
|
47
47
|
cache
|
|
48
48
|
.command('show')
|
|
49
|
+
.alias('status')
|
|
49
50
|
.description('Summarize the cache files (paths, ages, entry counts)')
|
|
51
|
+
.addHelpText('after', `
|
|
52
|
+
Cache TTL is computed from the 'lastUpdated' field inside the JSON, not the file mtime.
|
|
53
|
+
touch does not invalidate; use 'cache clear' to force a refresh.
|
|
54
|
+
`)
|
|
50
55
|
.action(() => {
|
|
51
56
|
const summary = describeCache();
|
|
52
57
|
if (isJsonMode()) {
|
|
@@ -81,9 +86,21 @@ Examples:
|
|
|
81
86
|
.command('clear')
|
|
82
87
|
.description('Delete cache files')
|
|
83
88
|
.option('--key <which>', 'Which cache to clear: "list" | "status" | "all" (default)', enumArg('--key', CACHE_KEYS), 'all')
|
|
89
|
+
.option('--status', 'Shorthand for --key status')
|
|
90
|
+
.option('--list', 'Shorthand for --key list')
|
|
84
91
|
.action((options) => {
|
|
85
92
|
try {
|
|
86
|
-
|
|
93
|
+
if (options.status && options.list) {
|
|
94
|
+
throw new UsageError('--status and --list are mutually exclusive.');
|
|
95
|
+
}
|
|
96
|
+
if ((options.status || options.list) && options.key !== 'all') {
|
|
97
|
+
throw new UsageError('--status / --list cannot be combined with --key.');
|
|
98
|
+
}
|
|
99
|
+
let key = options.key;
|
|
100
|
+
if (options.status)
|
|
101
|
+
key = 'status';
|
|
102
|
+
if (options.list)
|
|
103
|
+
key = 'list';
|
|
87
104
|
if (!['list', 'status', 'all'].includes(key)) {
|
|
88
105
|
throw new UsageError(`Unknown --key "${key}". Expected: list, status, all.`);
|
|
89
106
|
}
|
|
@@ -22,12 +22,18 @@ const COMMAND_META = {
|
|
|
22
22
|
'devices types': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
23
23
|
'devices commands': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
24
24
|
'devices watch': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
25
|
+
// devices meta (local metadata — no quota, no API call)
|
|
26
|
+
'devices meta set': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
|
|
27
|
+
'devices meta get': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
28
|
+
'devices meta list': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
29
|
+
'devices meta clear': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
|
|
25
30
|
// devices: actions
|
|
26
31
|
'devices command': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 800 },
|
|
27
32
|
'devices batch': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1200 },
|
|
28
33
|
// scenes
|
|
29
34
|
'scenes list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
30
35
|
'scenes execute': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1500 },
|
|
36
|
+
'scenes describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
31
37
|
// webhook
|
|
32
38
|
'webhook setup': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 500 },
|
|
33
39
|
'webhook query': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
@@ -51,6 +57,7 @@ const COMMAND_META = {
|
|
|
51
57
|
'history replay': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1000 },
|
|
52
58
|
'history range': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 50 },
|
|
53
59
|
'history stats': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
60
|
+
'history aggregate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 80 },
|
|
54
61
|
'plan run': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 2000 },
|
|
55
62
|
'plan validate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
56
63
|
'plan schema': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
|
|
@@ -88,6 +95,7 @@ const MCP_TOOLS = [
|
|
|
88
95
|
'account_overview',
|
|
89
96
|
'get_device_history',
|
|
90
97
|
'query_device_history',
|
|
98
|
+
'aggregate_device_history',
|
|
91
99
|
];
|
|
92
100
|
const IDEMPOTENCY_CONTRACT = {
|
|
93
101
|
flag: '--idempotency-key <key>',
|
package/dist/commands/config.js
CHANGED
|
@@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
4
4
|
import { stringArg } from '../utils/arg-parsers.js';
|
|
5
5
|
import { intArg } from '../utils/arg-parsers.js';
|
|
6
6
|
import { saveConfig, showConfig, listProfiles, readProfileMeta } from '../config.js';
|
|
7
|
-
import { isJsonMode, printJson } from '../utils/output.js';
|
|
7
|
+
import { isJsonMode, printJson, emitJsonError } from '../utils/output.js';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
function parseEnvFile(file) {
|
|
10
10
|
const out = {};
|
|
@@ -147,7 +147,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
147
147
|
if (!fs.existsSync(options.fromEnvFile)) {
|
|
148
148
|
const msg = `--from-env-file: file not found: ${options.fromEnvFile}`;
|
|
149
149
|
if (isJsonMode()) {
|
|
150
|
-
|
|
150
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
151
151
|
}
|
|
152
152
|
else {
|
|
153
153
|
console.error(msg);
|
|
@@ -162,7 +162,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
162
162
|
if (!options.opSecret) {
|
|
163
163
|
const msg = '--from-op requires --op-secret <ref> for the secret reference.';
|
|
164
164
|
if (isJsonMode()) {
|
|
165
|
-
|
|
165
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
166
166
|
}
|
|
167
167
|
else {
|
|
168
168
|
console.error(msg);
|
|
@@ -176,7 +176,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
176
176
|
catch (err) {
|
|
177
177
|
const msg = `1Password CLI read failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
178
178
|
if (isJsonMode()) {
|
|
179
|
-
|
|
179
|
+
emitJsonError({ code: 1, kind: 'runtime', message: msg, hint: 'Ensure the "op" CLI is installed and authenticated (op signin).' });
|
|
180
180
|
}
|
|
181
181
|
else {
|
|
182
182
|
console.error(msg);
|
|
@@ -189,7 +189,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
189
189
|
if ((!token || !secret) && !options.fromEnvFile && !options.fromOp && process.stdin.isTTY) {
|
|
190
190
|
if (isJsonMode()) {
|
|
191
191
|
const msg = 'Interactive mode cannot run under --json. Provide token/secret via --from-env-file, --from-op, or positional args.';
|
|
192
|
-
|
|
192
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
193
193
|
process.exit(2);
|
|
194
194
|
}
|
|
195
195
|
try {
|
|
@@ -206,7 +206,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
206
206
|
if (!token || !secret) {
|
|
207
207
|
const msg = 'Missing token/secret. Run interactively, or use --from-env-file / --from-op, or pass positional arguments (discouraged).';
|
|
208
208
|
if (isJsonMode()) {
|
|
209
|
-
|
|
209
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
210
210
|
}
|
|
211
211
|
else {
|
|
212
212
|
console.error(msg);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { stringArg } from '../utils/arg-parsers.js';
|
|
2
2
|
import { handleError, isJsonMode, printJson, printTable, UsageError } from '../utils/output.js';
|
|
3
|
-
import { loadDeviceMeta, setDeviceMeta, clearDeviceMeta, getDeviceMeta, getMetaFilePath, } from '../devices/device-meta.js';
|
|
3
|
+
import { loadDeviceMeta, saveDeviceMeta, setDeviceMeta, clearDeviceMeta, getDeviceMeta, getMetaFilePath, } from '../devices/device-meta.js';
|
|
4
4
|
export function registerDevicesMetaCommand(devices) {
|
|
5
5
|
const meta = devices
|
|
6
6
|
.command('meta')
|
|
@@ -14,6 +14,7 @@ export function registerDevicesMetaCommand(devices) {
|
|
|
14
14
|
.option('--hide', 'Hide this device from "devices list"')
|
|
15
15
|
.option('--show', 'Un-hide this device')
|
|
16
16
|
.option('--notes <text>', 'Freeform notes shown in "devices describe"', stringArg('--notes'))
|
|
17
|
+
.option('--force', 'Reassign alias even if it already belongs to another device')
|
|
17
18
|
.action((deviceId, options) => {
|
|
18
19
|
try {
|
|
19
20
|
if (options.hide && options.show) {
|
|
@@ -22,6 +23,22 @@ export function registerDevicesMetaCommand(devices) {
|
|
|
22
23
|
if (!options.alias && !options.hide && !options.show && !options.notes) {
|
|
23
24
|
throw new UsageError('Specify at least one of: --alias, --hide, --show, --notes');
|
|
24
25
|
}
|
|
26
|
+
// Enforce alias uniqueness across devices
|
|
27
|
+
if (options.alias !== undefined) {
|
|
28
|
+
const meta = loadDeviceMeta();
|
|
29
|
+
const holder = Object.entries(meta.devices).find(([id, m]) => m.alias === options.alias && id !== deviceId);
|
|
30
|
+
if (holder) {
|
|
31
|
+
if (!options.force) {
|
|
32
|
+
throw new UsageError(`Alias "${options.alias}" is already assigned to device ${holder[0]}. Use --force to reassign.`);
|
|
33
|
+
}
|
|
34
|
+
// --force: clear the alias from the previous holder
|
|
35
|
+
meta.devices[holder[0]] = { ...meta.devices[holder[0]], alias: undefined };
|
|
36
|
+
saveDeviceMeta(meta);
|
|
37
|
+
if (!isJsonMode()) {
|
|
38
|
+
console.log(`(reassigned alias from ${holder[0]})`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
25
42
|
const patch = {};
|
|
26
43
|
if (options.alias !== undefined)
|
|
27
44
|
patch.alias = options.alias;
|