@switchbot/openapi-cli 1.0.1 → 1.2.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.
Files changed (90) hide show
  1. package/README.md +174 -18
  2. package/dist/api/client.d.ts +7 -1
  3. package/dist/api/client.js +44 -8
  4. package/dist/api/client.js.map +1 -1
  5. package/dist/commands/batch.d.ts +2 -0
  6. package/dist/commands/batch.js +252 -0
  7. package/dist/commands/batch.js.map +1 -0
  8. package/dist/commands/cache.d.ts +2 -0
  9. package/dist/commands/cache.js +108 -0
  10. package/dist/commands/cache.js.map +1 -0
  11. package/dist/commands/capabilities.d.ts +2 -0
  12. package/dist/commands/capabilities.js +91 -0
  13. package/dist/commands/capabilities.js.map +1 -0
  14. package/dist/commands/catalog.d.ts +2 -0
  15. package/dist/commands/catalog.js +291 -0
  16. package/dist/commands/catalog.js.map +1 -0
  17. package/dist/commands/config.js +123 -10
  18. package/dist/commands/config.js.map +1 -1
  19. package/dist/commands/devices.js +234 -112
  20. package/dist/commands/devices.js.map +1 -1
  21. package/dist/commands/doctor.d.ts +2 -0
  22. package/dist/commands/doctor.js +147 -0
  23. package/dist/commands/doctor.js.map +1 -0
  24. package/dist/commands/events.d.ts +15 -0
  25. package/dist/commands/events.js +188 -0
  26. package/dist/commands/events.js.map +1 -0
  27. package/dist/commands/explain.d.ts +2 -0
  28. package/dist/commands/explain.js +137 -0
  29. package/dist/commands/explain.js.map +1 -0
  30. package/dist/commands/history.d.ts +2 -0
  31. package/dist/commands/history.js +104 -0
  32. package/dist/commands/history.js.map +1 -0
  33. package/dist/commands/mcp.d.ts +4 -0
  34. package/dist/commands/mcp.js +386 -0
  35. package/dist/commands/mcp.js.map +1 -0
  36. package/dist/commands/plan.d.ts +37 -0
  37. package/dist/commands/plan.js +344 -0
  38. package/dist/commands/plan.js.map +1 -0
  39. package/dist/commands/quota.d.ts +2 -0
  40. package/dist/commands/quota.js +77 -0
  41. package/dist/commands/quota.js.map +1 -0
  42. package/dist/commands/scenes.js +19 -13
  43. package/dist/commands/scenes.js.map +1 -1
  44. package/dist/commands/schema.d.ts +2 -0
  45. package/dist/commands/schema.js +77 -0
  46. package/dist/commands/schema.js.map +1 -0
  47. package/dist/commands/watch.d.ts +2 -0
  48. package/dist/commands/watch.js +161 -0
  49. package/dist/commands/watch.js.map +1 -0
  50. package/dist/commands/webhook.js +37 -22
  51. package/dist/commands/webhook.js.map +1 -1
  52. package/dist/config.d.ts +11 -0
  53. package/dist/config.js +32 -6
  54. package/dist/config.js.map +1 -1
  55. package/dist/devices/cache.d.ts +75 -0
  56. package/dist/devices/cache.js +225 -0
  57. package/dist/devices/cache.js.map +1 -0
  58. package/dist/devices/catalog.d.ts +49 -0
  59. package/dist/devices/catalog.js +362 -92
  60. package/dist/devices/catalog.js.map +1 -1
  61. package/dist/index.js +31 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/lib/devices.d.ts +144 -0
  64. package/dist/lib/devices.js +329 -0
  65. package/dist/lib/devices.js.map +1 -0
  66. package/dist/lib/scenes.d.ts +7 -0
  67. package/dist/lib/scenes.js +11 -0
  68. package/dist/lib/scenes.js.map +1 -0
  69. package/dist/utils/audit.d.ts +13 -0
  70. package/dist/utils/audit.js +43 -0
  71. package/dist/utils/audit.js.map +1 -0
  72. package/dist/utils/filter.d.ts +45 -0
  73. package/dist/utils/filter.js +96 -0
  74. package/dist/utils/filter.js.map +1 -0
  75. package/dist/utils/flags.d.ts +42 -0
  76. package/dist/utils/flags.js +108 -0
  77. package/dist/utils/flags.js.map +1 -1
  78. package/dist/utils/format.d.ts +9 -0
  79. package/dist/utils/format.js +109 -0
  80. package/dist/utils/format.js.map +1 -0
  81. package/dist/utils/output.d.ts +11 -0
  82. package/dist/utils/output.js +37 -6
  83. package/dist/utils/output.js.map +1 -1
  84. package/dist/utils/quota.d.ts +48 -0
  85. package/dist/utils/quota.js +144 -0
  86. package/dist/utils/quota.js.map +1 -0
  87. package/dist/utils/retry.d.ts +23 -0
  88. package/dist/utils/retry.js +60 -0
  89. package/dist/utils/retry.js.map +1 -0
  90. package/package.json +4 -1
@@ -2,82 +2,109 @@
2
2
  * Static catalog of SwitchBot device types, control commands and status fields.
3
3
  * Sourced from https://github.com/OpenWonderLabs/SwitchBotAPI — keep in sync
4
4
  * when the upstream API adds new device types.
5
+ *
6
+ * Field conventions:
7
+ * - CommandSpec.idempotent: repeat-safe — calling it N times ends in the
8
+ * same state as calling it once (turnOn, setBrightness 50). Agents can
9
+ * retry these freely. Counter-examples: toggle, press, volumeAdd.
10
+ * - CommandSpec.destructive: causes a real-world effect that is hard or
11
+ * unsafe to reverse (unlock, garage open, deleteKey). UIs and agents
12
+ * should require explicit confirmation before issuing these.
13
+ * - DeviceCatalogEntry.role: functional grouping for filter/search
14
+ * ("all lighting", "all security"). Does not affect API behavior.
15
+ * - DeviceCatalogEntry.readOnly: the device has no control commands; it
16
+ * can only be queried via 'devices status'.
5
17
  */
18
+ // ---- Command fragments (reused across entries) -------------------------
6
19
  const onOff = [
7
- { command: 'turnOn', parameter: '—', description: 'Power on' },
8
- { command: 'turnOff', parameter: '—', description: 'Power off' },
20
+ { command: 'turnOn', parameter: '—', description: 'Power on', idempotent: true },
21
+ { command: 'turnOff', parameter: '—', description: 'Power off', idempotent: true },
9
22
  ];
10
23
  const onOffToggle = [
11
24
  ...onOff,
12
- { command: 'toggle', parameter: '—', description: 'Toggle power' },
25
+ { command: 'toggle', parameter: '—', description: 'Toggle power', idempotent: false },
13
26
  ];
14
27
  const lightControls = [
15
- { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage' },
16
- { command: 'setColor', parameter: 'R:G:B (0-255 each)', description: 'Set RGB color, e.g. "255:0:0"' },
17
- { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)' },
28
+ { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage', idempotent: true, exampleParams: ['50', '80'] },
29
+ { command: 'setColor', parameter: 'R:G:B (0-255 each)', description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ['255:0:0', '255:255:255'] },
30
+ { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)', idempotent: true, exampleParams: ['2700', '4000', '6500'] },
18
31
  ];
19
32
  export const DEVICE_CATALOG = [
20
33
  // ---------- Physical devices ----------
21
34
  {
22
35
  type: 'Bot',
23
36
  category: 'physical',
37
+ description: 'Mechanical arm robot that physically presses a button or toggles a switch on demand.',
38
+ role: 'other',
24
39
  commands: [
25
40
  ...onOff,
26
- { command: 'press', parameter: '—', description: 'Press the button (momentary)' },
41
+ { command: 'press', parameter: '—', description: 'Press the button (momentary)', idempotent: false },
27
42
  ],
28
43
  statusFields: ['power', 'battery', 'deviceMode', 'version'],
29
44
  },
30
45
  {
31
46
  type: 'Curtain',
32
47
  category: 'physical',
48
+ description: 'Motorized curtain track runner that opens/closes curtains by slide position (0=open, 100=closed).',
49
+ role: 'curtain',
33
50
  aliases: ['Curtain3', 'Curtain 3'],
34
51
  commands: [
35
52
  ...onOff,
36
- { command: 'pause', parameter: '—', description: 'Stop movement' },
37
- { command: 'setPosition', parameter: '0-100 (0=open, 100=closed)', description: 'Move to a position' },
38
- { command: 'setPosition', parameter: 'index,mode,position (e.g. "0,ff,80")', description: 'Multi-arg form: mode=0 Performance | 1 Silent | ff default' },
53
+ { command: 'pause', parameter: '—', description: 'Stop movement', idempotent: true },
54
+ { command: 'setPosition', parameter: '0-100 (0=open, 100=closed)', description: 'Move to a position', idempotent: true, exampleParams: ['0', '50', '100'] },
55
+ { command: 'setPosition', parameter: 'index,mode,position (e.g. "0,ff,80")', description: 'Multi-arg form: mode=0 Performance | 1 Silent | ff default', idempotent: true, exampleParams: ['0,ff,50'] },
39
56
  ],
40
57
  statusFields: ['calibrate', 'group', 'moving', 'slidePosition', 'battery', 'version'],
41
58
  },
42
59
  {
43
60
  type: 'Smart Lock',
44
61
  category: 'physical',
62
+ description: 'Bluetooth/Wi-Fi electronic deadbolt that locks and unlocks a door via cloud API.',
63
+ role: 'security',
45
64
  aliases: ['Smart Lock Pro'],
46
65
  commands: [
47
- { command: 'lock', parameter: '—', description: 'Lock the door' },
48
- { command: 'unlock', parameter: '—', description: 'Unlock the door' },
49
- { command: 'deadbolt', parameter: '—', description: 'Pro only: engage deadbolt' },
66
+ { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
67
+ { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' },
68
+ { command: 'deadbolt', parameter: '—', description: 'Pro only: engage deadbolt', idempotent: true },
50
69
  ],
51
70
  statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
52
71
  },
53
72
  {
54
73
  type: 'Smart Lock Lite',
55
74
  category: 'physical',
75
+ description: 'Compact electronic deadbolt with lock and unlock control; no deadbolt mode.',
76
+ role: 'security',
56
77
  commands: [
57
- { command: 'lock', parameter: '—', description: 'Lock the door' },
58
- { command: 'unlock', parameter: '—', description: 'Unlock the door' },
78
+ { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
79
+ { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' },
59
80
  ],
60
81
  statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
61
82
  },
62
83
  {
63
84
  type: 'Smart Lock Ultra',
64
85
  category: 'physical',
86
+ description: 'Premium electronic deadbolt with full lock, unlock, and deadbolt control.',
87
+ role: 'security',
65
88
  commands: [
66
- { command: 'lock', parameter: '—', description: 'Lock the door' },
67
- { command: 'unlock', parameter: '—', description: 'Unlock the door' },
68
- { command: 'deadbolt', parameter: '—', description: 'Engage deadbolt' },
89
+ { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
90
+ { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' },
91
+ { command: 'deadbolt', parameter: '—', description: 'Engage deadbolt', idempotent: true },
69
92
  ],
70
93
  statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
71
94
  },
72
95
  {
73
96
  type: 'Plug',
74
97
  category: 'physical',
98
+ description: 'Smart wall outlet plug with on/off/toggle control and basic power status.',
99
+ role: 'power',
75
100
  commands: onOffToggle,
76
101
  statusFields: ['power', 'version'],
77
102
  },
78
103
  {
79
104
  type: 'Plug Mini (US)',
80
105
  category: 'physical',
106
+ description: 'Compact smart plug with voltage, current, and daily energy consumption reporting.',
107
+ role: 'power',
81
108
  aliases: ['Plug Mini (JP)'],
82
109
  commands: onOffToggle,
83
110
  statusFields: ['voltage', 'weight', 'electricityOfDay', 'electricCurrent', 'power', 'version'],
@@ -85,195 +112,237 @@ export const DEVICE_CATALOG = [
85
112
  {
86
113
  type: 'Relay Switch 1',
87
114
  category: 'physical',
115
+ description: 'In-wall relay switch with configurable modes (toggle/edge/detached/momentary) and power metering.',
116
+ role: 'power',
88
117
  aliases: ['Relay Switch 1PM'],
89
118
  commands: [
90
119
  ...onOffToggle,
91
- { command: 'setMode', parameter: '0=toggle | 1=edge | 2=detached | 3=momentary', description: 'Switch operating mode' },
120
+ { command: 'setMode', parameter: '0=toggle | 1=edge | 2=detached | 3=momentary', description: 'Switch operating mode', idempotent: true, exampleParams: ['0', '1', '2', '3'] },
92
121
  ],
93
122
  statusFields: ['switchStatus', 'voltage', 'version', 'useTime', 'electricCurrent', 'power', 'usedElectricity'],
94
123
  },
95
124
  {
96
125
  type: 'Relay Switch 2PM',
97
126
  category: 'physical',
127
+ description: 'Dual-channel relay switch with per-channel on/off/toggle and optional roller-shade mode.',
128
+ role: 'power',
98
129
  commands: [
99
- { command: 'turnOn', parameter: '1 | 2 (channel)', description: 'Turn on channel 1 or 2' },
100
- { command: 'turnOff', parameter: '1 | 2 (channel)', description: 'Turn off channel 1 or 2' },
101
- { command: 'toggle', parameter: '1 | 2 (channel)', description: 'Toggle channel 1 or 2' },
102
- { command: 'setMode', parameter: '"<channel>;<mode>" e.g. "1;0"', description: 'Per-channel mode (see Relay Switch 1 modes)' },
103
- { command: 'setPosition', parameter: '0-100 (roller percentage)', description: 'Roller-shade-pair mode only' },
130
+ { command: 'turnOn', parameter: '1 | 2 (channel)', description: 'Turn on channel 1 or 2', idempotent: true, exampleParams: ['1', '2'] },
131
+ { command: 'turnOff', parameter: '1 | 2 (channel)', description: 'Turn off channel 1 or 2', idempotent: true, exampleParams: ['1', '2'] },
132
+ { command: 'toggle', parameter: '1 | 2 (channel)', description: 'Toggle channel 1 or 2', idempotent: false, exampleParams: ['1', '2'] },
133
+ { command: 'setMode', parameter: '"<channel>;<mode>" e.g. "1;0"', description: 'Per-channel mode (see Relay Switch 1 modes)', idempotent: true, exampleParams: ['1;0', '2;3'] },
134
+ { command: 'setPosition', parameter: '0-100 (roller percentage)', description: 'Roller-shade-pair mode only', idempotent: true, exampleParams: ['0', '50', '100'] },
104
135
  ],
105
136
  statusFields: ['switch1Status', 'switch2Status', 'voltage', 'electricCurrent', 'power', 'usedElectricity'],
106
137
  },
107
138
  {
108
139
  type: 'Humidifier',
109
140
  category: 'physical',
141
+ description: 'Ultrasonic humidifier with auto and preset humidity level control.',
142
+ role: 'climate',
110
143
  commands: [
111
144
  ...onOff,
112
- { command: 'setMode', parameter: 'auto | 101 (34%) | 102 (67%) | 103 (100%) | 0-100', description: 'Set preset or target humidity' },
145
+ { command: 'setMode', parameter: 'auto | 101 (34%) | 102 (67%) | 103 (100%) | 0-100', description: 'Set preset or target humidity', idempotent: true, exampleParams: ['auto', '101', '50'] },
113
146
  ],
114
147
  statusFields: ['power', 'humidity', 'temperature', 'nebulizationEfficiency', 'auto', 'childLock', 'sound', 'lackWater'],
115
148
  },
116
149
  {
117
150
  type: 'Humidifier2',
118
151
  category: 'physical',
152
+ description: 'Evaporative humidifier with multiple speed/auto/sleep/humidity modes and child lock.',
153
+ role: 'climate',
119
154
  aliases: ['Evaporative Humidifier'],
120
155
  commands: [
121
156
  ...onOff,
122
- { command: 'setMode', parameter: '\'{"mode":1-8,"targetHumidify":0-100}\'', description: 'mode: 1=lv4 2=lv3 3=lv2 4=lv1 5=humidity 6=sleep 7=auto 8=drying' },
123
- { command: 'setChildLock', parameter: 'true | false', description: 'Enable or disable child lock' },
157
+ { command: 'setMode', parameter: '\'{"mode":1-8,"targetHumidify":0-100}\'', description: 'mode: 1=lv4 2=lv3 3=lv2 4=lv1 5=humidity 6=sleep 7=auto 8=drying', idempotent: true, exampleParams: ['{"mode":7,"targetHumidify":50}'] },
158
+ { command: 'setChildLock', parameter: 'true | false', description: 'Enable or disable child lock', idempotent: true, exampleParams: ['true', 'false'] },
124
159
  ],
125
160
  statusFields: ['power', 'humidity', 'temperature', 'mode', 'childLock', 'filterElement'],
126
161
  },
127
162
  {
128
163
  type: 'Air Purifier VOC',
129
164
  category: 'physical',
165
+ description: 'HEPA air purifier with VOC or PM2.5 sensing, multiple fan modes, and child lock.',
166
+ role: 'climate',
130
167
  aliases: ['Air Purifier PM2.5', 'Air Purifier Table VOC', 'Air Purifier Table PM2.5'],
131
168
  commands: [
132
169
  ...onOff,
133
- { command: 'setMode', parameter: '\'{"mode":1-4,"fanGear":1-3}\'', description: 'mode: 1=normal 2=auto 3=sleep 4=pet; fanGear only when mode=1' },
134
- { command: 'setChildLock', parameter: '0 | 1', description: 'Disable / enable child lock' },
170
+ { command: 'setMode', parameter: '\'{"mode":1-4,"fanGear":1-3}\'', description: 'mode: 1=normal 2=auto 3=sleep 4=pet; fanGear only when mode=1', idempotent: true, exampleParams: ['{"mode":2}', '{"mode":1,"fanGear":2}'] },
171
+ { command: 'setChildLock', parameter: '0 | 1', description: 'Disable / enable child lock', idempotent: true, exampleParams: ['0', '1'] },
135
172
  ],
136
173
  statusFields: ['power', 'mode', 'childLock', 'filterElement'],
137
174
  },
138
175
  {
139
176
  type: 'Color Bulb',
140
177
  category: 'physical',
178
+ description: 'Wi-Fi smart bulb with tunable brightness, RGB color, and color temperature.',
179
+ role: 'lighting',
141
180
  commands: [...onOffToggle, ...lightControls],
142
181
  statusFields: ['power', 'brightness', 'color', 'colorTemperature', 'version'],
143
182
  },
144
183
  {
145
184
  type: 'Strip Light',
146
185
  category: 'physical',
147
- commands: [...onOffToggle, ...lightControls.slice(0, 2)],
148
- statusFields: ['power', 'brightness', 'color', 'version'],
186
+ description: 'Addressable LED strip with on/off, brightness, RGB color, and color temperature control.',
187
+ role: 'lighting',
188
+ aliases: ['Strip Light 3'],
189
+ commands: [...onOffToggle, ...lightControls],
190
+ statusFields: ['power', 'brightness', 'color', 'colorTemperature', 'version'],
149
191
  },
150
192
  {
151
193
  type: 'Ceiling Light',
152
194
  category: 'physical',
195
+ description: 'Smart ceiling fixture with brightness and color-temperature adjustment (no RGB).',
196
+ role: 'lighting',
153
197
  aliases: ['Ceiling Light Pro'],
154
198
  commands: [
155
199
  ...onOffToggle,
156
- { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage' },
157
- { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)' },
200
+ { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage', idempotent: true, exampleParams: ['50', '80'] },
201
+ { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)', idempotent: true, exampleParams: ['2700', '4000', '6500'] },
158
202
  ],
159
203
  statusFields: ['power', 'brightness', 'colorTemperature', 'version'],
160
204
  },
161
205
  {
162
206
  type: 'Smart Radiator Thermostat',
163
207
  category: 'physical',
208
+ description: 'Motorized thermostatic valve for radiators with schedule, manual, eco, and comfort modes.',
209
+ role: 'climate',
164
210
  commands: [
165
211
  ...onOff,
166
- { command: 'setMode', parameter: '0=schedule 1=manual 2=off 3=eco 4=comfort 5=quickHeat', description: 'Operating mode' },
167
- { command: 'setManualModeTemperature', parameter: '5-30 (°C)', description: 'Target temperature in manual mode' },
212
+ { command: 'setMode', parameter: '0=schedule 1=manual 2=off 3=eco 4=comfort 5=quickHeat', description: 'Operating mode', idempotent: true, exampleParams: ['1', '3'] },
213
+ { command: 'setManualModeTemperature', parameter: '5-30 (°C)', description: 'Target temperature in manual mode', idempotent: true, exampleParams: ['20', '22'] },
168
214
  ],
169
215
  statusFields: ['power', 'temperature', 'humidity', 'battery', 'version', 'mode', 'targetTemperature'],
170
216
  },
171
217
  {
172
218
  type: 'Robot Vacuum Cleaner S1',
173
219
  category: 'physical',
220
+ description: 'Entry-level robot vacuum with start/stop/dock and four suction power levels.',
221
+ role: 'cleaning',
174
222
  aliases: ['Robot Vacuum Cleaner S1 Plus', 'K10+'],
175
223
  commands: [
176
- { command: 'start', parameter: '—', description: 'Start cleaning' },
177
- { command: 'stop', parameter: '—', description: 'Stop cleaning' },
178
- { command: 'dock', parameter: '—', description: 'Return to dock' },
179
- { command: 'PowLevel', parameter: '0-3', description: '0=Quiet 1=Standard 2=Strong 3=Max' },
224
+ { command: 'start', parameter: '—', description: 'Start cleaning', idempotent: true },
225
+ { command: 'stop', parameter: '—', description: 'Stop cleaning', idempotent: true },
226
+ { command: 'dock', parameter: '—', description: 'Return to dock', idempotent: true },
227
+ { command: 'PowLevel', parameter: '0-3', description: '0=Quiet 1=Standard 2=Strong 3=Max', idempotent: true, exampleParams: ['0', '1', '2', '3'] },
180
228
  ],
181
229
  statusFields: ['workingStatus', 'onlineStatus', 'battery', 'version'],
182
230
  },
183
231
  {
184
232
  type: 'K10+ Pro Combo',
185
233
  category: 'physical',
234
+ description: 'Compact robot vacuum and mop combo with sweep/mop sessions, fan level, and water level.',
235
+ role: 'cleaning',
186
236
  aliases: ['K20+ Pro'],
187
237
  commands: [
188
- { command: 'startClean', parameter: '\'{"action":"sweep"|"mop","param":{"fanLevel":1-4,"times":1-2639999}}\'', description: 'Begin a cleaning session' },
189
- { command: 'pause', parameter: '—', description: 'Pause cleaning' },
190
- { command: 'dock', parameter: '—', description: 'Return to dock' },
191
- { command: 'setVolume', parameter: '0-100', description: 'Set voice volume' },
192
- { command: 'changeParam', parameter: '\'{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}\'', description: 'Change parameters mid-run' },
238
+ { command: 'startClean', parameter: '\'{"action":"sweep"|"mop","param":{"fanLevel":1-4,"times":1-2639999}}\'', description: 'Begin a cleaning session', idempotent: false, exampleParams: ['{"action":"sweep","param":{"fanLevel":2,"times":1}}'] },
239
+ { command: 'pause', parameter: '—', description: 'Pause cleaning', idempotent: true },
240
+ { command: 'dock', parameter: '—', description: 'Return to dock', idempotent: true },
241
+ { command: 'setVolume', parameter: '0-100', description: 'Set voice volume', idempotent: true, exampleParams: ['0', '50', '100'] },
242
+ { command: 'changeParam', parameter: '\'{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}\'', description: 'Change parameters mid-run', idempotent: true, exampleParams: ['{"fanLevel":3,"waterLevel":1,"times":1}'] },
193
243
  ],
194
244
  statusFields: ['workingStatus', 'onlineStatus', 'battery', 'taskType'],
195
245
  },
196
246
  {
197
247
  type: 'Floor Cleaning Robot S10',
198
248
  category: 'physical',
249
+ description: 'Advanced floor cleaning robot with sweep/mop modes, self-wash dock, and humidifier refill.',
250
+ role: 'cleaning',
199
251
  aliases: ['Robot Vacuum Cleaner S10', 'Robot Vacuum Cleaner S20'],
200
252
  commands: [
201
- { command: 'startClean', parameter: '\'{"action":"sweep"|"sweep_mop","param":{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}}\'', description: 'Begin a cleaning session' },
202
- { command: 'pause', parameter: '—', description: 'Pause cleaning' },
203
- { command: 'dock', parameter: '—', description: 'Return to dock' },
204
- { command: 'addWaterForHumi', parameter: '—', description: 'Refill the humidifier water tank' },
205
- { command: 'selfClean', parameter: '1 | 2 | 3', description: '1=wash mop | 2=dry | 3=terminate self-clean' },
206
- { command: 'setVolume', parameter: '0-100', description: 'Set voice volume' },
207
- { command: 'changeParam', parameter: '\'{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}\'', description: 'Change parameters mid-run' },
253
+ { command: 'startClean', parameter: '\'{"action":"sweep"|"sweep_mop","param":{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}}\'', description: 'Begin a cleaning session', idempotent: false, exampleParams: ['{"action":"sweep","param":{"fanLevel":2,"waterLevel":1,"times":1}}'] },
254
+ { command: 'pause', parameter: '—', description: 'Pause cleaning', idempotent: true },
255
+ { command: 'dock', parameter: '—', description: 'Return to dock', idempotent: true },
256
+ { command: 'addWaterForHumi', parameter: '—', description: 'Refill the humidifier water tank', idempotent: false },
257
+ { command: 'selfClean', parameter: '1 | 2 | 3', description: '1=wash mop | 2=dry | 3=terminate self-clean', idempotent: false, exampleParams: ['1', '2', '3'] },
258
+ { command: 'setVolume', parameter: '0-100', description: 'Set voice volume', idempotent: true, exampleParams: ['0', '50', '100'] },
259
+ { command: 'changeParam', parameter: '\'{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}\'', description: 'Change parameters mid-run', idempotent: true, exampleParams: ['{"fanLevel":3,"waterLevel":1,"times":1}'] },
208
260
  ],
209
261
  statusFields: ['workingStatus', 'onlineStatus', 'battery', 'taskType'],
210
262
  },
211
263
  {
212
264
  type: 'Battery Circulator Fan',
213
265
  category: 'physical',
266
+ description: 'Rechargeable table/floor fan with wind modes, speed control, night-light, and auto-off timer.',
267
+ role: 'fan',
214
268
  aliases: ['Circulator Fan'],
215
269
  commands: [
216
270
  ...onOffToggle,
217
- { command: 'setNightLightMode', parameter: 'off | 1 | 2', description: 'Night-light mode' },
218
- { command: 'setWindMode', parameter: 'direct | natural | sleep | baby', description: 'Wind mode' },
219
- { command: 'setWindSpeed', parameter: '1-100', description: 'Fan speed' },
220
- { command: 'closeDelay', parameter: 'seconds', description: 'Auto-off timer in seconds' },
271
+ { command: 'setNightLightMode', parameter: 'off | 1 | 2', description: 'Night-light mode', idempotent: true, exampleParams: ['off', '1', '2'] },
272
+ { command: 'setWindMode', parameter: 'direct | natural | sleep | baby', description: 'Wind mode', idempotent: true, exampleParams: ['natural', 'sleep'] },
273
+ { command: 'setWindSpeed', parameter: '1-100', description: 'Fan speed', idempotent: true, exampleParams: ['50', '100'] },
274
+ { command: 'closeDelay', parameter: 'seconds', description: 'Auto-off timer in seconds', idempotent: true, exampleParams: ['1800', '3600'] },
221
275
  ],
222
276
  statusFields: ['mode', 'version', 'battery', 'power', 'nightStatus', 'oscillation', 'verticalOscillation', 'chargingStatus', 'fanSpeed'],
223
277
  },
224
278
  {
225
279
  type: 'Blind Tilt',
226
280
  category: 'physical',
281
+ description: 'Motorized tilt rod for horizontal blinds; controls slat angle (0=closed, 100=open).',
282
+ role: 'curtain',
227
283
  commands: [
228
284
  ...onOff,
229
- { command: 'setPosition', parameter: '"<direction>;<angle>" (up|down; 0,2,...,100)', description: 'Tilt direction + angle (0=closed, 100=open)' },
230
- { command: 'fullyOpen', parameter: '—', description: 'Open fully' },
231
- { command: 'closeUp', parameter: '—', description: 'Close up' },
232
- { command: 'closeDown', parameter: '—', description: 'Close down' },
285
+ { command: 'setPosition', parameter: '"<direction>;<angle>" (up|down; 0,2,...,100)', description: 'Tilt direction + angle (0=closed, 100=open)', idempotent: true, exampleParams: ['up;50', 'down;80'] },
286
+ { command: 'fullyOpen', parameter: '—', description: 'Open fully', idempotent: true },
287
+ { command: 'closeUp', parameter: '—', description: 'Close up', idempotent: true },
288
+ { command: 'closeDown', parameter: '—', description: 'Close down', idempotent: true },
233
289
  ],
234
290
  statusFields: ['version', 'calibrate', 'group', 'moving', 'direction', 'slidePosition', 'battery'],
235
291
  },
236
292
  {
237
293
  type: 'Roller Shade',
238
294
  category: 'physical',
295
+ description: 'Motorized roller blind that moves to a set position (0=open, 100=closed).',
296
+ role: 'curtain',
239
297
  commands: [
240
298
  ...onOff,
241
- { command: 'setPosition', parameter: '0-100 (0=open, 100=closed)', description: 'Move to a position' },
299
+ { command: 'setPosition', parameter: '0-100 (0=open, 100=closed)', description: 'Move to a position', idempotent: true, exampleParams: ['0', '50', '100'] },
242
300
  ],
243
301
  statusFields: ['slidePosition', 'battery', 'version', 'moving'],
244
302
  },
245
303
  {
246
304
  type: 'Garage Door Opener',
247
305
  category: 'physical',
248
- commands: onOff,
306
+ description: 'Cloud-connected garage door controller; turnOn opens and turnOff closes the door.',
307
+ role: 'security',
308
+ commands: [
309
+ { command: 'turnOn', parameter: '—', description: 'Open the garage door', idempotent: true, destructive: true, destructiveReason: 'Opens the garage door — anyone nearby can enter the space.' },
310
+ { command: 'turnOff', parameter: '—', description: 'Close the garage door', idempotent: true, destructive: true, destructiveReason: 'Closes the garage door — verify no person or obstacle is in the way.' },
311
+ ],
249
312
  statusFields: ['switchStatus', 'version', 'online'],
250
313
  },
251
314
  {
252
315
  type: 'Video Doorbell',
253
316
  category: 'physical',
317
+ description: 'Wi-Fi video doorbell with motion detection enable/disable control.',
318
+ role: 'security',
254
319
  commands: [
255
- { command: 'enableMotionDetection', parameter: '—', description: 'Enable motion detection' },
256
- { command: 'disableMotionDetection', parameter: '—', description: 'Disable motion detection' },
320
+ { command: 'enableMotionDetection', parameter: '—', description: 'Enable motion detection', idempotent: true },
321
+ { command: 'disableMotionDetection', parameter: '—', description: 'Disable motion detection', idempotent: true },
257
322
  ],
258
323
  statusFields: ['battery', 'version'],
259
324
  },
260
325
  {
261
326
  type: 'Keypad',
262
327
  category: 'physical',
328
+ description: 'PIN-pad access controller that creates and deletes door passcodes for a Smart Lock.',
329
+ role: 'security',
263
330
  aliases: ['Keypad Touch'],
264
331
  commands: [
265
- { command: 'createKey', parameter: '\'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":<s>,"endTime":<s>}\'', description: 'Create a passcode (async; result via webhook)' },
266
- { command: 'deleteKey', parameter: '\'{"id":<passcode_id>}\'', description: 'Delete a passcode (async; result via webhook)' },
332
+ { command: 'createKey', parameter: '\'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":<s>,"endTime":<s>}\'', description: 'Create a passcode (async; result via webhook)', idempotent: false, destructive: true, destructiveReason: 'Provisions a new access credential — anyone with this passcode can unlock the door.' },
333
+ { command: 'deleteKey', parameter: '\'{"id":<passcode_id>}\'', description: 'Delete a passcode (async; result via webhook)', idempotent: true, destructive: true, destructiveReason: 'Permanently removes a passcode — the holder immediately loses door access.' },
267
334
  ],
268
335
  statusFields: ['version'],
269
336
  },
270
337
  {
271
338
  type: 'Candle Warmer Lamp',
272
339
  category: 'physical',
340
+ description: 'Decorative candle-warmer lamp with adjustable brightness and color temperature.',
341
+ role: 'lighting',
273
342
  commands: [
274
343
  ...onOffToggle,
275
- { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage' },
276
- { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)' },
344
+ { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage', idempotent: true, exampleParams: ['50', '80'] },
345
+ { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)', idempotent: true, exampleParams: ['2700', '4000'] },
277
346
  ],
278
347
  statusFields: ['power', 'brightness', 'colorTemperature', 'version'],
279
348
  },
@@ -281,6 +350,9 @@ export const DEVICE_CATALOG = [
281
350
  {
282
351
  type: 'Meter',
283
352
  category: 'physical',
353
+ description: 'Battery-powered temperature and humidity sensor; read-only, no control commands.',
354
+ role: 'sensor',
355
+ readOnly: true,
284
356
  aliases: ['Meter Plus', 'MeterPro', 'MeterPro(CO2)', 'WoIOSensor', 'Hub 2'],
285
357
  commands: [],
286
358
  statusFields: ['temperature', 'humidity', 'CO2', 'battery', 'version'],
@@ -288,85 +360,162 @@ export const DEVICE_CATALOG = [
288
360
  {
289
361
  type: 'Motion Sensor',
290
362
  category: 'physical',
363
+ description: 'PIR motion detector that reports movement and ambient brightness; read-only.',
364
+ role: 'sensor',
365
+ readOnly: true,
291
366
  commands: [],
292
367
  statusFields: ['battery', 'version', 'moveDetected', 'brightness', 'openState'],
293
368
  },
294
369
  {
295
370
  type: 'Contact Sensor',
296
371
  category: 'physical',
372
+ description: 'Door or window open/close sensor that also reports movement and brightness; read-only.',
373
+ role: 'sensor',
374
+ readOnly: true,
297
375
  commands: [],
298
376
  statusFields: ['battery', 'version', 'moveDetected', 'openState', 'brightness'],
299
377
  },
300
378
  {
301
379
  type: 'Water Leak Detector',
302
380
  category: 'physical',
381
+ description: 'Water sensor that reports leak status; read-only, no control commands.',
382
+ role: 'sensor',
383
+ readOnly: true,
303
384
  commands: [],
304
385
  statusFields: ['battery', 'version', 'status'],
305
386
  },
387
+ // Status-only hub-class devices (no control commands)
388
+ {
389
+ type: 'Hub Mini',
390
+ category: 'physical',
391
+ description: 'IR hub that bridges BLE devices to the cloud and learns IR remotes; no direct control commands.',
392
+ role: 'hub',
393
+ readOnly: true,
394
+ aliases: ['Hub Mini2'],
395
+ commands: [],
396
+ statusFields: ['version'],
397
+ },
398
+ {
399
+ type: 'Hub 3',
400
+ category: 'physical',
401
+ description: 'Wi-Fi hub with built-in temperature, humidity, and light sensors; manages local BLE devices.',
402
+ role: 'hub',
403
+ readOnly: true,
404
+ commands: [],
405
+ statusFields: ['version', 'temperature', 'humidity', 'lightLevel'],
406
+ },
407
+ {
408
+ type: 'AI Hub',
409
+ category: 'physical',
410
+ description: 'Advanced hub with AI-based automations; bridges BLE devices to the cloud; read-only status.',
411
+ role: 'hub',
412
+ readOnly: true,
413
+ commands: [],
414
+ statusFields: ['version'],
415
+ },
416
+ {
417
+ type: 'Home Climate Panel',
418
+ category: 'physical',
419
+ description: 'Wall-mounted display showing temperature and humidity; sensor-only, no control.',
420
+ role: 'climate',
421
+ readOnly: true,
422
+ commands: [],
423
+ statusFields: ['temperature', 'humidity', 'version'],
424
+ },
425
+ {
426
+ type: 'Wallet Finder Card',
427
+ category: 'physical',
428
+ description: 'Slim Bluetooth tracker card for locating wallets; reports battery status only.',
429
+ role: 'sensor',
430
+ readOnly: true,
431
+ commands: [],
432
+ statusFields: ['battery', 'version'],
433
+ },
434
+ {
435
+ type: 'Outdoor Spotlight Cam',
436
+ category: 'physical',
437
+ description: 'Battery-powered outdoor security camera with spotlight; status-only via cloud API.',
438
+ role: 'security',
439
+ readOnly: true,
440
+ commands: [],
441
+ statusFields: ['battery', 'version'],
442
+ },
306
443
  // ---------- Virtual IR remotes ----------
307
444
  {
308
445
  type: 'Air Conditioner',
309
446
  category: 'ir',
447
+ description: 'IR-controlled air conditioner with on/off and full HVAC parameter control (mode, fan, temp).',
448
+ role: 'climate',
310
449
  commands: [
311
450
  ...onOff,
312
- { command: 'setAll', parameter: '"<temp>,<mode>,<fan>,<on|off>"', description: 'mode: 1=auto 2=cool 3=dry 4=fan 5=heat; fan: 1=auto 2=low 3=mid 4=high' },
451
+ { command: 'setAll', parameter: '"<temp>,<mode>,<fan>,<on|off>"', description: 'mode: 1=auto 2=cool 3=dry 4=fan 5=heat; fan: 1=auto 2=low 3=mid 4=high', idempotent: true, exampleParams: ['26,2,3,on', '22,5,2,on'] },
313
452
  ],
314
453
  },
315
454
  {
316
455
  type: 'TV',
317
456
  category: 'ir',
457
+ description: 'IR-controlled television or streaming device with channel, volume, and power commands.',
458
+ role: 'media',
318
459
  aliases: ['IPTV', 'Streamer', 'Set Top Box'],
319
460
  commands: [
320
461
  ...onOff,
321
- { command: 'SetChannel', parameter: '1-999 (channel number)', description: 'Switch to a specific channel' },
322
- { command: 'volumeAdd', parameter: '—', description: 'Volume up' },
323
- { command: 'volumeSub', parameter: '—', description: 'Volume down' },
324
- { command: 'channelAdd', parameter: '—', description: 'Channel up' },
325
- { command: 'channelSub', parameter: '—', description: 'Channel down' },
462
+ { command: 'SetChannel', parameter: '1-999 (channel number)', description: 'Switch to a specific channel', idempotent: true, exampleParams: ['1', '15'] },
463
+ { command: 'volumeAdd', parameter: '—', description: 'Volume up', idempotent: false },
464
+ { command: 'volumeSub', parameter: '—', description: 'Volume down', idempotent: false },
465
+ { command: 'channelAdd', parameter: '—', description: 'Channel up', idempotent: false },
466
+ { command: 'channelSub', parameter: '—', description: 'Channel down', idempotent: false },
326
467
  ],
327
468
  },
328
469
  {
329
470
  type: 'DVD',
330
471
  category: 'ir',
472
+ description: 'IR-controlled disc player or speaker with playback, track navigation, and volume commands.',
473
+ role: 'media',
331
474
  aliases: ['Speaker'],
332
475
  commands: [
333
476
  ...onOff,
334
- { command: 'setMute', parameter: '—', description: 'Toggle mute' },
335
- { command: 'FastForward', parameter: '—', description: 'Fast forward' },
336
- { command: 'Rewind', parameter: '—', description: 'Rewind' },
337
- { command: 'Next', parameter: '—', description: 'Next track' },
338
- { command: 'Previous', parameter: '—', description: 'Previous track' },
339
- { command: 'Pause', parameter: '—', description: 'Pause' },
340
- { command: 'Play', parameter: '—', description: 'Play' },
341
- { command: 'Stop', parameter: '—', description: 'Stop' },
342
- { command: 'volumeAdd', parameter: '—', description: 'Volume up' },
343
- { command: 'volumeSub', parameter: '—', description: 'Volume down' },
477
+ { command: 'setMute', parameter: '—', description: 'Toggle mute', idempotent: false },
478
+ { command: 'FastForward', parameter: '—', description: 'Fast forward', idempotent: false },
479
+ { command: 'Rewind', parameter: '—', description: 'Rewind', idempotent: false },
480
+ { command: 'Next', parameter: '—', description: 'Next track', idempotent: false },
481
+ { command: 'Previous', parameter: '—', description: 'Previous track', idempotent: false },
482
+ { command: 'Pause', parameter: '—', description: 'Pause', idempotent: true },
483
+ { command: 'Play', parameter: '—', description: 'Play', idempotent: true },
484
+ { command: 'Stop', parameter: '—', description: 'Stop', idempotent: true },
485
+ { command: 'volumeAdd', parameter: '—', description: 'Volume up', idempotent: false },
486
+ { command: 'volumeSub', parameter: '—', description: 'Volume down', idempotent: false },
344
487
  ],
345
488
  },
346
489
  {
347
490
  type: 'Fan',
348
491
  category: 'ir',
492
+ description: 'IR-controlled fan with on/off, swing, timer, and speed preset commands.',
493
+ role: 'fan',
349
494
  commands: [
350
495
  ...onOff,
351
- { command: 'swing', parameter: '—', description: 'Toggle swing' },
352
- { command: 'timer', parameter: '—', description: 'Toggle timer' },
353
- { command: 'lowSpeed', parameter: '—', description: 'Low speed' },
354
- { command: 'middleSpeed', parameter: '—', description: 'Medium speed' },
355
- { command: 'highSpeed', parameter: '—', description: 'High speed' },
496
+ { command: 'swing', parameter: '—', description: 'Toggle swing', idempotent: false },
497
+ { command: 'timer', parameter: '—', description: 'Toggle timer', idempotent: false },
498
+ { command: 'lowSpeed', parameter: '—', description: 'Low speed', idempotent: true },
499
+ { command: 'middleSpeed', parameter: '—', description: 'Medium speed', idempotent: true },
500
+ { command: 'highSpeed', parameter: '—', description: 'High speed', idempotent: true },
356
501
  ],
357
502
  },
358
503
  {
359
504
  type: 'Light',
360
505
  category: 'ir',
506
+ description: 'IR-controlled light fixture with on/off and relative brightness adjustment commands.',
507
+ role: 'lighting',
361
508
  commands: [
362
509
  ...onOff,
363
- { command: 'brightnessUp', parameter: '—', description: 'Brightness up' },
364
- { command: 'brightnessDown', parameter: '—', description: 'Brightness down' },
510
+ { command: 'brightnessUp', parameter: '—', description: 'Brightness up', idempotent: false },
511
+ { command: 'brightnessDown', parameter: '—', description: 'Brightness down', idempotent: false },
365
512
  ],
366
513
  },
367
514
  {
368
515
  type: 'Others',
369
516
  category: 'ir',
517
+ description: 'Catch-all for custom IR remotes with user-defined button names learned by a Hub.',
518
+ role: 'other',
370
519
  commands: [
371
520
  { command: '<buttonName>', parameter: '—', description: 'User-defined custom IR button (requires --type customize)', commandType: 'customize' },
372
521
  ],
@@ -378,14 +527,135 @@ export function findCatalogEntry(query) {
378
527
  if (!q)
379
528
  return null;
380
529
  const names = (e) => [e.type, ...(e.aliases ?? [])];
381
- const exact = DEVICE_CATALOG.find((e) => names(e).some((n) => n.toLowerCase() === q));
530
+ const catalog = getEffectiveCatalog();
531
+ const exact = catalog.find((e) => names(e).some((n) => n.toLowerCase() === q));
382
532
  if (exact)
383
533
  return exact;
384
- const matches = DEVICE_CATALOG.filter((e) => names(e).some((n) => n.toLowerCase().includes(q)));
534
+ const matches = catalog.filter((e) => names(e).some((n) => n.toLowerCase().includes(q)));
385
535
  if (matches.length === 0)
386
536
  return null;
387
537
  if (matches.length === 1)
388
538
  return matches[0];
389
539
  return matches;
390
540
  }
541
+ /**
542
+ * Pick up to 3 non-destructive, idempotent commands an agent can safely invoke
543
+ * to explore or exercise a device. Used by `devices describe --json` to hint
544
+ * at concrete next steps.
545
+ */
546
+ export function suggestedActions(entry) {
547
+ const safe = entry.commands.filter((c) => c.idempotent === true && !c.destructive && c.commandType !== 'customize');
548
+ const picks = [];
549
+ const seen = new Set();
550
+ for (const c of safe) {
551
+ if (seen.has(c.command))
552
+ continue;
553
+ seen.add(c.command);
554
+ picks.push(c);
555
+ if (picks.length >= 3)
556
+ break;
557
+ }
558
+ return picks.map((c) => ({
559
+ command: c.command,
560
+ parameter: c.exampleParams?.[0],
561
+ description: c.description,
562
+ }));
563
+ }
564
+ // ---- Overlay loader ----------------------------------------------------
565
+ //
566
+ // Users can drop a `~/.switchbot/catalog.json` file to override or extend
567
+ // the built-in catalog without waiting on a CLI release. The overlay is a
568
+ // list of DeviceCatalogEntry objects; each entry matches on `type`:
569
+ // - Entry with `type` matching a built-in replaces that built-in entry.
570
+ // - Entry with a new `type` is appended.
571
+ // - Entry with `{ type: "X", remove: true }` deletes the built-in.
572
+ //
573
+ // The overlay is loaded once per process and cached. Malformed JSON or
574
+ // files that don't match the expected shape are ignored (with a warning
575
+ // to stderr in verbose mode).
576
+ import fs from 'node:fs';
577
+ import path from 'node:path';
578
+ import os from 'node:os';
579
+ function overlayFilePath() {
580
+ return path.join(os.homedir(), '.switchbot', 'catalog.json');
581
+ }
582
+ export function getCatalogOverlayPath() {
583
+ return overlayFilePath();
584
+ }
585
+ /** Read the overlay file. Never throws — returns `error` on bad files. */
586
+ export function loadCatalogOverlay() {
587
+ const file = overlayFilePath();
588
+ if (!fs.existsSync(file)) {
589
+ return { path: file, exists: false, entries: [] };
590
+ }
591
+ try {
592
+ const raw = fs.readFileSync(file, 'utf-8');
593
+ const parsed = JSON.parse(raw);
594
+ if (!Array.isArray(parsed)) {
595
+ return {
596
+ path: file,
597
+ exists: true,
598
+ entries: [],
599
+ error: 'overlay must be a JSON array of device catalog entries',
600
+ };
601
+ }
602
+ const entries = [];
603
+ for (const item of parsed) {
604
+ if (!item || typeof item !== 'object' || typeof item.type !== 'string') {
605
+ return {
606
+ path: file,
607
+ exists: true,
608
+ entries: [],
609
+ error: 'every overlay entry must be an object with a string `type`',
610
+ };
611
+ }
612
+ entries.push(item);
613
+ }
614
+ return { path: file, exists: true, entries };
615
+ }
616
+ catch (err) {
617
+ return {
618
+ path: file,
619
+ exists: true,
620
+ entries: [],
621
+ error: err instanceof Error ? err.message : String(err),
622
+ };
623
+ }
624
+ }
625
+ let overlayCache = null;
626
+ function overlayOnce() {
627
+ if (overlayCache === null)
628
+ overlayCache = loadCatalogOverlay();
629
+ return overlayCache;
630
+ }
631
+ /** Clear the overlay cache (test helper; also useful for `catalog refresh`). */
632
+ export function resetCatalogOverlayCache() {
633
+ overlayCache = null;
634
+ }
635
+ /** Merge built-in catalog with the on-disk overlay. */
636
+ export function getEffectiveCatalog() {
637
+ const overlay = overlayOnce();
638
+ if (!overlay.entries.length)
639
+ return DEVICE_CATALOG;
640
+ const byType = new Map();
641
+ for (const e of DEVICE_CATALOG)
642
+ byType.set(e.type, e);
643
+ for (const entry of overlay.entries) {
644
+ if (entry.remove) {
645
+ byType.delete(entry.type);
646
+ continue;
647
+ }
648
+ const existing = byType.get(entry.type);
649
+ if (existing) {
650
+ byType.set(entry.type, { ...existing, ...entry });
651
+ }
652
+ else if (entry.category && entry.commands) {
653
+ // New entry — require the fields the renderer needs. Missing fields
654
+ // would make the new entry crash later, so skip silently rather than
655
+ // ship half-valid data to the user.
656
+ byType.set(entry.type, entry);
657
+ }
658
+ }
659
+ return Array.from(byType.values());
660
+ }
391
661
  //# sourceMappingURL=catalog.js.map