@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
@@ -0,0 +1,975 @@
1
+ // @ts-check
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { CATALOG, ancestry, idKey, idKeys, idNames } from '../mcp/catalog.js';
4
+ import { textResult } from '../mcp/helpers.js';
5
+ import { CLI, DATA, resourceTools } from '../mcp/tools-resource.js';
6
+ import { inputError } from '../errors.js';
7
+ import { identity } from '../identity.js';
8
+ import { classifyError, isJsonMode, printErr, printOk } from '../output.js';
9
+ import {
10
+ applyJsonFlag,
11
+ collectKeyString,
12
+ collectKeyValue,
13
+ confirmDestructive,
14
+ ensureConfigured,
15
+ extractField,
16
+ getGlobalUser,
17
+ parseJsonValue,
18
+ parseNonNegativeInt,
19
+ parsePositiveInt,
20
+ bodyFromOptions,
21
+ bodyOptions,
22
+ } from './_shared.js';
23
+
24
+ /**
25
+ * The generic resource commands: `<cli> <noun> [<noun>…] list|get|create|
26
+ * update|delete|clone|describe|set|<action>` for every type of the catalog
27
+ * the MCP serves, run by the same handlers as the `resource_*` tools (hooks
28
+ * included), so a type added to the catalog reaches both at once.
29
+ *
30
+ * A type's noun is its name without its parent's, nested under the
31
+ * parent's command (`device property`, `device bucket tag`); server types
32
+ * go under `admin`. Ids are positional, outermost parent first, each named
33
+ * after its noun (`<device> <property>`). A command written by hand wins:
34
+ * a generated verb whose name a group already has is left out.
35
+ */
36
+
37
+ /** Verbs a type may have, in the order the help shows them. */
38
+ const VERBS = /** @type {const} */ (['list', 'get', 'create', 'update', 'delete', 'clone']);
39
+
40
+ /** Verbs that move data rather than records: their actions go under "Data:". */
41
+ export const DATA_VERBS = [
42
+ 'read',
43
+ 'write',
44
+ 'send',
45
+ 'export',
46
+ 'import',
47
+ 'overview',
48
+ 'call',
49
+ 'upload',
50
+ 'download',
51
+ ];
52
+
53
+ const kebab = (name) => name.replaceAll('_', '-');
54
+
55
+ /**
56
+ * Nouns of the types whose command is not their name without the parent's:
57
+ * the product profile categories go under `product profile`, alarms and
58
+ * assets under `alarm` and `asset`, and the export files of a bucket are
59
+ * `export-file` (`bucket export` downloads its data).
60
+ */
61
+ const NOUN = {
62
+ alarm_rule: ['alarm', 'rule'],
63
+ alarm_instance: ['alarm', 'instance'],
64
+ asset_type: ['asset', 'type'],
65
+ asset_group: ['asset', 'group'],
66
+ product_profile_api: ['profile', 'api'],
67
+ product_profile_bucket: ['profile', 'bucket'],
68
+ product_profile_property: ['profile', 'property'],
69
+ product_profile_autoprovision: ['profile', 'autoprovision'],
70
+ product_profile_flow: ['profile', 'flow'],
71
+ product_profile_code: ['profile', 'code'],
72
+ bucket_export: ['export-file'],
73
+ };
74
+
75
+ /** Actions whose command is not the catalog's name in kebab case. */
76
+ const ACTION_NAME = {
77
+ 'alarm_rule.test_rule': 'test',
78
+ 'host.read_settings': 'get-settings',
79
+ 'plugin.remove': 'uninstall',
80
+ };
81
+
82
+ /** Groups that are not types, with their description. */
83
+ export const GROUP_DESCRIPTIONS = {
84
+ alarm: 'Alarms: the rules that watch devices and buckets, and the instances they raise',
85
+ asset: 'Assets: types and groups that organize devices',
86
+ admin: 'Server administration (admin accounts): users, hosts, domains, brands…',
87
+ 'product profile':
88
+ 'Profile of a product: the API resources, buckets, properties, autoprovisioning, flows and code its devices get',
89
+ 'device resource': "Resources of a connected device: its firmware's API and its product's",
90
+ 'storage file': 'Files of a file storage',
91
+ doc: 'Guides for agents and app developers: the platform, product code, the dashboard SDK, this CLI',
92
+ };
93
+
94
+ /**
95
+ * Types whose records have a name and a description: their create and
96
+ * update take `-n` and `-d` (a test checks the set against the server's
97
+ * API).
98
+ */
99
+ export const NAMED_FIELDS = new Set([
100
+ 'device',
101
+ 'product',
102
+ 'bucket',
103
+ 'dashboard',
104
+ 'endpoint',
105
+ 'storage',
106
+ 'asset_type',
107
+ 'asset_group',
108
+ 'project',
109
+ 'token',
110
+ 'alarm_rule',
111
+ 'alarm_instance',
112
+ 'claim',
113
+ 'property',
114
+ 'sync',
115
+ 'domain',
116
+ 'brand',
117
+ 'oauth_client',
118
+ 'identity_provider',
119
+ 'proxy',
120
+ 'sync_lock',
121
+ 'device_property',
122
+ 'product_property',
123
+ 'product_profile_api',
124
+ 'product_profile_bucket',
125
+ 'product_profile_property',
126
+ 'product_profile_autoprovision',
127
+ 'product_profile_flow',
128
+ 'product_service',
129
+ 'plugin_property',
130
+ 'asset_type_property',
131
+ 'asset_group_property',
132
+ 'project_property',
133
+ ]);
134
+
135
+ /** Command a type lives under: `alarm rule`, `device bucket tag`, `admin user`. */
136
+ export function commandPath(type) {
137
+ const spec = CATALOG[type];
138
+ const own = NOUN[type] ?? [kebab(spec.parent ? type.slice(spec.parent.length + 1) : type)];
139
+ if (spec.parent) return [...commandPath(spec.parent), ...own];
140
+ return spec.scope === 'server' ? ['admin', ...own] : own;
141
+ }
142
+
143
+ /** Command of an action: `test` for alarm_rule's test_rule, `test-source`… */
144
+ export const actionName = (type, action) => ACTION_NAME[`${type}.${action}`] ?? kebab(action);
145
+
146
+ /**
147
+ * Argument names of a type's own ids: `<asset-type>`, `<property>`,
148
+ * `<file>`. They are the names the tools take in `ids`, in kebab case.
149
+ */
150
+ export function idArgs(type) {
151
+ const key = idKey(type);
152
+ return key ? [kebab(key)] : [];
153
+ }
154
+
155
+ /** Argument names of a type's parents, outermost first. */
156
+ export const parentArgs = (type) => ancestry(type).flatMap(idArgs);
157
+
158
+ /** Type at a command path ('device property' → device_property). */
159
+ export const TYPE_AT = new Map(Object.keys(CATALOG).map((t) => [commandPath(t).join(' '), t]));
160
+
161
+ /** How a type reads in the help: "device property", "oauth-client". */
162
+ const noun = (type) =>
163
+ commandPath(type)
164
+ .filter((n, i) => i || n !== 'admin')
165
+ .join(' ');
166
+
167
+ /**
168
+ * The catalog's description for a command line: what names MCP tools
169
+ * (sentences, or remarks in parentheses) and the form of a `parent` id is
170
+ * for agents; the command line takes the parent ids one by one.
171
+ */
172
+ export function cliDescription(text) {
173
+ return cliWording(text)
174
+ .replace(/\s*\([^)]*\btools?\b[^)]*\)/g, '')
175
+ .replace(/\s*\(parent: [^)]*\)/g, '')
176
+ .replace(/`body`/g, 'the body')
177
+ .split(/(?<=\.)\s+/)
178
+ .filter((s) => !s.includes('{tool:') && !/^Confirm with the user\b/.test(s))
179
+ .join(' ')
180
+ .trim()
181
+ .replace(/\.$/, '');
182
+ }
183
+
184
+ /**
185
+ * Commands of the operations of the core's types and actions (data,
186
+ * files, device resources, guides, apps): written by hand, not generated.
187
+ */
188
+ export const CORE_COMMANDS = {
189
+ 'bucket_data.list': 'bucket read',
190
+ 'bucket_data.create': 'bucket write',
191
+ 'storage_file.list': 'storage file list',
192
+ 'storage_file.get': 'storage file download',
193
+ 'storage_file.create': 'storage file upload',
194
+ 'storage_file.update': 'storage file upload',
195
+ 'storage_file.delete': 'storage file delete',
196
+ 'device_resource.list': 'device resource list',
197
+ 'device_resource.call': 'device resource call',
198
+ 'device.overview': 'device overview',
199
+ 'product_profile_api.call': 'product profile api call',
200
+ 'plugin.call': 'plugin call',
201
+ 'dashboard.init': 'dashboard init',
202
+ 'dashboard.dev': 'dashboard dev',
203
+ 'dashboard.publish': 'dashboard publish',
204
+ 'doc.list': 'doc list',
205
+ 'doc.get': 'doc get',
206
+ 'profile.list': 'profile list',
207
+ };
208
+
209
+ /** The command of an operation (a type's verb or action): `device property list`. */
210
+ function opCommand(type, op) {
211
+ const core = CORE_COMMANDS[`${type}.${op}`];
212
+ if (core) return core;
213
+ const spec = CATALOG[type];
214
+ if (!spec) return undefined;
215
+ if (spec.actions?.[op]) return `${commandPath(type).join(' ')} ${actionName(type, op)}`;
216
+ if (op in spec.ops || op === 'describe') return `${commandPath(type).join(' ')} ${op}`;
217
+ return undefined;
218
+ }
219
+
220
+ /** The command doing what tool `base` does, `type` the resource the text is about. */
221
+ function commandFor(base, type) {
222
+ const verb = base.match(/^resource_(\w+)$/)?.[1];
223
+ if (!verb) return undefined;
224
+ // The texts point to describe for other types ("describe of any type").
225
+ const own = type && type in CATALOG && verb !== 'describe';
226
+ const at = own ? commandPath(type).join(' ') : '<noun>';
227
+ return `\`${identity().name} ${at} ${verb === 'call' ? '<action>' : verb}\``;
228
+ }
229
+
230
+ /**
231
+ * A text the commands share with the MCP tools, for a command line: the
232
+ * operations it names (`{op:type.verb}`, or the call written out:
233
+ * `<prefix>_resource_list type "x"`, ids after it as the positional
234
+ * arguments) and the tools (`{tool:x}`) as the commands that do the same,
235
+ * and their arguments as options. Operations without a command stay as
236
+ * they are.
237
+ *
238
+ * @param {string} text
239
+ * @param {string} [type] The resource the text is about.
240
+ */
241
+ export function cliWording(text, type) {
242
+ const prefix = identity().mcp.toolPrefix;
243
+ const tool = `(?:\\{tool:([a-z0-9_]+)\\}|\\b${prefix}_([a-z0-9_]+))`;
244
+ const op = new RegExp(
245
+ `(?:\\{op:([a-z0-9_]+)\\.([a-z0-9_]+)\\}|\\b${prefix}_resource_(\\w+) type "([a-z0-9_]+)"(?: action "([a-z0-9_]+)")?)(?: ids (\\{[^{}]*\\}))?`,
246
+ 'g',
247
+ );
248
+ return text
249
+ .replace(op, (all, t1, op1, verb, t2, action, ids) => {
250
+ const words = opCommand(t1 ?? t2, op1 ?? action ?? verb);
251
+ if (!words) return all;
252
+ let values;
253
+ try {
254
+ values = ids ? Object.values(JSON.parse(ids)) : [];
255
+ } catch {
256
+ return all;
257
+ }
258
+ return `\`${identity().name} ${[words, ...values].join(' ')}\``;
259
+ })
260
+ .replace(new RegExp(tool, 'g'), (all, a, b) => commandFor(a ?? b, type) ?? all)
261
+ .replace(/\bpass index: (\d+)/g, 'pass --index $1')
262
+ .replace(/narrow it with filter\b/g, 'narrow it with --search or --filter');
263
+ }
264
+
265
+ /** The first sentence, for a line of help: the whole text goes after the options. */
266
+ const firstSentence = (text) => text.split(/(?<=[.;:])\s+/)[0].replace(/[.;:]$/, '');
267
+
268
+ /** Whether a command answers to `name`, by its name or an alias. */
269
+ export const named = (cmd, name) => cmd.name() === name || cmd.aliases().includes(name);
270
+
271
+ /**
272
+ * The group at `path` below `program`, made on the way when missing (with
273
+ * the type's description, or GROUP_DESCRIPTIONS'): groups written by hand
274
+ * keep theirs. Null when a segment is a command without subcommands, a
275
+ * CLI's own leaf: no group can go there.
276
+ *
277
+ * @param {import('commander').Command} program
278
+ * @param {string[]} path
279
+ */
280
+ export function groupAt(program, path) {
281
+ let parent = program;
282
+ for (let i = 0; i < path.length; i++) {
283
+ const found = parent.commands.find((c) => named(c, path[i]));
284
+ if (found) {
285
+ if (!found.commands.length) return null;
286
+ parent = found;
287
+ continue;
288
+ }
289
+ const at = path.slice(0, i + 1).join(' ');
290
+ const type = TYPE_AT.get(at);
291
+ const top = path[0] === 'admin' ? 'Administration:' : 'Resources:';
292
+ parent = parent
293
+ .command(path[i])
294
+ .helpGroup(i ? 'Subresources:' : top)
295
+ .description(
296
+ type ? cliDescription(CATALOG[type].description) : (GROUP_DESCRIPTIONS[at] ?? ''),
297
+ );
298
+ }
299
+ return parent;
300
+ }
301
+
302
+ /** Describe the id arguments of a command on `type`: "ID of the alarm rule". */
303
+ function describeIds(cmd, type) {
304
+ const help = new Map(
305
+ [...ancestry(type), type].flatMap((t) =>
306
+ idArgs(t).map((n) => [n, `ID of the ${t.replaceAll('_', ' ')}`]),
307
+ ),
308
+ );
309
+ help.set('new-id', 'ID of the copy');
310
+ help.set('value', 'Value, parsed as JSON (text when it is not JSON)');
311
+ for (const arg of cmd.registeredArguments) {
312
+ if (!arg.description) arg.description = help.get(arg.name()) ?? '';
313
+ }
314
+ return cmd;
315
+ }
316
+
317
+ /**
318
+ * `--search`, `--filter`, `--count` and `--index`: the options of every
319
+ * list, generated or written by hand.
320
+ *
321
+ * @param {import('commander').Command} cmd
322
+ */
323
+ export function listOptions(cmd) {
324
+ return cmd
325
+ .option('--search <text>', 'Text to look for in ids and names')
326
+ .option(
327
+ '--filter <key=value>',
328
+ 'Filter (repeatable): product, type, asset_type, asset_group, project, labels, or a field of the records',
329
+ collectKeyValue,
330
+ )
331
+ .option('--count <n>', 'Page size (default: all)', parsePositiveInt('count'))
332
+ .option('--index <n>', 'Offset of the first record', parseNonNegativeInt('index'));
333
+ }
334
+
335
+ /** Whether a list was given any of the listOptions. */
336
+ export const hasListOptions = (opts) =>
337
+ ['search', 'filter', 'count', 'index'].some((o) => opts[o] !== undefined);
338
+
339
+ /** The listOptions as resource_list takes them: every record unless --count. */
340
+ function listArgs(opts) {
341
+ return {
342
+ ...(opts.search ? { search: opts.search } : {}),
343
+ ...(opts.filter ? { filter: opts.filter } : {}),
344
+ ...(opts.count ? { count: opts.count } : { all: true }),
345
+ ...(opts.index !== undefined ? { index: opts.index } : {}),
346
+ };
347
+ }
348
+
349
+ /** Handlers of the resource tools without a CLI's hooks, for the commands written by hand. */
350
+ let plainTools;
351
+ const plain = () =>
352
+ (plainTools ??= Object.fromEntries(resourceTools().map((t) => [t.name, t.handler])));
353
+
354
+ /**
355
+ * The records of a list with the listOptions, through resource_list: what
356
+ * the lists written by hand show in their own table.
357
+ *
358
+ * @param {string} type
359
+ * @param {Record<string, any>} opts
360
+ * @param {string | null} [user]
361
+ */
362
+ export async function listRecords(type, opts, user) {
363
+ const result = await plain().resource_list({
364
+ type,
365
+ ...listArgs(opts),
366
+ ...(user ? { user } : {}),
367
+ [CLI]: true,
368
+ });
369
+ return result[DATA] ?? [];
370
+ }
371
+
372
+ /** `--projects a@p1,b@p2` → ['a@p1', 'b@p2']. */
373
+ const parseProjects = (list) =>
374
+ String(list)
375
+ .split(',')
376
+ .map((p) => p.trim())
377
+ .filter(Boolean);
378
+
379
+ /** The `--projects` option of a create or update. */
380
+ export const projectsOption = (cmd) =>
381
+ cmd.option('--projects <list>', 'Projects it belongs to, comma-separated "<owner>@<project>"');
382
+
383
+ /**
384
+ * Set the projects of a record a command written by hand created
385
+ * (`--projects`), as the generated create does. A failure says the record
386
+ * is there all the same.
387
+ *
388
+ * @param {string} type
389
+ * @param {string} id
390
+ * @param {string} list "<owner>@<project>", comma-separated.
391
+ * @param {string | null} [user]
392
+ */
393
+ export async function setProjects(type, id, list, user) {
394
+ try {
395
+ await plain().resource_update({
396
+ type,
397
+ ids: { [/** @type {string} */ (idKey(type))]: id },
398
+ body: { projects: parseProjects(list) },
399
+ ...(user ? { user } : {}),
400
+ });
401
+ } catch (error) {
402
+ const { message, code } = classifyError(error);
403
+ throw Object.assign(
404
+ new Error(`${type} ${id} was created, but not its projects: ${message}`),
405
+ {
406
+ code,
407
+ },
408
+ );
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Register the commands on `program`.
414
+ *
415
+ * @param {import('commander').Command} program
416
+ * @param {{
417
+ * hooks?: Record<string, import('../mcp/resource-hooks.js').ResourceHooks>,
418
+ * strict?: boolean,
419
+ * }} [opts]
420
+ * `strict` (tests): a type whose path a CLI's own leaf takes throws,
421
+ * instead of being left out (said on stderr with DEBUG set).
422
+ */
423
+ export function registerResourceCommands(program, { hooks, strict = false } = {}) {
424
+ const tools = Object.fromEntries(resourceTools({ hooks }).map((t) => [t.name, t.handler]));
425
+
426
+ // Parents first: a child's group goes into its parent's.
427
+ const types = Object.keys(CATALOG).sort((a, b) => ancestry(a).length - ancestry(b).length);
428
+ for (const type of types) {
429
+ const spec = CATALOG[type];
430
+ const cmd = groupAt(program, commandPath(type));
431
+ if (!cmd) {
432
+ const message = `${commandPath(type).join(' ')}: a command of the CLI takes the place of the ${type} commands`;
433
+ if (strict) throw new Error(message);
434
+ if (process.env.DEBUG) process.stderr.write(`${message}\n`);
435
+ continue;
436
+ }
437
+ const has = (name) => cmd.commands.some((c) => named(c, name));
438
+
439
+ for (const verb of VERBS) {
440
+ if (verb in spec.ops && !has(verb)) addVerb(cmd, type, verb, tools);
441
+ }
442
+ if (spec.id === 'property' && 'update' in spec.ops && !has('set')) {
443
+ addVerb(cmd, type, 'set', tools);
444
+ }
445
+ if (!has('describe')) addDescribe(cmd, type, tools);
446
+ for (const [action, a] of Object.entries(spec.actions || {})) {
447
+ if (!has(actionName(type, action))) addAction(cmd, type, action, a, tools);
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Positional arguments → the `ids` the tools take, by name: the parents',
454
+ * then the own one (`withId`; a create may leave it out).
455
+ */
456
+ function locate(type, values, { withId }) {
457
+ const keys = idKeys(type);
458
+ const names = withId || !idKey(type) ? keys : keys.slice(0, -1);
459
+ /** @type {Record<string, string>} */
460
+ const ids = {};
461
+ names.forEach((key, i) => {
462
+ if (values[i] !== undefined && values[i] !== null) ids[key] = values[i];
463
+ });
464
+ return { ids };
465
+ }
466
+
467
+ /** How the ids of a call read in a question: `boiler/setpoint`. */
468
+ const shown = (ids) => Object.values(ids).join('/');
469
+
470
+ /** `<device> <bucket>`: commander's spelling of the arguments. */
471
+ const argSpec = (names, optional = false) =>
472
+ names.map((n) => (optional ? `[${n}]` : `<${n}>`)).join(' ');
473
+
474
+ /** Print the value `-f` picked: text as is, anything else as JSON; nothing when missing. */
475
+ function printValue(value) {
476
+ if (value === undefined) return;
477
+ console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
478
+ }
479
+
480
+ /**
481
+ * Run an MCP tool handler as a command and print its answer: its text, or
482
+ * its data with --json.
483
+ *
484
+ * @param {(args: any) => any} handler
485
+ * @param {Record<string, any>} args
486
+ * @param {{ output?: string, field?: string }} [opts] `output`: write the
487
+ * answer (JSON) to this file; `field`: only this field (dot path) of it.
488
+ */
489
+ export async function runTool(handler, args, { output, field } = {}) {
490
+ try {
491
+ const result = await handler({ ...args, [CLI]: true });
492
+ if (result?.isError) {
493
+ const message = result.content?.map((c) => c.text).join('\n') || 'failed';
494
+ printErr(message, { code: 'error' });
495
+ return;
496
+ }
497
+ let data = DATA in result ? result[DATA] : result.content?.map((c) => c.text).join('\n');
498
+ if (field !== undefined) data = extractField(data, field);
499
+ if (output) {
500
+ await writeFile(
501
+ output,
502
+ typeof data === 'string'
503
+ ? data
504
+ : data === null || data === undefined
505
+ ? ''
506
+ : `${JSON.stringify(data, null, 2)}\n`,
507
+ );
508
+ if (isJsonMode()) printOk({ file: output });
509
+ else process.stderr.write(`Saved to ${output}\n`);
510
+ return;
511
+ }
512
+ if (isJsonMode()) return printOk(data);
513
+ if (field !== undefined) return printValue(data);
514
+ for (const c of result.content || []) {
515
+ if (c.type === 'text') console.log(cliText(c.text, args.type));
516
+ }
517
+ } catch (error) {
518
+ const { message, code } = classifyError(error);
519
+ printErr(message, { code });
520
+ }
521
+ }
522
+
523
+ /**
524
+ * `set`: the value of a property, created when missing (an update first,
525
+ * then a create when the server has none).
526
+ */
527
+ async function setValue(tools, args) {
528
+ try {
529
+ await tools.resource_update(args);
530
+ } catch (error) {
531
+ if (classifyError(error).code !== 'not_found') throw error;
532
+ await tools.resource_create(args);
533
+ }
534
+ const id = args.ids[/** @type {string} */ (idKey(args.type))];
535
+ const parent = idKeys(args.type)
536
+ .slice(0, -1)
537
+ .map((k) => args.ids[k])
538
+ .join('/');
539
+ const where = [parent, id].filter(Boolean).join('/');
540
+ return textResult(`Set ${noun(args.type)} ${where} to ${JSON.stringify(args.body.value)}.`, {
541
+ ...(parent ? { parent } : {}),
542
+ id,
543
+ value: args.body.value,
544
+ });
545
+ }
546
+
547
+ /** "an endpoint", "a device". */
548
+ const article = (t) => `${/^[aeiou]/.test(t) ? 'an' : 'a'} ${t}`;
549
+
550
+ const SUMMARY = {
551
+ list: (t) => `List ${t} records`,
552
+ get: (t) => `Show ${article(t)} as JSON`,
553
+ create: (t) => `Create ${article(t)} from --from and --set`,
554
+ update: (t) => `Update fields of ${article(t)} (fields left out are kept)`,
555
+ delete: (t) => `Delete ${article(t)}`,
556
+ clone: (t) => `Copy ${article(t)} under a new id`,
557
+ set: (t) => `Set the value of ${article(t)} (created when missing)`,
558
+ };
559
+
560
+ function addVerb(group, type, verb, tools) {
561
+ const spec = CATALOG[type];
562
+ const parents = parentArgs(type);
563
+ const ids = idArgs(type);
564
+ const what = noun(type);
565
+ // A create may take its id from the body, unless it is stored under it.
566
+ const optionalIds = verb === 'create' && !spec.upsert;
567
+ const usage = [
568
+ verb,
569
+ argSpec(parents),
570
+ verb === 'list' ? '' : argSpec(ids, optionalIds),
571
+ verb === 'clone' ? '<new-id>' : '',
572
+ verb === 'set' ? '<value>' : '',
573
+ ]
574
+ .filter(Boolean)
575
+ .join(' ');
576
+ const cmd = group
577
+ .command(usage)
578
+ .helpGroup('Records:')
579
+ .description(SUMMARY[verb](what))
580
+ .option('-j, --json', 'Output as JSON');
581
+ describeIds(cmd, type);
582
+
583
+ if (verb === 'list') listOptions(cmd);
584
+ if (['create', 'update', 'clone'].includes(verb) && NAMED_FIELDS.has(type)) {
585
+ cmd.option('-n, --name <name>', 'Name (over --from and --set)').option(
586
+ '-d, --description <text>',
587
+ 'Description (over --from and --set)',
588
+ );
589
+ }
590
+ if (verb === 'create' || verb === 'update' || verb === 'clone') bodyOptions(cmd);
591
+ if ((verb === 'create' || verb === 'update') && 'projects' in spec.ops) projectsOption(cmd);
592
+ if (verb === 'update' && 'patch' in spec.ops) {
593
+ cmd.option('--merge', 'Patch the value (JSON merge) instead of replacing it');
594
+ }
595
+ if (verb === 'delete') cmd.option('-y, --yes', 'Do not ask for confirmation');
596
+ if (verb === 'get') {
597
+ cmd.option('-f, --field <path>', 'Print only this field (dot path, e.g. config.source)');
598
+ cmd.option('-o, --output <file>', 'Write the JSON to a file');
599
+ }
600
+
601
+ cmd.action(async (...params) => {
602
+ const command = params[params.length - 1];
603
+ const opts = params[params.length - 2];
604
+ const values = params.slice(0, -2);
605
+ applyJsonFlag(opts);
606
+ ensureConfigured();
607
+ const user = getGlobalUser(command);
608
+ try {
609
+ const where = locate(type, values, { withId: verb !== 'list' });
610
+ const base = { type, ids: where.ids, ...(user ? { user } : {}) };
611
+ if (verb === 'list')
612
+ return runTool(tools.resource_list, { ...base, ...listArgs(opts) });
613
+ if (verb === 'get') {
614
+ return runTool(tools.resource_get, base, {
615
+ output: opts.output,
616
+ field: opts.field,
617
+ });
618
+ }
619
+ if (verb === 'delete') {
620
+ const ok = await confirmDestructive(opts, `Delete ${what} ${shown(where.ids)}?`);
621
+ if (ok) await runTool(tools.resource_delete, base);
622
+ return;
623
+ }
624
+ if (verb === 'set') {
625
+ const value = parseJsonValue(values[values.length - 1]);
626
+ return runTool((args) => setValue(tools, args), { ...base, body: { value } });
627
+ }
628
+ let body = (await bodyFromOptions(opts)) ?? {};
629
+ // Only a create may take several records (an array of properties).
630
+ if (verb !== 'create' && (typeof body !== 'object' || Array.isArray(body))) {
631
+ throw inputError(`${verb} takes a JSON object`);
632
+ }
633
+ const fields = {
634
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
635
+ ...(opts.description !== undefined ? { description: opts.description } : {}),
636
+ ...(opts.projects ? { projects: parseProjects(opts.projects) } : {}),
637
+ };
638
+ if (Object.keys(fields).length) {
639
+ if (typeof body !== 'object' || Array.isArray(body)) {
640
+ const flags = Object.keys(fields).map((f) => `--${f}`);
641
+ throw inputError(`${flags.join(', ')} need a JSON object body`);
642
+ }
643
+ body = { ...body, ...fields };
644
+ }
645
+ if (verb === 'create') return runTool(tools.resource_create, { ...base, body });
646
+ if (verb === 'update') {
647
+ return runTool(tools.resource_update, {
648
+ ...base,
649
+ body,
650
+ ...(opts.merge ? { merge: true } : {}),
651
+ });
652
+ }
653
+ // clone: the new id goes into the body's id field.
654
+ const idFields = spec.bodyId ?? idNames(spec);
655
+ if (idFields.length !== 1)
656
+ throw inputError(`${what} cannot be cloned from the command line`);
657
+ const copy = values[values.length - 1];
658
+ return runTool(tools.resource_clone, {
659
+ ...base,
660
+ body: { ...body, [idFields[0]]: copy },
661
+ });
662
+ } catch (error) {
663
+ const { message, code } = classifyError(error);
664
+ printErr(message, { code });
665
+ }
666
+ });
667
+ }
668
+
669
+ function addDescribe(group, type, tools) {
670
+ group
671
+ .command('describe')
672
+ .helpGroup('Records:')
673
+ .description(
674
+ `Fields, examples and permissions of ${article(noun(type))}, from the server's API`,
675
+ )
676
+ .option('-j, --json', 'Output as JSON')
677
+ .action(async (opts) => {
678
+ applyJsonFlag(opts);
679
+ ensureConfigured();
680
+ await runTool(
681
+ async (args) => {
682
+ const result = await tools.resource_describe(args);
683
+ if (result.isError || isJsonMode()) return result;
684
+ const said = result.content.map((c) => c.text).join('\n');
685
+ return textResult(describeForCli(said, type, group));
686
+ },
687
+ { type },
688
+ );
689
+ });
690
+ }
691
+
692
+ /** How a field of a JSON schema reads: its type, values or shape. */
693
+ function schemaType(field) {
694
+ if (!field || typeof field !== 'object') return 'any';
695
+ if (field.enum) return field.enum.map((v) => JSON.stringify(v)).join('|');
696
+ if (field.const !== undefined) return JSON.stringify(field.const);
697
+ const alternatives = field.oneOf ?? field.anyOf;
698
+ if (alternatives) return [...new Set(alternatives.map(schemaType))].join('|');
699
+ if (field.type === 'array') return `array of ${schemaType(field.items)}`;
700
+ if (Array.isArray(field.type)) return field.type.join('|');
701
+ if (field.type) return field.type;
702
+ if (field.properties) return 'object';
703
+ if (field.patternProperties || field.additionalProperties) return 'map';
704
+ return 'any';
705
+ }
706
+
707
+ /** Limits of a field worth a mention: lengths, ranges, default. */
708
+ function schemaLimits(field) {
709
+ const out = [];
710
+ if (field.maxLength !== undefined) out.push(`max ${field.maxLength} chars`);
711
+ if (field.minimum !== undefined) out.push(`min ${field.minimum}`);
712
+ if (field.maximum !== undefined) out.push(`max ${field.maximum}`);
713
+ if (field.default !== undefined) out.push(`default ${JSON.stringify(field.default)}`);
714
+ return out.join(', ');
715
+ }
716
+
717
+ /** Fields of an object schema, nested ones by their dot path, two levels down. */
718
+ function schemaFields(schema, prefix = '', depth = 0) {
719
+ const required = new Set(schema?.required || []);
720
+ const rows = [];
721
+ for (const [name, field] of Object.entries(schema?.properties || {})) {
722
+ const path = `${prefix}${name}`;
723
+ const nested = field.type === 'array' ? field.items : field;
724
+ const children = Object.keys(nested?.properties || {});
725
+ let type = schemaType(field);
726
+ if (children.length && depth >= 1) type += ` {${children.join(', ')}}`;
727
+ const about = [schemaLimits(field), field.description].filter(Boolean).join('. ');
728
+ rows.push([`${path}${required.has(name) ? '*' : ''}`, type, about]);
729
+ if (children.length && depth < 1) {
730
+ rows.push(
731
+ ...schemaFields(nested, `${path}${field.type === 'array' ? '[]' : ''}.`, depth + 1),
732
+ );
733
+ }
734
+ }
735
+ return rows;
736
+ }
737
+
738
+ const indent = (text, by) => text.replace(/^/gm, ' '.repeat(by));
739
+
740
+ /** Rows of schemaFields as aligned lines under a heading. */
741
+ function fieldTable(heading, rows) {
742
+ const width = Math.min(30, Math.max(...rows.map((r) => r[0].length)));
743
+ const typeWidth = Math.min(24, Math.max(...rows.map((r) => r[1].length)));
744
+ const lines = rows.map(([name, type, about]) =>
745
+ ` ${name.padEnd(width)} ${type.padEnd(typeWidth)} ${about}`.trimEnd(),
746
+ );
747
+ return [heading, ...lines];
748
+ }
749
+
750
+ /**
751
+ * A body (`{schema, examples}`) as tables of fields and its examples,
752
+ * pretty. A body with alternatives (a device by its type) gets a table per
753
+ * alternative, named after the field that tells them apart.
754
+ */
755
+ function describeBody(verb, body) {
756
+ const out = [];
757
+ const variants = (body.schema?.oneOf ?? body.schema?.anyOf)?.filter((v) => v?.properties);
758
+ if (variants?.length) {
759
+ const tag = Object.keys(variants[0].properties).find((name) =>
760
+ variants.every(
761
+ (v) => v.properties[name]?.enum || v.properties[name]?.const !== undefined,
762
+ ),
763
+ );
764
+ variants.forEach((v, i) => {
765
+ const when = tag ? `${tag} ${schemaType(v.properties[tag])}` : `option ${i + 1}`;
766
+ out.push(...fieldTable(`${verb} fields, ${when} (* required):`, schemaFields(v)));
767
+ });
768
+ } else if (body.schema?.properties) {
769
+ out.push(...fieldTable(`${verb} fields (* required):`, schemaFields(body.schema)));
770
+ } else if (body.schema) {
771
+ out.push(`${verb} body: ${schemaType(body.schema)}`);
772
+ }
773
+ for (const [name, example] of Object.entries(body.examples || {})) {
774
+ out.push(`${verb} example "${name}":`, indent(JSON.stringify(example, null, 2), 2));
775
+ }
776
+ return out.join('\n');
777
+ }
778
+
779
+ /**
780
+ * resource_describe's answer, for a command line: the commands of the type
781
+ * instead of the tools that run its operations and actions, its ids as the
782
+ * command takes them, the bodies as tables of fields and the JSON pretty.
783
+ */
784
+ export function describeForCli(text, type, group) {
785
+ const at = `${identity().name} ${commandPath(type).join(' ')}`;
786
+ const actions = Object.keys(CATALOG[type]?.actions || {});
787
+ const rename = (line) =>
788
+ actions.reduce(
789
+ (out, a) =>
790
+ actionName(type, a) === a
791
+ ? out
792
+ : out.replace(new RegExp(`\\b${a}\\b`, 'g'), actionName(type, a)),
793
+ line,
794
+ );
795
+ const prose = (value) =>
796
+ cliDescription(cliWording(rename(value), type)).replace(
797
+ /\bproduct_profile_\* types\b/g,
798
+ `\`${identity().name} product profile\` commands`,
799
+ );
800
+ const json = (value) => {
801
+ try {
802
+ return JSON.parse(value);
803
+ } catch {
804
+ return undefined;
805
+ }
806
+ };
807
+ let examples = false;
808
+ return text
809
+ .split('\n')
810
+ .map((line) => {
811
+ // After "examples:", one call per line: the command that makes it.
812
+ if (examples) return exampleCommand(json(line));
813
+ if (line === 'examples:') {
814
+ examples = true;
815
+ return line;
816
+ }
817
+ const [, key, value] = line.match(/^(\w+): (.*)$/s) || [];
818
+ const parsed = value?.startsWith('{') ? json(value) : undefined;
819
+ if (key === 'operations') {
820
+ const verbs = group.commands
821
+ .filter((c) => !c.commands.length && c.helpGroup() === 'Records:')
822
+ .map((c) => c.name());
823
+ return `commands: ${at} ${verbs.join('|')}`;
824
+ }
825
+ if (key === 'ids') {
826
+ // As the commands take them: positional, outermost first.
827
+ const chain = ancestry(type);
828
+ const own = idArgs(type);
829
+ return [
830
+ ...(chain.length
831
+ ? [`parent: ${chain.join(' > ')} (ids ${argSpec(parentArgs(type))})`]
832
+ : []),
833
+ `id: ${own.length ? argSpec(own) : 'none: a single resource of its parent'}`,
834
+ ].join('\n');
835
+ }
836
+ // The usage of each command says which ids it takes.
837
+ if (key === 'own_id') return null;
838
+ if (key === 'actions' && parsed) {
839
+ const lines = Object.entries(parsed).map(
840
+ ([a, { does }]) => ` ${at} ${actionName(type, a)}: ${prose(does ?? '')}`,
841
+ );
842
+ return `actions:\n${lines.join('\n')}`;
843
+ }
844
+ if (['create', 'update', 'clone'].includes(key) && parsed) {
845
+ return describeBody(key, parsed);
846
+ }
847
+ if (key === 'permissions' && parsed?.actions) {
848
+ // Actions by their command: test, not test_rule.
849
+ const named = Object.entries(parsed.actions).map(([verb, action]) => [
850
+ actions.includes(verb) ? actionName(type, verb) : verb,
851
+ action,
852
+ ]);
853
+ const width = Math.max(...named.map(([verb]) => verb.length));
854
+ const lines = named.map(([verb, action]) => ` ${verb.padEnd(width)} ${action}`);
855
+ return `permissions (${parsed.resource_type}):\n${lines.join('\n')}`;
856
+ }
857
+ if (key === 'record_example' && parsed) {
858
+ return `record example:\n${indent(JSON.stringify(parsed, null, 2), 2)}`;
859
+ }
860
+ if (key === 'notes' || key === 'description') return `${key}: ${prose(value)}`;
861
+ return line;
862
+ })
863
+ .filter((line) => line !== null && line !== undefined)
864
+ .join('\n');
865
+ }
866
+
867
+ /**
868
+ * The command that makes an example call of resource_describe, or
869
+ * undefined (dropped) when no command does: `thinger device property get
870
+ * boiler setpoint`, with its body piped to --from.
871
+ *
872
+ * @param {{ tool?: string, arguments?: Record<string, any>, does?: string } | undefined} example
873
+ */
874
+ function exampleCommand(example) {
875
+ const args = example?.arguments;
876
+ const tool = String(example?.tool ?? '').replace(/^[a-z0-9]+_(?=resource_)/, '');
877
+ const verb = tool.replace(/^resource_/, '');
878
+ if (!args?.type) return undefined;
879
+ const words = opCommand(args.type, verb === 'call' ? args.action : verb);
880
+ // Lists take filters as options the example does not map: skip them.
881
+ if (!words || (args.filter && Object.keys(args.filter).length)) return undefined;
882
+ const line = [identity().name, words, ...Object.values(args.ids || {})].join(' ');
883
+ const piped =
884
+ args.body === undefined ? line : `echo '${JSON.stringify(args.body)}' | ${line} --from -`;
885
+ return ` ${piped}${example?.does ? ` # ${example.does}` : ''}`;
886
+ }
887
+
888
+ /**
889
+ * A tool's text, for a command line (see cliWording), its type by the
890
+ * command's noun. Lines of JSON records are printed as they come.
891
+ */
892
+ function cliText(text, type) {
893
+ const label = type && type in CATALOG ? new RegExp(`\\b${type}\\b`, 'g') : null;
894
+ return text
895
+ .split('\n')
896
+ .map((line) => {
897
+ if (/^\s*([[{\]}]|"[^"]*":)/.test(line)) return line;
898
+ const out = cliWording(line, type);
899
+ return label && !out.includes('{') ? out.replace(label, noun(type)) : out;
900
+ })
901
+ .join('\n');
902
+ }
903
+
904
+ /**
905
+ * Actions that undo things as a delete does, asking first unless -y: the
906
+ * question each asks, of the type's noun and the ids.
907
+ */
908
+ const DESTRUCTIVE = {
909
+ replace_all: (what, of) => `Replace every ${what} of ${of}?`,
910
+ 'plugin.remove': (what, of) => `Uninstall plugin ${of}? Its data is removed.`,
911
+ 'host.restart': (what, of) => `Restart host ${of}? Every connection drops meanwhile.`,
912
+ };
913
+
914
+ function addAction(group, type, action, spec, tools) {
915
+ const parents = parentArgs(type);
916
+ const ids = spec.on === 'collection' ? [] : idArgs(type);
917
+ const name = actionName(type, action);
918
+ const destructive = DESTRUCTIVE[`${type}.${action}`] ?? DESTRUCTIVE[action];
919
+ const usage = [name, argSpec(parents), argSpec(ids)].filter(Boolean).join(' ');
920
+ const cmd = group
921
+ .command(usage)
922
+ .helpGroup(DATA_VERBS.includes(name) ? 'Data:' : 'Actions:')
923
+ // The group lists the first sentence; the command's help, all of it.
924
+ .summary(firstSentence(cliDescription(spec.description)))
925
+ .description(cliDescription(spec.description))
926
+ .option('-q, --query <key=value>', 'Query parameter (repeatable)', collectKeyString)
927
+ .option('-f, --field <path>', 'Print only this field of the answer (dot path)')
928
+ .option('-o, --output <file>', 'Write the answer to a file')
929
+ .option('-j, --json', 'Output as JSON');
930
+ describeIds(cmd, type);
931
+ if (spec.method !== 'get') bodyOptions(cmd);
932
+ if (destructive) cmd.option('-y, --yes', 'Do not ask for confirmation');
933
+ cmd.action(async (...params) => {
934
+ const command = params[params.length - 1];
935
+ const opts = params[params.length - 2];
936
+ const values = params.slice(0, -2);
937
+ applyJsonFlag(opts);
938
+ ensureConfigured();
939
+ const user = getGlobalUser(command);
940
+ try {
941
+ const where = locate(type, values, { withId: spec.on !== 'collection' });
942
+ let body = await bodyFromOptions(opts);
943
+ if (
944
+ destructive &&
945
+ !(await confirmDestructive(opts, destructive(noun(type), shown(where.ids))))
946
+ ) {
947
+ return;
948
+ }
949
+ // Actions that ask first: the command line asked already (or
950
+ // never did, for those it runs as typed).
951
+ if (
952
+ spec.confirm &&
953
+ (body === undefined || (typeof body === 'object' && !Array.isArray(body)))
954
+ ) {
955
+ body = { ...(body ?? {}), confirm: true };
956
+ }
957
+ await runTool(
958
+ tools.resource_call,
959
+ {
960
+ type,
961
+ action,
962
+ ids: where.ids,
963
+ ...(body !== undefined ? { body } : {}),
964
+
965
+ ...(opts.query ? { query: opts.query } : {}),
966
+ ...(user ? { user } : {}),
967
+ },
968
+ { output: opts.output, field: opts.field },
969
+ );
970
+ } catch (error) {
971
+ const { message, code } = classifyError(error);
972
+ printErr(message, { code });
973
+ }
974
+ });
975
+ }