@thinger-io/cli-core 0.4.2 → 0.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/README.md +82 -31
  3. package/lib/auth.js +25 -1
  4. package/lib/bucket-export.js +91 -0
  5. package/lib/cli.js +106 -50
  6. package/lib/commands/_shared.js +89 -1
  7. package/lib/commands/api.js +90 -0
  8. package/lib/commands/arguments.js +62 -0
  9. package/lib/commands/bucket.js +52 -44
  10. package/lib/commands/dashboard.js +5 -2
  11. package/lib/commands/data.js +539 -0
  12. package/lib/commands/device.js +128 -241
  13. package/lib/commands/grammar.js +279 -0
  14. package/lib/commands/product.js +104 -214
  15. package/lib/commands/profile.js +1 -0
  16. package/lib/commands/resources.js +975 -0
  17. package/lib/dashboard-init.js +4 -3
  18. package/lib/dashboard.js +10 -2
  19. package/lib/mcp/catalog.js +302 -64
  20. package/lib/mcp/core-types.js +36 -0
  21. package/lib/mcp/helpers.js +23 -2
  22. package/lib/mcp/naming.js +20 -0
  23. package/lib/mcp/registry.js +18 -67
  24. package/lib/mcp/resource-hooks.js +19 -5
  25. package/lib/mcp/server.js +43 -11
  26. package/lib/mcp/tools-resource.js +1234 -330
  27. package/lib/mcp/types/bucket-data.js +151 -0
  28. package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
  29. package/lib/mcp/types/device-resource.js +117 -0
  30. package/lib/mcp/types/doc.js +35 -0
  31. package/lib/mcp/types/plugin-call.js +75 -0
  32. package/lib/mcp/types/product-api.js +44 -0
  33. package/lib/mcp/types/profile.js +37 -0
  34. package/lib/mcp/types/storage-file.js +170 -0
  35. package/lib/openapi.js +18 -0
  36. package/lib/product-apply.js +121 -0
  37. package/lib/raw-api.js +111 -0
  38. package/lib/storage.js +41 -13
  39. package/lib/tokens.js +16 -2
  40. package/package.json +1 -1
  41. package/lib/mcp/tools-buckets.js +0 -130
  42. package/lib/mcp/tools-devices.js +0 -134
  43. package/lib/mcp/tools-misc.js +0 -48
  44. package/lib/mcp/tools-plugins.js +0 -63
  45. package/lib/mcp/tools-products.js +0 -52
  46. package/lib/mcp/tools-storage.js +0 -173
@@ -0,0 +1,539 @@
1
+ // @ts-check
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { Command } from 'commander';
4
+ import { resourceTools } from '../mcp/tools-resource.js';
5
+ import { applyProduct } from '../product-apply.js';
6
+ import { exportBucket } from '../bucket-export.js';
7
+ import { rawApiCall } from '../raw-api.js';
8
+ import { storageFilePath } from '../storage.js';
9
+ import { inputError } from '../errors.js';
10
+ import { classifyError, isJsonMode, printErr, printOk } from '../output.js';
11
+ import { formatDeviceResourcesWithSchemas } from '../resource.js';
12
+ import { GROUP_DESCRIPTIONS, groupAt, named, runTool } from './resources.js';
13
+ import { CLI, DATA, textResult } from '../mcp/helpers.js';
14
+ import {
15
+ applyJsonFlag,
16
+ bodyFromOptions,
17
+ bodyOptions,
18
+ collectKeyString,
19
+ confirmDestructive,
20
+ ensureConfigured,
21
+ getGlobalUser,
22
+ parseNonNegativeInt,
23
+ } from './_shared.js';
24
+
25
+ /**
26
+ * Commands over the operations the generic resource commands do not make:
27
+ * the core's types and actions (write a bucket, upload files, send device
28
+ * data, call a device resource or a plugin, read the guides…), run by the
29
+ * same `resource_*` handlers, plus `product apply` and `bucket export`.
30
+ * They go into the groups written by hand, or create the groups the
31
+ * generic resource commands then complete (`storage file`, `product
32
+ * profile api`). Their --json output keeps the shape it always had.
33
+ */
34
+
35
+ /** The resource tools' handlers, by bare name. */
36
+ let handlers;
37
+ const tool = (name) =>
38
+ (handlers ??= Object.fromEntries(resourceTools().map((t) => [t.name, t.handler])))[name];
39
+
40
+ /** Run a resource tool as a command does: its records without the ids agents pass back. */
41
+ const call = (name, args) => tool(name)({ ...args, [CLI]: true });
42
+
43
+ /** Report a failure the way every command does. */
44
+ function fail(error) {
45
+ const { message, code } = classifyError(error);
46
+ printErr(message, { code });
47
+ }
48
+
49
+ /** Prepare a command's run: --json, a profile, the --user of the root. */
50
+ function start(opts, cmd) {
51
+ applyJsonFlag(opts);
52
+ ensureConfigured();
53
+ const user = getGlobalUser(cmd);
54
+ return user ? { user } : {};
55
+ }
56
+
57
+ /**
58
+ * The group at `path` (see groupAt), or a detached one (never shown or
59
+ * run) when the CLI has a command without subcommands on the way: its own
60
+ * leaf.
61
+ *
62
+ * @param {Command} program
63
+ * @param {string[]} path
64
+ */
65
+ const groupOf = (program, path) => groupAt(program, path) ?? new Command(path.at(-1));
66
+
67
+ /**
68
+ * A new subcommand of `group`, or a detached one (never shown or run) when
69
+ * the group already has one of that name: the CLI's own commands win.
70
+ *
71
+ * @param {Command} group
72
+ * @param {string} usage
73
+ */
74
+ function sub(group, usage) {
75
+ const name = usage.split(' ')[0];
76
+ if (group.commands.some((c) => named(c, name))) return new Command(name);
77
+ return group.command(usage);
78
+ }
79
+
80
+ /** @param {import('commander').Command} program */
81
+ export function registerDataCommands(program) {
82
+ const json = (cmd) => cmd.option('-j, --json', 'Output as JSON');
83
+ const device = groupOf(program, ['device']);
84
+ const product = groupOf(program, ['product']);
85
+ const bucket = groupOf(program, ['bucket']);
86
+
87
+ // ── bucket write / export ───────────────────────────────────────────
88
+ json(
89
+ bodyOptions(
90
+ sub(bucket, 'write <bucket>')
91
+ .helpGroup('Data:')
92
+ .description(
93
+ 'Write samples: one JSON object, an array of them with their ts (unix ms), or NDJSON',
94
+ ),
95
+ ).option('--ts <ms>', 'Timestamp of a single sample (unix ms)', parseNonNegativeInt('ts')),
96
+ ).action(async (id, opts, cmd) => {
97
+ const base = start(opts, cmd);
98
+ try {
99
+ let data = await bodyFromOptions(opts, { lines: true });
100
+ if (data === undefined) throw inputError('give the samples with --from or --set');
101
+ if (opts.ts !== undefined) {
102
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
103
+ throw inputError('--ts is for one sample: give each sample of an array its ts');
104
+ }
105
+ data = { ...data, ts: opts.ts };
106
+ }
107
+ await runTool(
108
+ async (args) => {
109
+ const result = await tool('resource_create')(args);
110
+ const { bucket, samples } = result[DATA].result;
111
+ return textResult(
112
+ `Wrote ${samples} sample${samples === 1 ? '' : 's'} to bucket ${bucket}.`,
113
+ { bucket, samples },
114
+ );
115
+ },
116
+ { ...base, type: 'bucket_data', ids: { bucket: id }, body: data },
117
+ );
118
+ } catch (error) {
119
+ fail(error);
120
+ }
121
+ });
122
+
123
+ json(
124
+ sub(bucket, 'export <bucket>')
125
+ .helpGroup('Data:')
126
+ .description('Export the data of a bucket to a file (or stdout) and download it')
127
+ .option('-o, --output <file>', 'Write it to this file')
128
+ .option('--format <format>', 'csv, json or arff', 'csv')
129
+ .option('--timestamps <format>', 'iso, seconds or milliseconds', 'iso')
130
+ .option(
131
+ '--min-ts <ms>',
132
+ 'From (unix ms; default: the first sample)',
133
+ parseNonNegativeInt('min-ts'),
134
+ )
135
+ .option(
136
+ '--max-ts <ms>',
137
+ 'To (unix ms; default: the last sample)',
138
+ parseNonNegativeInt('max-ts'),
139
+ )
140
+ .option(
141
+ '--keep',
142
+ 'Keep the export file on the server (listed by bucket export-file list)',
143
+ ),
144
+ ).action(async (id, opts, cmd) => {
145
+ const { user } = start(opts, cmd);
146
+ try {
147
+ if (isJsonMode() && !opts.output)
148
+ throw inputError('with --json, give the file with -o');
149
+ const { file, data } = await exportBucket({
150
+ bucket: id,
151
+ user,
152
+ format: opts.format,
153
+ timestamps: opts.timestamps,
154
+ minTs: opts.minTs,
155
+ maxTs: opts.maxTs,
156
+ keep: !!opts.keep,
157
+ });
158
+ if (!opts.output) return void process.stdout.write(data);
159
+ await writeFile(opts.output, data);
160
+ if (isJsonMode())
161
+ printOk({ bucket: id, export: file, file: opts.output, bytes: data.length });
162
+ else process.stderr.write(`Saved ${id} to ${opts.output}\n`);
163
+ } catch (error) {
164
+ fail(error);
165
+ }
166
+ });
167
+
168
+ // ── device send / overview ──────────────────────────────────────────
169
+ json(
170
+ bodyOptions(
171
+ sub(device, 'send <device>')
172
+ .helpGroup('Data:')
173
+ .description(
174
+ "Send data as an HTTP device would: the server runs the device's callback",
175
+ )
176
+ .option('-q, --query <key=value>', 'Query parameter (repeatable)', collectKeyString)
177
+ .option('-f, --field <path>', 'Print only this field of the answer (dot path)')
178
+ .option('-o, --output <file>', 'Write the answer to a file'),
179
+ ),
180
+ ).action(async (id, opts, cmd) => {
181
+ const base = start(opts, cmd);
182
+ try {
183
+ const data = await bodyFromOptions(opts);
184
+ if (data === undefined) throw inputError('give the data with --from or --set');
185
+ await runTool(
186
+ async (args) => {
187
+ const answer = (await tool('resource_call')(args))[DATA];
188
+ const shown =
189
+ answer !== undefined &&
190
+ answer !== null &&
191
+ answer !== '' &&
192
+ (typeof answer !== 'object' || Object.keys(answer).length);
193
+ return textResult(
194
+ `Sent data to ${id}.${shown ? `\nAnswer: ${JSON.stringify(answer)}` : ''}`,
195
+ { device: id, ...(shown ? { answer } : {}) },
196
+ );
197
+ },
198
+ {
199
+ ...base,
200
+ type: 'device',
201
+ action: 'send',
202
+ ids: { device: id },
203
+ body: data,
204
+ ...(opts.query ? { query: opts.query } : {}),
205
+ },
206
+ { output: opts.output, field: opts.field },
207
+ );
208
+ } catch (error) {
209
+ fail(error);
210
+ }
211
+ });
212
+
213
+ json(
214
+ sub(device, 'overview')
215
+ .helpGroup('Data:')
216
+ .description('Usage of all the devices over the last 30 days, per day')
217
+ .option('--locations', 'Also the last known position of each connected device'),
218
+ ).action(async (opts, cmd) => {
219
+ const base = start(opts, cmd);
220
+ await runTool(tool('resource_call'), {
221
+ ...base,
222
+ type: 'device',
223
+ action: 'overview',
224
+ ...(opts.locations ? { body: { locations: true } } : {}),
225
+ });
226
+ });
227
+
228
+ // ── device resource list / call ─────────────────────────────────────
229
+ const resource = groupOf(program, ['device', 'resource']);
230
+
231
+ json(
232
+ sub(resource, 'list <device>')
233
+ .helpGroup('Records:')
234
+ .description(
235
+ 'List the resources a connected device exposes, with their inputs and outputs',
236
+ ),
237
+ ).action(async (id, opts, cmd) => {
238
+ const base = start(opts, cmd);
239
+ try {
240
+ const result = await call('resource_list', {
241
+ ...base,
242
+ type: 'device_resource',
243
+ ids: { device: id },
244
+ all: true,
245
+ });
246
+ // The kind is for agents: the table shows it from fn.
247
+ const entries = result[DATA].map(({ kind: _kind, ...e }) => e);
248
+ if (isJsonMode()) printOk(entries);
249
+ else console.log(formatDeviceResourcesWithSchemas(id, entries));
250
+ } catch (error) {
251
+ fail(error);
252
+ }
253
+ });
254
+
255
+ json(
256
+ bodyOptions(
257
+ sub(resource, 'call <device> <resource>')
258
+ .helpGroup('Data:')
259
+ .description(
260
+ 'Call a resource of a connected device: with --from or --set it runs it with that input; without, it reads its output',
261
+ )
262
+ .option('-f, --field <path>', 'Print only this field of the answer (dot path)')
263
+ .option('-o, --output <file>', 'Write the answer to a file'),
264
+ ),
265
+ ).action(async (id, name, opts, cmd) => {
266
+ const base = start(opts, cmd);
267
+ try {
268
+ const inputs = await bodyFromOptions(opts);
269
+ await runTool(
270
+ tool('resource_call'),
271
+ {
272
+ ...base,
273
+ type: 'device_resource',
274
+ action: 'call',
275
+ ids: { device: id, resource: name },
276
+ ...(inputs !== undefined ? { body: inputs } : {}),
277
+ },
278
+ { output: opts.output, field: opts.field },
279
+ );
280
+ } catch (error) {
281
+ fail(error);
282
+ }
283
+ });
284
+
285
+ // ── product apply / profile api call ────────────────────────────────
286
+ json(
287
+ bodyOptions(
288
+ sub(product, 'apply')
289
+ .helpGroup('Actions:')
290
+ .description(
291
+ 'Create or update a product from the JSON `product export` writes (profile and properties included)',
292
+ ),
293
+ ),
294
+ ).action(async (opts, cmd) => {
295
+ const { user } = start(opts, cmd);
296
+ try {
297
+ const doc = await bodyFromOptions(opts);
298
+ if (doc === undefined) throw inputError('give the product with --from <file|->');
299
+ const result = await applyProduct(doc, user);
300
+ if (isJsonMode()) return printOk(result);
301
+ const props = [
302
+ result.properties.created.length && `${result.properties.created.length} created`,
303
+ result.properties.updated.length && `${result.properties.updated.length} updated`,
304
+ ].filter(Boolean);
305
+ console.log(
306
+ `${result.created ? 'Created' : 'Updated'} product ${result.product}${props.length ? `; properties: ${props.join(', ')}` : ''}.`,
307
+ );
308
+ if (result.ignored) console.log(`Left as they are: ${result.ignored.join(', ')}.`);
309
+ } catch (error) {
310
+ fail(error);
311
+ }
312
+ });
313
+
314
+ json(
315
+ bodyOptions(
316
+ sub(groupOf(program, ['product', 'profile', 'api']), 'call <product> <resource>')
317
+ .helpGroup('Data:')
318
+ .description(
319
+ 'Call a product API resource for the whole product (needs its device_id_resolver)',
320
+ )
321
+ .option('-f, --field <path>', 'Print only this field of the answer (dot path)')
322
+ .option('-o, --output <file>', 'Write the answer to a file'),
323
+ ),
324
+ ).action(async (id, resource, opts, cmd) => {
325
+ const base = start(opts, cmd);
326
+ try {
327
+ const data = await bodyFromOptions(opts);
328
+ await runTool(
329
+ tool('resource_call'),
330
+ {
331
+ ...base,
332
+ type: 'product_profile_api',
333
+ action: 'call',
334
+ ids: { product: id, resource },
335
+ ...(data !== undefined ? { body: data } : {}),
336
+ },
337
+ { output: opts.output, field: opts.field },
338
+ );
339
+ } catch (error) {
340
+ fail(error);
341
+ }
342
+ });
343
+
344
+ // ── storage file list / upload / download / delete ──────────────────
345
+ const file = groupOf(program, ['storage', 'file']);
346
+
347
+ json(
348
+ sub(file, 'list <storage> [folder]')
349
+ .helpGroup('Records:')
350
+ .description('List the files of a storage, or of a folder in it'),
351
+ ).action(async (id, folder, opts, cmd) => {
352
+ const base = start(opts, cmd);
353
+ await runTool(
354
+ async (args) => {
355
+ const files = (await tool('resource_list')(args))[DATA];
356
+ return textResult(
357
+ files.length
358
+ ? files.map((f) => JSON.stringify(f)).join('\n')
359
+ : `No files${folder ? ` under ${folder}` : ''}.`,
360
+ files,
361
+ );
362
+ },
363
+ {
364
+ ...base,
365
+ type: 'storage_file',
366
+ ids: { storage: id },
367
+ ...(folder ? { filter: { path: folder } } : {}),
368
+ all: true,
369
+ },
370
+ );
371
+ });
372
+
373
+ json(
374
+ sub(file, 'upload <storage> <local>')
375
+ .helpGroup('Data:')
376
+ .description(
377
+ 'Upload a local file or folder (binary too), overwriting what is there; folders on the way are created',
378
+ )
379
+ .option('--to <path>', 'Where in the storage: a file path, or the folder for a folder'),
380
+ ).action(async (id, local, opts, cmd) => {
381
+ const base = start(opts, cmd);
382
+ await runTool(
383
+ async (args) => {
384
+ const { storage, files, bytes } = (await tool('resource_create')(args))[DATA]
385
+ .result;
386
+ const shown =
387
+ files.slice(0, 20).join(', ') +
388
+ (files.length > 20 ? `… (+${files.length - 20})` : '');
389
+ return textResult(
390
+ `Uploaded ${files.length} file(s), ${bytes} bytes, to storage ${storage}: ${shown}.`,
391
+ { storage, files, bytes },
392
+ );
393
+ },
394
+ {
395
+ ...base,
396
+ type: 'storage_file',
397
+ ids: { storage: id, ...(opts.to ? { file: opts.to } : {}) },
398
+ body: { local },
399
+ },
400
+ );
401
+ });
402
+
403
+ json(
404
+ sub(file, 'download <storage> <path>')
405
+ .helpGroup('Data:')
406
+ .description('Download a file: to -o, or printed (text) / piped (binary)')
407
+ .option('-o, --output <file>', 'Write it to this file'),
408
+ ).action(async (id, path, opts, cmd) => {
409
+ const { user } = start(opts, cmd);
410
+ try {
411
+ const clean = String(path).replace(/^\/+/, '');
412
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) throw inputError(`invalid storage id: "${id}"`);
413
+ const res = await rawApiCall({
414
+ path: `/v2/users/{user}/storages/${id}/files/${storageFilePath(clean)}`,
415
+ user,
416
+ });
417
+ const bytes = res.raw;
418
+ if (opts.output) {
419
+ await writeFile(opts.output, bytes);
420
+ if (isJsonMode()) printOk({ storage: id, path: clean, file: opts.output });
421
+ else process.stderr.write(`Saved ${clean} to ${opts.output}\n`);
422
+ return;
423
+ }
424
+ if (isJsonMode() || (res.kind === 'binary' && process.stdout.isTTY)) {
425
+ throw inputError(
426
+ `save it with -o <file>${res.kind === 'binary' ? ': it is binary' : ''}`,
427
+ );
428
+ }
429
+ process.stdout.write(bytes);
430
+ } catch (error) {
431
+ fail(error);
432
+ }
433
+ });
434
+
435
+ json(
436
+ sub(file, 'delete <storage> <path>')
437
+ .helpGroup('Records:')
438
+ .description('Delete a file of a storage')
439
+ .option('-y, --yes', 'Do not ask for confirmation'),
440
+ ).action(async (id, path, opts, cmd) => {
441
+ const base = start(opts, cmd);
442
+ if (!(await confirmDestructive(opts, `Delete ${path} from storage ${id}?`))) return;
443
+ try {
444
+ // A file that was not there is a failure (not_found) for a script.
445
+ await call('resource_delete', {
446
+ ...base,
447
+ type: 'storage_file',
448
+ ids: { storage: id, file: path },
449
+ });
450
+ const clean = String(path).replace(/^\/+/, '');
451
+ if (isJsonMode()) printOk({ path: clean, deleted: true });
452
+ else console.log(`Deleted ${clean}.`);
453
+ } catch (error) {
454
+ fail(error);
455
+ }
456
+ });
457
+
458
+ // ── plugin call ─────────────────────────────────────────────────────
459
+ json(
460
+ bodyOptions(
461
+ sub(groupOf(program, ['plugin']), 'call <plugin> [path]')
462
+ .helpGroup('Data:')
463
+ .description('Call the API a plugin serves, at its own path')
464
+ .option(
465
+ '-X, --method <method>',
466
+ 'get, post, put, patch or delete (default: get, or post with a body)',
467
+ )
468
+ .option('-q, --query <key=value>', 'Query parameter (repeatable)', collectKeyString)
469
+ .option('-f, --field <path>', 'Print only this field of the answer (dot path)')
470
+ .option('-o, --output <file>', 'Write the answer to a file'),
471
+ ),
472
+ ).action(async (id, path, opts, cmd) => {
473
+ const base = start(opts, cmd);
474
+ try {
475
+ const body = await bodyFromOptions(opts);
476
+ await runTool(
477
+ tool('resource_call'),
478
+ {
479
+ ...base,
480
+ type: 'plugin',
481
+ action: 'call',
482
+ ids: { plugin: id },
483
+ body: {
484
+ ...(path ? { path } : {}),
485
+ method: (
486
+ opts.method || (body !== undefined ? 'post' : 'get')
487
+ ).toLowerCase(),
488
+ ...(body !== undefined ? { body } : {}),
489
+ },
490
+ ...(opts.query ? { query: opts.query } : {}),
491
+ },
492
+ { output: opts.output, field: opts.field },
493
+ );
494
+ } catch (error) {
495
+ fail(error);
496
+ }
497
+ });
498
+
499
+ // ── doc list / get ──────────────────────────────────────────────────
500
+ const doc =
501
+ program.commands.find((c) => named(c, 'doc')) ??
502
+ program.command('doc').helpGroup('Integration:').description(GROUP_DESCRIPTIONS.doc);
503
+
504
+ json(
505
+ sub(doc, 'list')
506
+ .helpGroup('Records:')
507
+ .description(
508
+ 'List the guides: the platform, product code, the dashboard SDK, this CLI',
509
+ ),
510
+ ).action(async (opts, cmd) => {
511
+ start(opts, cmd);
512
+ await runTool(
513
+ async (args) => {
514
+ const docs = (await tool('resource_list')(args))[DATA];
515
+ return textResult(
516
+ docs.length
517
+ ? docs.map((d) => `${d.doc} ${d.title}`).join('\n')
518
+ : 'No guides available.',
519
+ docs,
520
+ );
521
+ },
522
+ { type: 'doc', all: true },
523
+ );
524
+ });
525
+
526
+ json(
527
+ sub(doc, 'get <doc>')
528
+ .helpGroup('Records:')
529
+ .description('Print a guide (Markdown), whole')
530
+ .option('-o, --output <file>', 'Write it to a file'),
531
+ ).action(async (id, opts, cmd) => {
532
+ start(opts, cmd);
533
+ await runTool(
534
+ tool('resource_get'),
535
+ { type: 'doc', ids: { doc: id } },
536
+ { output: opts.output },
537
+ );
538
+ });
539
+ }