@thinger-io/cli-core 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +82 -31
  3. package/lib/bucket-export.js +91 -0
  4. package/lib/cli.js +106 -50
  5. package/lib/commands/_shared.js +89 -1
  6. package/lib/commands/api.js +90 -0
  7. package/lib/commands/arguments.js +62 -0
  8. package/lib/commands/bucket.js +52 -44
  9. package/lib/commands/dashboard.js +5 -2
  10. package/lib/commands/data.js +539 -0
  11. package/lib/commands/device.js +128 -241
  12. package/lib/commands/grammar.js +279 -0
  13. package/lib/commands/product.js +104 -214
  14. package/lib/commands/profile.js +1 -0
  15. package/lib/commands/resources.js +975 -0
  16. package/lib/dashboard-init.js +4 -3
  17. package/lib/dashboard.js +10 -2
  18. package/lib/mcp/catalog.js +301 -63
  19. package/lib/mcp/core-types.js +36 -0
  20. package/lib/mcp/helpers.js +23 -2
  21. package/lib/mcp/naming.js +20 -0
  22. package/lib/mcp/registry.js +18 -67
  23. package/lib/mcp/resource-hooks.js +19 -5
  24. package/lib/mcp/server.js +43 -11
  25. package/lib/mcp/tools-resource.js +1232 -330
  26. package/lib/mcp/types/bucket-data.js +151 -0
  27. package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
  28. package/lib/mcp/types/device-resource.js +117 -0
  29. package/lib/mcp/types/doc.js +35 -0
  30. package/lib/mcp/types/plugin-call.js +75 -0
  31. package/lib/mcp/types/product-api.js +44 -0
  32. package/lib/mcp/types/profile.js +37 -0
  33. package/lib/mcp/types/storage-file.js +170 -0
  34. package/lib/openapi.js +18 -0
  35. package/lib/product-apply.js +121 -0
  36. package/lib/raw-api.js +111 -0
  37. package/lib/storage.js +41 -13
  38. package/lib/tokens.js +16 -2
  39. package/package.json +1 -1
  40. package/lib/mcp/tools-buckets.js +0 -130
  41. package/lib/mcp/tools-devices.js +0 -134
  42. package/lib/mcp/tools-misc.js +0 -48
  43. package/lib/mcp/tools-plugins.js +0 -63
  44. package/lib/mcp/tools-products.js +0 -52
  45. package/lib/mcp/tools-storage.js +0 -173
@@ -5,159 +5,163 @@ import {
5
5
  createDevice,
6
6
  deleteDevice,
7
7
  generateDeviceCredentials,
8
- getDevice,
9
8
  getDevices,
10
9
  } from '../devices.js';
11
- import {
12
- formatDeviceProperties,
13
- formatDeviceProperty,
14
- getDeviceProperties,
15
- getDeviceProperty,
16
- setDeviceProperty,
17
- } from '../property.js';
18
- import {
19
- callDeviceResource,
20
- formatDeviceResourcesWithSchemas,
21
- listDeviceResourcesWithSchemas,
22
- } from '../resource.js';
23
10
  import { setDeviceProduct } from '../product.js';
24
- import { hint, info, label, success, warning } from '../format.js';
11
+ import { inputError } from '../errors.js';
12
+ import { hint, info, label, success } from '../format.js';
25
13
  import { classifyError, isJsonMode, printErr, printOk } from '../output.js';
14
+ import {
15
+ hasListOptions,
16
+ listOptions,
17
+ listRecords,
18
+ projectsOption,
19
+ setProjects,
20
+ } from './resources.js';
26
21
  import {
27
22
  applyJsonFlag,
28
- collectKeyValue,
23
+ bodyFromOptions,
24
+ bodyOptions,
29
25
  confirmDestructive,
30
26
  ensureConfigured,
31
- extractField,
32
27
  getGlobalUser,
33
- parseJsonValue,
34
28
  runCommand,
35
29
  } from './_shared.js';
36
30
 
37
31
  /**
38
- * `<cli> device <action> <deviceId> …` — device records, their properties
39
- * and the resources they expose.
32
+ * `<cli> device <verb> <device> …` — the device records written by hand
33
+ * (list as a table, create with generated credentials, delete, set-product);
34
+ * the rest of the group (get, update, property, resource, send…) comes from
35
+ * the tool and resource commands.
40
36
  */
41
37
  export function registerDeviceCommands(program) {
42
38
  const device = program
43
39
  .command('device')
44
- .helpGroup('Operations:')
45
- .description('Devices: list, inspect, create, delete, properties and resources');
46
-
47
- device
48
- .command('list [pattern]')
49
- .helpGroup('Discovery:')
50
- .description(
51
- 'List devices, optionally filtered by name/id pattern (case-insensitive regex)',
52
- )
53
- .option('-j, --json', 'Output as JSON')
54
- .action(async (pattern, opts, cmd) => {
55
- applyJsonFlag(opts);
56
- ensureConfigured();
57
- const user = getGlobalUser(cmd);
58
- const filter = pattern ? { name: pattern } : {};
59
- await runCommand({
60
- start: pattern
61
- ? `Searching devices matching "${pattern}"...`
62
- : 'Fetching devices...',
63
- fn: () => getDevices(filter, user),
64
- success: (devices) => `Found ${devices.length} device(s)`,
65
- failure: 'Failed to list devices',
66
- onSuccess: (devices) => {
67
- if (isJsonMode()) {
68
- printOk(devices);
69
- return;
70
- }
71
- // Pad the id column so trailing names line up even when
72
- // ids have very different widths. A record without an id
73
- // can exist (a broken document on the server): show it
74
- // rather than fail the whole listing.
75
- const idOf = (d) => d.device ?? '(no id)';
76
- const idWidth = devices.reduce((w, d) => Math.max(w, idOf(d).length), 0);
77
- for (const d of devices) {
78
- const online = d.connection?.active ? success('online ') : hint('offline');
79
- const id = label(idOf(d).padEnd(idWidth));
80
- const extras = [
81
- d.name ? hint(`(${d.name})`) : '',
82
- d.type && d.type !== 'Generic' ? hint(`<${d.type}>`) : '',
83
- d.product ? info(`[${d.product}]`) : '',
84
- ].filter(Boolean);
85
- console.log(
86
- ` ${online} ${id}${extras.length ? ' ' + extras.join(' ') : ''}`,
87
- );
88
- }
89
- },
90
- });
91
- });
40
+ .helpGroup('Resources:')
41
+ .description('Devices: records, properties, resources, data and their children');
92
42
 
93
- device
94
- .command('info <deviceId>')
95
- .helpGroup('Discovery:')
96
- .description('Show the server record of a device (type, product, connection…)')
97
- .option('-j, --json', 'Output as JSON')
98
- .action(async (deviceId, opts, cmd) => {
99
- applyJsonFlag(opts);
100
- ensureConfigured();
101
- const user = getGlobalUser(cmd);
102
- await runCommand({
103
- start: `Fetching ${deviceId}...`,
104
- fn: () => getDevice(deviceId, user),
105
- success: null,
106
- failure: 'Failed to read device',
107
- onSuccess: (record) => {
108
- if (isJsonMode()) printOk(record);
109
- else console.log(JSON.stringify(record, null, 2));
110
- },
111
- });
43
+ listOptions(
44
+ device
45
+ .command('list')
46
+ .helpGroup('Records:')
47
+ .description('List devices: online state, id, name, type and product')
48
+ .option('-j, --json', 'Output as JSON'),
49
+ ).action(async (opts, cmd) => {
50
+ applyJsonFlag(opts);
51
+ ensureConfigured();
52
+ const user = getGlobalUser(cmd);
53
+ await runCommand({
54
+ start: 'Fetching devices...',
55
+ fn: () =>
56
+ hasListOptions(opts) ? listRecords('device', opts, user) : getDevices({}, user),
57
+ success: (devices) => `Found ${devices.length} device(s)`,
58
+ failure: 'Failed to list devices',
59
+ onSuccess: (devices) => {
60
+ if (isJsonMode()) {
61
+ printOk(devices);
62
+ return;
63
+ }
64
+ // Pad the id column so trailing names line up even when
65
+ // ids have very different widths. A record without an id
66
+ // can exist (a broken document on the server): show it
67
+ // rather than fail the whole listing.
68
+ const idOf = (d) => d.device ?? '(no id)';
69
+ const idWidth = devices.reduce((w, d) => Math.max(w, idOf(d).length), 0);
70
+ for (const d of devices) {
71
+ const online = d.connection?.active ? success('online ') : hint('offline');
72
+ const id = label(idOf(d).padEnd(idWidth));
73
+ const extras = [
74
+ d.name ? hint(`(${d.name})`) : '',
75
+ d.type && d.type !== 'Generic' ? hint(`<${d.type}>`) : '',
76
+ d.product ? info(`[${d.product}]`) : '',
77
+ ].filter(Boolean);
78
+ console.log(
79
+ ` ${online} ${id}${extras.length ? ' ' + extras.join(' ') : ''}`,
80
+ );
81
+ }
82
+ },
112
83
  });
84
+ });
113
85
 
114
- device
115
- .command('create <deviceId>')
116
- .helpGroup('Records:')
117
- .description('Register a device; Generic/MQTT ones get random credentials unless given')
118
- .option('-t, --type <type>', `Device type: ${DEVICE_TYPES.join(', ')}`, 'Generic')
119
- .option('-c, --credentials <credentials>', 'Device credentials (6+ characters)')
120
- .option('-n, --name <name>', 'Display name')
121
- .option('-d, --description <text>', 'Description')
122
- .option('-p, --product <productId>', 'Assign to a product')
86
+ projectsOption(
87
+ bodyOptions(
88
+ device
89
+ .command('create [device]')
90
+ .helpGroup('Records:')
91
+ .description(
92
+ 'Register a device; Generic/MQTT ones get random credentials unless given',
93
+ )
94
+ .option(
95
+ '-t, --type <type>',
96
+ `Device type: ${DEVICE_TYPES.join(', ')} (default: Generic)`,
97
+ )
98
+ .option('-c, --credentials <credentials>', 'Device credentials (6+ characters)')
99
+ .option('-n, --name <name>', 'Display name')
100
+ .option('-d, --description <text>', 'Description')
101
+ .option('-p, --product <product>', 'Assign to a product'),
102
+ ),
103
+ )
123
104
  .option('-j, --json', 'Output as JSON')
124
- .action(async (deviceId, opts, cmd) => {
105
+ .action(async (id, opts, cmd) => {
125
106
  applyJsonFlag(opts);
126
107
  ensureConfigured();
127
108
  const user = getGlobalUser(cmd);
128
- if (!DEVICE_TYPES.includes(opts.type)) {
109
+ let body;
110
+ try {
111
+ // The flags go over what --from and --set give.
112
+ body = (await bodyFromOptions(opts)) ?? {};
113
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
114
+ throw inputError('create takes a JSON object');
115
+ }
116
+ } catch (error) {
117
+ const { message, code } = classifyError(error);
118
+ printErr(message, { code });
119
+ return;
120
+ }
121
+ const deviceId = id ?? body.device;
122
+ const type = opts.type ?? body.type ?? 'Generic';
123
+ if (!deviceId) {
124
+ printErr('give the device id: <device>, or the body field "device"', {
125
+ code: 'input_error',
126
+ });
127
+ return;
128
+ }
129
+ if (!DEVICE_TYPES.includes(type)) {
129
130
  printErr(`--type must be one of ${DEVICE_TYPES.join(', ')}`, {
130
131
  code: 'input_error',
131
132
  });
132
133
  return;
133
134
  }
134
- const needsCredentials = CREDENTIAL_DEVICE_TYPES.has(opts.type);
135
+ const needsCredentials = CREDENTIAL_DEVICE_TYPES.has(type);
135
136
  if (!needsCredentials && opts.credentials) {
136
- printErr(`${opts.type} devices do not take credentials`, { code: 'input_error' });
137
+ printErr(`${type} devices do not take credentials`, { code: 'input_error' });
137
138
  return;
138
139
  }
139
- if (opts.credentials && opts.credentials.length < 6) {
140
+ const given = opts.credentials ?? body.credentials;
141
+ if (given && given.length < 6) {
140
142
  printErr('credentials must be at least 6 characters', { code: 'input_error' });
141
143
  return;
142
144
  }
143
- const credentials = needsCredentials
144
- ? opts.credentials || generateDeviceCredentials()
145
- : undefined;
145
+ const credentials = needsCredentials ? given || generateDeviceCredentials() : undefined;
146
146
  await runCommand({
147
147
  start: `Creating ${deviceId}...`,
148
- fn: () =>
149
- createDevice(
148
+ fn: async () => {
149
+ const sent = await createDevice(
150
150
  {
151
+ ...body,
151
152
  device: deviceId,
152
- type: opts.type,
153
- name: opts.name,
154
- description: opts.description,
153
+ type,
154
+ name: opts.name ?? body.name,
155
+ description: opts.description ?? body.description,
155
156
  credentials,
156
- product: opts.product,
157
+ product: opts.product ?? body.product,
157
158
  },
158
159
  user,
159
- ),
160
- success: `Created ${opts.type} device ${deviceId}`,
160
+ );
161
+ if (opts.projects) await setProjects('device', deviceId, opts.projects, user);
162
+ return sent;
163
+ },
164
+ success: `Created ${type} device ${deviceId}`,
161
165
  failure: 'Failed to create device',
162
166
  onSuccess: (sent) => {
163
167
  if (isJsonMode()) {
@@ -177,35 +181,26 @@ export function registerDeviceCommands(program) {
177
181
  });
178
182
 
179
183
  device
180
- .command('delete <deviceId>')
184
+ .command('delete <device>')
181
185
  .helpGroup('Records:')
182
186
  .description('Delete a device record from the platform (irreversible)')
183
187
  .option('-y, --yes', 'Skip the interactive confirmation prompt')
184
188
  .option('-j, --json', 'Output as JSON')
185
- .action(async (deviceId, opts, cmd) => {
189
+ .action(async (id, opts, cmd) => {
186
190
  applyJsonFlag(opts);
187
191
  ensureConfigured();
188
192
  const user = getGlobalUser(cmd);
189
- if (
190
- !(await confirmDestructive(
191
- opts,
192
- `Delete device ${deviceId}? This cannot be undone.`,
193
- ))
194
- ) {
193
+ if (!(await confirmDestructive(opts, `Delete device ${id}? This cannot be undone.`))) {
195
194
  console.log(hint('Cancelled.'));
196
195
  return;
197
196
  }
198
197
  try {
199
- const removed = await deleteDevice(deviceId, user);
198
+ const removed = await deleteDevice(id, user);
200
199
  if (isJsonMode()) {
201
- printOk({ device: deviceId, removed });
200
+ printOk({ device: id, removed });
202
201
  return;
203
202
  }
204
- console.log(
205
- removed
206
- ? `Deleted ${info(deviceId)}`
207
- : `Device ${info(deviceId)} did not exist`,
208
- );
203
+ console.log(removed ? `Deleted ${info(id)}` : `Device ${info(id)} did not exist`);
209
204
  } catch (error) {
210
205
  const { message, code } = classifyError(error);
211
206
  printErr(message, { code });
@@ -213,129 +208,21 @@ export function registerDeviceCommands(program) {
213
208
  });
214
209
 
215
210
  device
216
- .command('set-product <deviceId> <productId>')
217
- .helpGroup('Records:')
211
+ .command('set-product <device> <product>')
212
+ .helpGroup('Actions:')
218
213
  .description('Assign a device to a product ("" to unassign)')
219
214
  .option('-j, --json', 'Output as JSON')
220
- .action(async (deviceId, productId, opts, cmd) => {
215
+ .action(async (id, productId, opts, cmd) => {
221
216
  applyJsonFlag(opts);
222
217
  ensureConfigured();
223
218
  const user = getGlobalUser(cmd);
224
219
  await runCommand({
225
- start: `Assigning ${deviceId}...`,
226
- fn: () => setDeviceProduct(deviceId, productId, user),
227
- success: productId
228
- ? `Assigned ${deviceId} → ${productId}`
229
- : `Unassigned ${deviceId}`,
220
+ start: `Assigning ${id}...`,
221
+ fn: () => setDeviceProduct(id, productId, user),
222
+ success: productId ? `Assigned ${id} → ${productId}` : `Unassigned ${id}`,
230
223
  failure: 'Failed to assign product',
231
224
  onSuccess: () => {
232
- if (isJsonMode()) printOk({ device: deviceId, product: productId || null });
233
- },
234
- });
235
- });
236
-
237
- device
238
- .command('property <deviceId> [propertyId]')
239
- .helpGroup('State & resources:')
240
- .description('List device properties, or read one by ID')
241
- .option('-j, --json', 'Output as JSON')
242
- .option('-f, --field <field>', 'Extract a sub-field (dot path, e.g. data.value)')
243
- .action(async (deviceId, propertyId, opts, cmd) => {
244
- applyJsonFlag(opts);
245
- ensureConfigured();
246
- const user = getGlobalUser(cmd);
247
- if (!propertyId) {
248
- await runCommand({
249
- start: `Getting properties for ${deviceId}...`,
250
- fn: () => getDeviceProperties(deviceId, user),
251
- success: null,
252
- failure: 'Property retrieval failed',
253
- onSuccess: (result) => {
254
- if (isJsonMode()) printOk(result);
255
- else console.log(formatDeviceProperties(deviceId, result));
256
- },
257
- });
258
- return;
259
- }
260
- await runCommand({
261
- start: `Retrieving property for ${deviceId}...`,
262
- fn: () => getDeviceProperty(deviceId, propertyId, user),
263
- success: null,
264
- failure: 'Property retrieval failed',
265
- onSuccess: (property) => {
266
- const value = extractField(property, opts.field);
267
- if (isJsonMode()) printOk(value);
268
- else if (opts.field) console.log(value);
269
- else console.log(formatDeviceProperty(deviceId, propertyId, property));
270
- },
271
- });
272
- });
273
-
274
- device
275
- .command('property-set <deviceId> <propertyId> <value>')
276
- .helpGroup('State & resources:')
277
- .description('Create or overwrite a device property (value parsed as JSON)')
278
- .option('-j, --json', 'Output as JSON')
279
- .action(async (deviceId, propertyId, value, opts, cmd) => {
280
- applyJsonFlag(opts);
281
- ensureConfigured();
282
- const user = getGlobalUser(cmd);
283
- const parsed = parseJsonValue(value);
284
- await runCommand({
285
- start: `Saving ${propertyId} on ${deviceId}...`,
286
- fn: () => setDeviceProperty(deviceId, propertyId, parsed, user),
287
- success: `Saved ${propertyId} on ${deviceId}`,
288
- failure: 'Failed to save property',
289
- onSuccess: () => {
290
- if (isJsonMode())
291
- printOk({ device: deviceId, property: propertyId, value: parsed });
292
- },
293
- });
294
- });
295
-
296
- device
297
- .command('resource <deviceId> [resource]')
298
- .helpGroup('State & resources:')
299
- .description('List device resources, or call one by name (the device must be online)')
300
- .option('-j, --json', 'Output as JSON')
301
- .option('-f, --field <field>', 'Extract a sub-field from the result (dot path)')
302
- .option(
303
- '-i, --input <key=value>',
304
- 'Resource input, value parsed as JSON (repeatable)',
305
- collectKeyValue,
306
- {},
307
- )
308
- .action(async (deviceId, resource, opts, cmd) => {
309
- applyJsonFlag(opts);
310
- ensureConfigured();
311
- const user = getGlobalUser(cmd);
312
- if (!resource) {
313
- await runCommand({
314
- start: `Getting resources for ${deviceId}...`,
315
- fn: () => listDeviceResourcesWithSchemas(deviceId, { user }),
316
- success: null,
317
- failure: 'Resource retrieval failed',
318
- onSuccess: (entries) => {
319
- if (isJsonMode()) printOk(entries);
320
- else console.log(formatDeviceResourcesWithSchemas(deviceId, entries));
321
- },
322
- });
323
- return;
324
- }
325
- await runCommand({
326
- start: `Calling resource ${resource} for ${deviceId}...`,
327
- fn: () => callDeviceResource(deviceId, resource, opts.input, { user }),
328
- success: null,
329
- failure: 'Resource call failed',
330
- onSuccess: (result) => {
331
- const value = extractField(result, opts.field);
332
- if (isJsonMode()) printOk(value);
333
- else if (value === undefined || value === '')
334
- console.log(warning('(no output)'));
335
- else
336
- console.log(
337
- typeof value === 'string' ? value : JSON.stringify(value, null, 2),
338
- );
225
+ if (isJsonMode()) printOk({ device: id, product: productId || null });
339
226
  },
340
227
  });
341
228
  });