@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 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~=Light,family=Living'
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. Uses the same `--filter` expressions as `devices list`. Destructive commands (Smart Lock unlock, Garage Door Opener, etc.) require `--yes` to prevent accidents.
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=<id>`, `type=<deviceType>` (comma-separated for AND logic).
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
  : [],
@@ -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>', 'Prefix for idempotency keys (key per device: <prefix>-<deviceId>)', stringArg('--idempotency-key-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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: error.message } }));
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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: error.message } }));
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
- console.error(JSON.stringify({
206
- error: {
207
- code: 2,
208
- kind: 'guard',
209
- message: `Destructive command "${cmd}" requires --yes to run on ${blockedForDestructive.length} device(s).`,
210
- hint: 'Re-issue the call with --yes to proceed.',
211
- context: { command: cmd, deviceIds },
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
- deviceId: s.deviceId,
329
- result: s.result,
330
- startedAt: s.startedAt,
331
- finishedAt: s.finishedAt,
332
- durationMs: s.durationMs,
333
- replayed: s.replayed,
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,
@@ -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
- const key = options.key;
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>',
@@ -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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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
- console.error(JSON.stringify({ error: { code: 1, kind: 'runtime', message: msg, hint: 'Ensure the "op" CLI is installed and authenticated (op signin).' } }));
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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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;