@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
@@ -4,21 +4,32 @@ import { TIMEOUTS } from '../constants.js';
4
4
  import { getActiveProfile } from '../config.js';
5
5
  import { apiError, inputError } from '../errors.js';
6
6
  import { readDoc } from '../docs.js';
7
- import { findOperation, resolveRefs, serverSpec } from '../openapi.js';
7
+ import {
8
+ bundledSpec,
9
+ findOperation,
10
+ findOperationById,
11
+ resolveRefs,
12
+ serverSpec,
13
+ } from '../openapi.js';
8
14
  import {
9
15
  CATALOG,
10
16
  actionRequest,
11
17
  actionTemplate,
12
- ancestry,
13
- idHint,
18
+ idKey,
19
+ idLevels,
14
20
  idNames,
15
- parentHint,
21
+ listed,
16
22
  requestFor,
23
+ resolveIds,
17
24
  templateFor,
18
25
  typeNames,
19
26
  } from './catalog.js';
27
+ import { CORE_ACTIONS, CORE_TYPES } from './core-types.js';
20
28
  import { toolName } from './naming.js';
21
29
  import { mergeHooks } from './resource-hooks.js';
30
+ import { CLI, textResult } from './helpers.js';
31
+
32
+ export { CLI, DATA } from './helpers.js';
22
33
 
23
34
  /**
24
35
  * The generic `resource_*` tools: one tool per verb over every type of the
@@ -29,45 +40,77 @@ import { mergeHooks } from './resource-hooks.js';
29
40
  */
30
41
 
31
42
  /**
32
- * Types and actions a CLI adds to the catalog (`runCli({ resourceTypes,
33
- * resourceActions })`): its platform's own things, served by the same
34
- * tools. A CLI type has no REST path: its functions answer each verb.
43
+ * Types and actions served next to the catalog's: the core's own
44
+ * (core-types.js: data, files, device resources, guides, profiles) and a
45
+ * CLI's (`runCli({ resourceTypes, resourceActions })`), its platform's own
46
+ * things. Such a type has no REST path: its functions answer each verb.
35
47
  *
36
- * What reaches those functions comes from the model: `parent` and `id` are
37
- * checked for their number of segments (and `..`, empty or control
38
- * characters), but `id`, `filter`, `query` and the body are still untrusted
39
- * input, to be validated before use.
48
+ * What reaches those functions comes from the model: `ids` are checked
49
+ * (every one the type needs, none empty, `.`, `..` or with control
50
+ * characters), and `filter` and the bodies against their schemas when the
51
+ * type declares them, but values are still untrusted input, to be
52
+ * validated before use.
40
53
  *
41
54
  * @typedef {object} LocalContext
42
55
  * @property {string} type
43
- * @property {string} [parent]
44
- * @property {string} [id]
56
+ * @property {Record<string, string>} ids Every id, by name.
57
+ * @property {string} [parent] The parents' ids, outermost first, joined with "/".
58
+ * @property {string} [id] The record's own id (it may hold "/": a path).
45
59
  * @property {string | null} [user]
46
60
  * @property {Record<string, any>} [filter] resource_list's filter.
47
61
  * @property {Record<string, any>} [query] resource_call's query.
62
+ * @property {number} [count] resource_list's count, for lists not paged by the tool.
48
63
  * @property {boolean} [full]
64
+ * @property {boolean} [confirmed] An action that asks first: the user agreed (body.confirm).
65
+ * @property {(plan: string) => any} [plan] An action that asks first: answer what it would
66
+ * do instead of doing it (return what this gives).
49
67
  * @property {Record<string, any>} state
50
68
  *
51
69
  * @typedef {object} LocalAction
52
70
  * @property {string} description
53
- * @property {'item' | 'collection' | 'either'} [on] Whether it takes an id: item (default)
54
- * needs one, collection takes none, either takes it or not.
55
- * @property {object} [body] JSON schema of its body, shown by resource_describe.
71
+ * @property {'item' | 'collection' | 'either'} [on] Whether it takes its own id: item
72
+ * (default) needs one, collection takes none, either takes it or not.
73
+ * @property {object} [body] JSON schema of its body, shown by resource_describe and checked
74
+ * when it has properties (unknown keys too, unless additionalProperties is true).
75
+ * @property {boolean} [bodyRequired] Refused without a body.
76
+ * @property {boolean} [query] It reads the call's `query`.
77
+ * @property {boolean} [readOnly] Changes nothing: served in read-only mode.
78
+ * @property {boolean} [confirm] May ask first: its run returns ctx.plan(…) unless
79
+ * ctx.confirmed, when what it would do has a high impact.
80
+ * @property {string | string[]} [op] The server operations it runs (for describe's
81
+ * permissions, and for a server to run it natively).
82
+ * @property {boolean} [local] Runs on the machine of this CLI: no server operation.
83
+ * @property {import('./catalog.js').Example[]} [examples]
56
84
  * @property {(body: any, ctx: LocalContext) => any} run Returns the answer: text,
57
85
  * anything JSON, or a whole tool result ({content: [{type, text}], isError}) to flag a
58
86
  * failure (only actions can; the verbs answer text or JSON).
59
87
  *
60
88
  * @typedef {object} LocalType
61
89
  * @property {string} description One line, shown in the catalog summary.
62
- * @property {string} [parent] Type this one hangs off (the server's or another CLI
63
- * type with an id): its location is the `parent`.
64
- * @property {string} [id] Name of the item id; without one, a single resource of its parent.
90
+ * @property {string} [parent] Type this one hangs off (the server's or another one
91
+ * with an id): its ids come first in `ids`.
92
+ * @property {string} [id] Name of its own id in `ids`; without one, a single record of
93
+ * its parent (or a list whose records are not addressed one by one).
94
+ * @property {string} [bodyId] Field of the record that holds its id, when not `id`: create
95
+ * copies `ids[id]` into it.
96
+ * @property {string} [recordId] Field of a listed record that holds its id, when it is
97
+ * neither bodyId nor `id` (the records come from elsewhere: a path, a name).
65
98
  * @property {string} [notes] Longer guidance returned by resource_describe.
66
99
  * @property {object} [schema] JSON schema of a record, the body of create and update.
100
+ * @property {object} [filter] JSON schema of the filter its list takes (checked).
101
+ * @property {false} [paged] false: the list is answered as the function returns it, with
102
+ * neither search nor paging (the function reads `count`).
103
+ * @property {Array<'create' | 'update'>} [optionalId] Verbs that may go without the own id.
104
+ * @property {false} [profile] false: takes no `profile` (the profiles themselves).
105
+ * @property {false} [user] false: takes no `user`.
106
+ * @property {Partial<Record<string, string | string[]>>} [ops] The server operations of each
107
+ * verb (for describe's permissions, and for a server to serve it natively).
108
+ * @property {boolean} [local] Runs on the machine of this CLI: no server operation.
109
+ * @property {import('./catalog.js').Example[]} [examples]
67
110
  * @property {() => Record<string, any> | Promise<Record<string, any>>} [describe] More
68
111
  * for resource_describe to show (a reference the type's records follow, say).
69
112
  * @property {(ctx: LocalContext) => any[] | Promise<any[]>} [list] The whole list:
70
- * search and paging are done by the tool.
113
+ * search and paging are done by the tool (unless paged is false).
71
114
  * @property {(ctx: LocalContext) => any} [get]
72
115
  * @property {(body: any, ctx: LocalContext) => any} [create] Returns a result or a note.
73
116
  * @property {(body: any, ctx: LocalContext) => any} [update]
@@ -77,8 +120,24 @@ import { mergeHooks } from './resource-hooks.js';
77
120
 
78
121
  const LOCAL_VERBS = /** @type {const} */ (['list', 'get', 'create', 'update', 'delete']);
79
122
  const ACTION_ON = new Set(['item', 'collection', 'either']);
80
- // eslint-disable-next-line no-control-regex -- control characters are what it looks for
81
- const CONTROL = /[\x00-\x1f]/;
123
+
124
+ /** Tools that only read: the ones served in read-only mode, with resource_call's read-only actions. */
125
+ const READ_TOOLS = new Set(['resource_describe', 'resource_list', 'resource_get', 'resource_call']);
126
+
127
+ /** The last line of what an action that asks first answers. */
128
+ const CONFIRM_HINT =
129
+ 'Run again with confirm: true in the body to do it; confirm it with the user first.';
130
+
131
+ /** Key of what ctx.plan gives: an action that asks first answers this instead of acting. */
132
+ const PLAN = Symbol('plan');
133
+
134
+ /**
135
+ * What the MCP server hands a tool next to its arguments.
136
+ *
137
+ * @typedef {object} ToolExtra
138
+ * @property {(message: string) => Promise<boolean>} [elicit] Ask the user to confirm
139
+ * (when the client supports elicitation); true when they did.
140
+ */
82
141
 
83
142
  /** An own key of a plain map: never `constructor` or another inherited one. */
84
143
  const own = (map, key) =>
@@ -107,8 +166,9 @@ function capped(t) {
107
166
  }
108
167
 
109
168
  /**
110
- * Check what a CLI adds against the catalog: a type or an action that
111
- * clashes with the server's is a mistake, found when the server starts.
169
+ * Check what a CLI adds against the catalog and the core's types: a type
170
+ * or an action that clashes with theirs is a mistake, found when the
171
+ * server starts.
112
172
  *
113
173
  * @param {Record<string, LocalType>} types
114
174
  * @param {Record<string, Record<string, LocalAction>>} actions
@@ -116,8 +176,10 @@ function capped(t) {
116
176
  export function checkLocal(types, actions) {
117
177
  for (const [name, t] of Object.entries(types)) {
118
178
  if (CATALOG[name]) throw new Error(`Resource type "${name}" is already a server type`);
179
+ if (own(CORE_TYPES, name))
180
+ throw new Error(`Resource type "${name}" is already a core type`);
119
181
  if (!t.description) throw new Error(`Resource type "${name}" needs a description`);
120
- if (t.parent && !CATALOG[t.parent] && !own(types, t.parent)) {
182
+ if (t.parent && !CATALOG[t.parent] && !own(types, t.parent) && !own(CORE_TYPES, t.parent)) {
121
183
  throw new Error(`Resource type "${name}": unknown parent "${t.parent}"`);
122
184
  }
123
185
  const seen = new Set([name]);
@@ -132,11 +194,12 @@ export function checkLocal(types, actions) {
132
194
  for (const [a, spec] of Object.entries(t.actions || {})) checkAction(name, a, spec);
133
195
  }
134
196
  for (const [type, list] of Object.entries(actions)) {
135
- if (!CATALOG[type] && !types[type]) {
197
+ if (!CATALOG[type] && !types[type] && !own(CORE_TYPES, type)) {
136
198
  throw new Error(`Actions for unknown resource type "${type}"`);
137
199
  }
138
200
  for (const [a, spec] of Object.entries(list)) {
139
- if (CATALOG[type]?.actions?.[a] || types[type]?.actions?.[a]) {
201
+ const core = own(CORE_ACTIONS, type)?.[a] ?? own(CORE_TYPES, type)?.actions?.[a];
202
+ if (CATALOG[type]?.actions?.[a] || types[type]?.actions?.[a] || core) {
140
203
  throw new Error(`Resource type "${type}" already has an action "${a}"`);
141
204
  }
142
205
  checkAction(type, a, spec);
@@ -156,38 +219,123 @@ function checkAction(type, name, spec) {
156
219
  /** `{tool:x}` in catalog notes → full tool names (tests check they exist). */
157
220
  const expandNotes = (notes) => notes.replace(/\{tool:([a-z0-9_]+)\}/g, (_, base) => toolName(base));
158
221
 
222
+ /** An operation in a text: `{op:<type>.<verb or action>}`. */
223
+ const OP_REF = /\{op:([a-z0-9_]+)\.([a-z0-9_]+)\}/g;
224
+
225
+ /** JSON type of a value, as a schema names it. */
226
+ function jsonType(value) {
227
+ if (value === null) return 'null';
228
+ if (Array.isArray(value)) return 'array';
229
+ return typeof value;
230
+ }
231
+
232
+ /** A schema type (or several) as an error names it: "a JSON object or an array". */
233
+ const typeWords = (type) =>
234
+ [type]
235
+ .flat()
236
+ .map(
237
+ (t) =>
238
+ ({
239
+ object: 'a JSON object',
240
+ array: 'an array',
241
+ integer: 'an integer',
242
+ null: 'null',
243
+ })[t] ?? `a ${t}`,
244
+ )
245
+ .join(' or ');
246
+
247
+ /** Whether `value` is of the schema type (or one of them). */
248
+ function typeOk(value, type) {
249
+ const types = [type].flat();
250
+ const actual = jsonType(value);
251
+ return types.some(
252
+ (t) =>
253
+ t === actual ||
254
+ (t === 'integer' && Number.isInteger(value)) ||
255
+ (t === 'number' && actual === 'number'),
256
+ );
257
+ }
258
+
259
+ /**
260
+ * Check a value against the top level of its schema: its type, the keys
261
+ * it requires, keys it does not take (unless additionalProperties is true,
262
+ * or `unknown` is false) and each key's type and allowed values. Free
263
+ * values (no properties) pass. The errors say what it takes.
264
+ *
265
+ * @param {unknown} value
266
+ * @param {any} schema
267
+ * @param {string} where What it is, for the errors: "filter of bucket_data".
268
+ * @param {{ unknown?: boolean }} [opts]
269
+ */
270
+ export function checkShape(value, schema, where, { unknown = true } = {}) {
271
+ if (!schema || typeof schema !== 'object' || value === undefined) return;
272
+ if (schema.type && !typeOk(value, schema.type)) {
273
+ throw inputError(`${where} must be ${typeWords(schema.type)}`);
274
+ }
275
+ const props = schema.properties;
276
+ if (!props || jsonType(value) !== 'object') return;
277
+ const record = /** @type {Record<string, any>} */ (value);
278
+ const missing = (schema.required || []).filter((k) => record[k] === undefined);
279
+ if (missing.length) throw inputError(`${where} needs ${listed(missing)}`);
280
+ if (unknown && schema.additionalProperties !== true) {
281
+ const stray = Object.keys(record).filter((k) => !Object.hasOwn(props, k));
282
+ if (stray.length) {
283
+ throw inputError(
284
+ `${where} has no ${stray.map((k) => `"${k}"`).join(', ')}; it takes: ${Object.keys(props).join(', ')}`,
285
+ );
286
+ }
287
+ }
288
+ for (const [key, v] of Object.entries(record)) {
289
+ const field = own(props, key);
290
+ if (!field || v === undefined) continue;
291
+ if (field.type && !typeOk(v, field.type)) {
292
+ throw inputError(`${where}: ${key} must be ${typeWords(field.type)}`);
293
+ }
294
+ if (Array.isArray(field.enum) && !field.enum.includes(v)) {
295
+ throw inputError(
296
+ `${where}: ${key} must be one of ${field.enum.map((e) => JSON.stringify(e)).join(', ')}`,
297
+ );
298
+ }
299
+ }
300
+ }
301
+
159
302
  /** Verbs whose request carries a body. */
160
303
  const BODY_VERBS = new Set(['create', 'update', 'patch', 'clone']);
161
304
 
162
305
  /** Examples named like this document one field each: noise in a description. */
163
306
  const FIELD_EXAMPLE = /^Update '/;
164
307
 
165
- const text = (t) => ({ content: [{ type: 'text', text: t }], isError: false });
308
+ /** A tool result; `data` is what the CLI prints with --json. */
309
+ const text = textResult;
166
310
 
167
311
  /**
168
312
  * A type and the ids that locate it, for messages: `device_property thermostat/setpoint`.
169
313
  *
170
314
  * @param {string} type
171
- * @param {{ parent?: string, id?: string }} [where]
315
+ * @param {Record<string, any>} [ids]
172
316
  */
173
- function label(type, { parent, id } = {}) {
174
- const where = [parent, id].filter(Boolean).join('/');
317
+ function label(type, ids = {}) {
318
+ const where = Object.values(ids || {})
319
+ .filter((v) => v !== undefined && v !== null && v !== '')
320
+ .join('/');
175
321
  return where ? `${type} ${where}` : type;
176
322
  }
177
323
 
178
- /** The id a create or clone body names, when it carries every id field. */
324
+ /** The field of a catalog record that holds its id, as `create` and `clone` bodies write it. */
325
+ const idField = (t) => (t.bodyId ?? idNames(t))[0];
326
+
327
+ /** The id a create or clone body names, when it carries the id field. */
179
328
  function bodyIdOf(t, body) {
180
- const fields = t.bodyId ?? idNames(t);
181
- if (!fields.length || !body || typeof body !== 'object' || Array.isArray(body))
182
- return undefined;
183
- const ids = fields.map((f) => body[f]);
184
- return ids.every((v) => v !== undefined && v !== null && v !== '') ? ids.join('/') : undefined;
329
+ const field = idField(t);
330
+ if (!field || !body || typeof body !== 'object' || Array.isArray(body)) return undefined;
331
+ const id = body[field];
332
+ return id !== undefined && id !== null && id !== '' ? String(id) : undefined;
185
333
  }
186
334
 
187
335
  /**
188
336
  * @param {string} type
189
337
  * @param {import('./catalog.js').Verb} verb
190
- * @param {{ parent?: string, id?: string, user?: string | null }} args
338
+ * @param {{ ids?: Record<string, any>, user?: string | null }} args
191
339
  * @param {{ data?: any, params?: Record<string, any> }} [request]
192
340
  */
193
341
  async function send(type, verb, args, { data, params } = {}) {
@@ -203,7 +351,7 @@ async function send(type, verb, args, { data, params } = {}) {
203
351
  });
204
352
  return res.data;
205
353
  } catch (e) {
206
- throw apiError(e, { notFound: `Not found: ${label(type, args)}` });
354
+ throw apiError(e, { notFound: `Not found: ${label(type, args.ids)}` });
207
355
  }
208
356
  }
209
357
 
@@ -249,11 +397,14 @@ export function pick(record, fields) {
249
397
  return out;
250
398
  }
251
399
 
252
- /** One record per line: lists stay readable without paying for indentation. */
253
- function formatList(type, data, pageSize) {
400
+ /**
401
+ * One record per line: lists stay readable without paying for indentation.
402
+ * `shown` gives what a line shows of a record (its ids first, for agents).
403
+ */
404
+ function formatList(type, data, pageSize, shown = (record) => record) {
254
405
  if (!Array.isArray(data)) return JSON.stringify(data, null, 2);
255
406
  if (!data.length) return `No ${type} found.`;
256
- const lines = data.map((item) => JSON.stringify(item));
407
+ const lines = data.map((item) => JSON.stringify(shown(item)));
257
408
  const full =
258
409
  data.length === pageSize
259
410
  ? `\n(page full: there may be more; pass index: ${data.length} for the next page)`
@@ -349,20 +500,68 @@ function operationsOf(ops) {
349
500
  }
350
501
 
351
502
  /**
352
- * The tools, over the catalog and what the CLI adds to it.
503
+ * Sample ids for the examples resource_describe makes up: real-looking
504
+ * values, never placeholders.
505
+ */
506
+ const SAMPLE_IDS = {
507
+ device: 'boiler',
508
+ product: 'boiler_kit',
509
+ bucket: 'temps',
510
+ storage: 'web_app',
511
+ dashboard: 'plant_overview',
512
+ endpoint: 'alert_webhook',
513
+ property: 'setpoint',
514
+ project: 'plant_a',
515
+ rule: 'high_temp',
516
+ instance: 'high_temp_boiler',
517
+ type: 'boiler',
518
+ group: 'plant_a',
519
+ token: 'dashboard_viewer',
520
+ plugin: 'node-red',
521
+ resource: 'uplink',
522
+ tag: 'device',
523
+ user: 'alice',
524
+ host: 'console.example.com',
525
+ };
526
+ const sampleId = (key) => SAMPLE_IDS[key] ?? `my_${key}`;
527
+
528
+ /**
529
+ * The tools, over the catalog, the core's types and what the CLI adds.
353
530
  *
354
531
  * @param {{
355
532
  * hooks?: Record<string, import('./resource-hooks.js').ResourceHooks>,
356
533
  * types?: Record<string, LocalType>,
357
534
  * actions?: Record<string, Record<string, LocalAction>>,
358
- * }} [opts]
535
+ * readOnly?: boolean,
536
+ * }} [opts] `readOnly`: serve only what reads (describe, list, get, and
537
+ * resource_call with the read-only actions).
538
+ * @returns {Array<Record<string, any>>} The tools: name, description, inputSchema,
539
+ * annotations, handler(args, extra) and explain(args, unknownKeys).
359
540
  */
360
- export function resourceTools({ hooks: extraHooks, types: local = {}, actions: extra = {} } = {}) {
361
- checkLocal(local, extra);
541
+ export function resourceTools({
542
+ hooks: extraHooks,
543
+ types: cliTypes = {},
544
+ actions: cliActions = {},
545
+ readOnly = false,
546
+ } = {}) {
547
+ checkLocal(cliTypes, cliActions);
362
548
  const hooks = mergeHooks(extraHooks);
549
+ /** @type {Record<string, LocalType>} */
550
+ const local = { ...CORE_TYPES, ...cliTypes };
551
+ /** @type {Record<string, Record<string, LocalAction>>} */
552
+ const extra = {};
553
+ for (const [type, list] of [...Object.entries(CORE_ACTIONS), ...Object.entries(cliActions)]) {
554
+ extra[type] = { ...(extra[type] || {}), ...list };
555
+ }
556
+ /** Core types and actions are served whole; a CLI's answers are capped. */
557
+ const isCore = (type, action) =>
558
+ action
559
+ ? !!(own(CORE_ACTIONS, type)?.[action] ?? own(CORE_TYPES, type)?.actions?.[action])
560
+ : !!own(CORE_TYPES, type);
561
+ const cap = (type, action, t) => (isCore(type, action) ? String(t ?? '') : capped(t));
363
562
  const served = new Set([...typeNames(), ...Object.keys(local)]);
364
563
 
365
- /** Verbs of a type: the catalog's, or the functions a CLI type has. */
564
+ /** Verbs of a type: the catalog's, or the functions a local type has. */
366
565
  const opsOf = (name) =>
367
566
  own(local, name)
368
567
  ? Object.fromEntries(
@@ -372,82 +571,188 @@ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: e
372
571
  ]),
373
572
  )
374
573
  : CATALOG[name].ops;
375
- /** Actions a CLI adds, to its own types or to the server's. */
574
+ /** The verbs this server runs on a type: in read-only mode, list and get. */
575
+ const servedOps = (name) =>
576
+ Object.fromEntries(
577
+ Object.entries(opsOf(name)).filter(([v]) => !readOnly || v === 'list' || v === 'get'),
578
+ );
579
+ /** Actions the core or a CLI adds, to their own types or to the server's. */
580
+
376
581
  const localActionsOf = (name) => ({
377
582
  ...(own(local, name)?.actions || {}),
378
583
  ...(own(extra, name) || {}),
379
584
  });
380
- const actionNames = (name) => [
381
- ...Object.keys(CATALOG[name]?.actions || {}),
382
- ...Object.keys(localActionsOf(name)),
383
- ];
585
+ /** Every action of a type, the catalog's and the added ones. */
586
+ const allActionsOf = (name) => ({ ...(CATALOG[name]?.actions || {}), ...localActionsOf(name) });
587
+ const actionNames = (name) =>
588
+ Object.entries(allActionsOf(name))
589
+ .filter(([, spec]) => !readOnly || spec.readOnly)
590
+ .map(([a]) => a);
384
591
  const namesWith = (verb) => [...served].filter((n) => verb in opsOf(n));
385
- /** How the parent of a type is written: `<device>`, `<device>/<bucket>`. */
386
- const parentOf = (name) => {
387
- if (!own(local, name)) return parentHint(name);
388
- const p = local[name].parent;
389
- return p ? [parentOf(p), idOf(p)].filter(Boolean).join('/') : '';
390
- };
391
- function idOf(name) {
392
- if (!own(local, name)) return idHint(name);
393
- return local[name].id ? `<${local[name].id}>` : '';
394
- }
592
+ const actionTypes = () => [...served].filter((n) => actionNames(n).length);
593
+ const levelsOf = (name) => idLevels(name, local);
594
+ /** Whether a type has an id of its own (not a single record of its parent). */
595
+ const hasOwnId = (name) => (own(local, name) ? !!local[name].id : idKey(name) !== undefined);
395
596
 
396
597
  /**
397
- * `parent` (and `id`, when needed) as many segments as the type's
398
- * location has, none empty, `.`, `..` or with control characters. The
399
- * id of a CLI type is one value, which may hold "/" (a path).
598
+ * `{op:t.v}` → the call that runs it, checked: a typo in a text fails
599
+ * when the tools are built, not when a model follows it.
400
600
  */
401
- function checkLocation(args, { needsId }) {
402
- const bad = (part) => !part || part === '.' || part === '..' || CONTROL.test(part);
403
- const segments = (value, hint, what) => {
404
- if (!hint) return;
405
- const parts = value === undefined || value === null ? [] : String(value).split('/');
406
- if (parts.length !== hint.split('/').length || parts.some(bad)) {
407
- throw inputError(`${what} (${hint}) is required`);
601
+ const expand = (text) =>
602
+ expandNotes(text).replace(OP_REF, (all, type, op) => {
603
+ if (!served.has(type)) throw new Error(`${all}: unknown type "${type}"`);
604
+ if (op === 'describe') return `${toolName('resource_describe')} type "${type}"`;
605
+ if (op in opsOf(type)) return `${toolName(`resource_${op}`)} type "${type}"`;
606
+ if (op in allActionsOf(type)) {
607
+ return `${toolName('resource_call')} type "${type}" action "${op}"`;
408
608
  }
409
- };
410
- segments(args.parent, parentOf(args.type), 'parent');
411
- if (!needsId || !idOf(args.type)) return;
412
- if (own(local, args.type)) {
413
- const id = args.id === undefined || args.id === null ? '' : String(args.id);
414
- if (!id || CONTROL.test(id))
415
- throw inputError(`id (${idOf(args.type)}) is required`);
609
+ throw new Error(`${all}: ${type} has no operation "${op}"`);
610
+ });
611
+
612
+ // Every reference of every text, checked now.
613
+ for (const name of served) {
614
+ const t = own(local, name) ?? CATALOG[name];
615
+ for (const text of [
616
+ t.description,
617
+ t.notes,
618
+ ...Object.values(allActionsOf(name)).map((a) => a.description),
619
+ ]) {
620
+ if (text) expand(text);
621
+ }
622
+ }
623
+
624
+ /** Describe's line about a type's ids
625
+ : their names, and the REST names where they differ. */
626
+ function idsLine(name) {
627
+ const levels = levelsOf(name);
628
+ if (!levels.length) return 'none';
629
+ const mine = hasOwnId(name) ? levels.at(-1) : undefined;
630
+ const shape = levels
631
+ .map((l) => `"${l.key}": <${l === mine ? 'its own id' : `${l.type} id`}>`)
632
+ .join(', ');
633
+ // `type` of an asset type is an id, not the type argument.
634
+ const clash = levels.some((l) => l.key === 'type')
635
+ ? '; ids.type is the id, not the `type` argument'
636
+ : '';
637
+ return `{${shape}}${clash}`;
638
+ }
639
+
640
+ /** What describe says about the own id of a create. */
641
+ function ownIdRule(name) {
642
+ if (!hasOwnId(name)) return undefined;
643
+ const key = levelsOf(name).at(-1)?.key;
644
+ if (own(local, name)) {
645
+ const t = local[name];
646
+ if (!('create' in opsOf(name))) return undefined;
647
+ if (t.optionalId?.includes('create')) return `optional on create`;
648
+ return `required: in ids.${key}, or the body field "${t.bodyId ?? t.id}"`;
649
+ }
650
+ const t = CATALOG[name];
651
+ if (!('create' in t.ops)) return undefined;
652
+ if (t.upsert) return `required on create, in ids.${key} (the entry is stored under it)`;
653
+ if (!idField(t)) return `generated by the server: create takes none`;
654
+ return `create takes it in ids.${key} or in the body field "${idField(t)}"`;
655
+ }
656
+
657
+ /** The ids of a record a list or get answered, to pass back as they are. */
658
+ function recordIds(type, record, parentIds, key) {
659
+ if (!hasOwnId(type)) return { ...parentIds };
660
+ const mine = levelsOf(type).at(-1);
661
+ let value;
662
+ if (own(local, type)) {
663
+ const t = local[type];
664
+ const field = t.recordId ?? t.bodyId ?? t.id;
665
+ value =
666
+ record && typeof record === 'object'
667
+ ? record[/** @type {string} */ (field)]
668
+ : undefined;
416
669
  } else {
417
- segments(args.id, idOf(args.type), 'id');
670
+ const t = CATALOG[type];
671
+ const rule = t.recordId || {};
672
+ if (rule.from === 'key') value = key;
673
+ else if (rule.from === 'value') value = typeof record === 'string' ? record : undefined;
674
+ else if (record && typeof record === 'object')
675
+ value = record[rule.field ?? idField(t) ?? t.id];
418
676
  }
677
+ if (value === undefined || value === null || value === '') return undefined;
678
+ return { ...parentIds, [/** @type {any} */ (mine).key]: String(value) };
679
+ }
680
+
681
+ /** A record as agents see it: its ids first. */
682
+ function withIds(type, record, parentIds, key) {
683
+ if (!hasOwnId(type)) return record;
684
+ const ids = recordIds(type, record, parentIds, key);
685
+ if (!ids) return record;
686
+ if (typeof record === 'string') return { ids };
687
+ return record && typeof record === 'object' && !Array.isArray(record)
688
+ ? { ids, ...record }
689
+ : record;
419
690
  }
420
691
 
692
+ /** The parents' ids of a call, by name. */
693
+ const parentIdsOf = (type, ids) => {
694
+ const keys = levelsOf(type)
695
+ .slice(0, hasOwnId(type) ? -1 : undefined)
696
+ .map((l) => l.key);
697
+ return Object.fromEntries(keys.map((k) => [k, ids[k]]));
698
+ };
699
+
421
700
  function catalogSummary(names) {
422
701
  const lines = names.map((name) => {
423
702
  const t = local[name] || CATALOG[name];
424
- const parent = t.parent ? ` (parent ${parentOf(name)})` : '';
425
- const id = idOf(name) ? '' : ' (single, no id)';
703
+ const keys = levelsOf(name).map((l) => l.key);
704
+ const single = !hasOwnId(name) && !('list' in opsOf(name));
705
+ const shown =
706
+ single || t.parent || (keys.length && keys[0] !== name)
707
+ ? ` (ids ${keys.join(', ')}${single ? '; single record' : ''})`
708
+ : '';
426
709
  const actions = actionNames(name);
427
710
  const ops =
428
711
  Object.keys(opsOf(name)).join(' ') +
429
712
  (actions.length ? ` | ${actions.join(' ')}` : '');
430
- return `${name}${parent}${id}: ${expandNotes(t.description)} [${ops}]`;
713
+ return `${name}${shown}: ${expand(t.description)} [${ops}]`;
431
714
  });
432
- return `Resource types, with their operations and, after |, their actions (run with ${toolName('resource_call')}). ${toolName('resource_describe')} with a type gives its schemas and examples.\n\n${lines.join('\n')}`;
715
+ return `Resource types, with their operations and, after |, their actions (run with ${toolName('resource_call')}). ${toolName('resource_describe')} with a type gives its ids, schemas and ready-to-send examples.\n\n${lines.join('\n')}`;
716
+ }
717
+
718
+ /**
719
+ * Check a call's `ids` for a type and verb: what resolveIds gives, plus
720
+ * the parents' ids joined (`parent`), as the context of hooks and of
721
+ * local types has them.
722
+ *
723
+ * @param {string} type
724
+ * @param {Record<string, any>} args
725
+ * @param {'required' | 'optional' | 'forbidden'} ownRule
726
+ * @param {(key: string) => string} [forbidden]
727
+ */
728
+ function where(type, args, ownRule, forbidden) {
729
+ const resolved = resolveIds(type, args.ids, { own: ownRule, forbidden, local });
730
+ return {
731
+ ids: resolved.ids,
732
+ parent: resolved.levels.length ? resolved.levels.join('/') : undefined,
733
+ id: resolved.own,
734
+ };
433
735
  }
434
736
 
435
- /** Context of a call on a CLI type or action, with its parent and id checked. */
436
- function localContext(args, { needsId = true, state = {} } = {}) {
437
- checkLocation(args, { needsId });
737
+ /** Context of a call on a local type or action, its ids already checked. */
738
+ function localContext(args, at, { state = {}, confirmed = false } = {}) {
438
739
  return {
439
740
  type: args.type,
440
- parent: args.parent,
441
- id: args.id,
741
+ ids: at.ids,
742
+ parent: at.parent,
743
+ id: at.id,
442
744
  user: args.user,
443
745
  filter: args.filter,
444
746
  query: args.query,
747
+ count: args.count,
445
748
  full: args.full === true,
749
+ confirmed,
750
+ plan: (text) => ({ [PLAN]: text }),
446
751
  state,
447
752
  };
448
753
  }
449
754
 
450
- /** What a CLI function answered, as tool text. */
755
+ /** What a local function answered, as tool text. */
451
756
  const answer = (value) =>
452
757
  value === undefined || value === null || value === ''
453
758
  ? undefined
@@ -455,82 +760,205 @@ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: e
455
760
  ? value
456
761
  : JSON.stringify(value, null, 2);
457
762
 
763
+ /** Every type, for the error of an unknown one. */
764
+ const unknownType = (type) =>
765
+ inputError(
766
+ `Unknown type "${type ?? ''}". Types: ${[...served].join(', ')} (${toolName('resource_describe')} describes them).`,
767
+ );
768
+
458
769
  /**
459
- * Validate the type for a verb and build the hook context.
770
+ * Validate the type for a verb, its ids and the arguments it refuses,
771
+ * and build the hook context.
460
772
  *
461
773
  * @param {Record<string, any>} args
462
774
  * @param {import('./catalog.js').Verb} verb
463
775
  */
464
776
  function prepare(args, verb) {
465
777
  const type = args.type;
466
- if (!type || !served.has(type)) {
778
+ if (!type || !served.has(type)) throw unknownType(type);
779
+ if (!(verb in opsOf(type))) {
780
+ const allowed = Object.keys(opsOf(type)).join(', ') || 'none';
781
+ const others = namesWith(verb).join(', ');
467
782
  throw inputError(
468
- `Unknown type "${type ?? ''}". ${toolName('resource_describe')} lists them.`,
783
+ `Type "${type}" does not support ${verb} (it supports: ${allowed}). Types with ${verb}: ${others}.`,
469
784
  );
470
785
  }
471
- if (!(verb in opsOf(type))) {
472
- const allowed = Object.keys(opsOf(type)).join(', ');
473
- throw inputError(`Type "${type}" does not support ${verb} (it supports: ${allowed})`);
786
+ const t = own(local, type);
787
+ if (t?.user === false && args.user !== undefined) {
788
+ throw inputError(`${type} takes no user: it is the same for every account`);
474
789
  }
475
- // CLI types: located before any hook runs; the id of a create may
476
- // still come from the body.
477
- if (own(local, type)) {
478
- if (args.merge) throw inputError(`${type} takes no merge: send the fields to change`);
479
- checkLocation(args, { needsId: verb !== 'list' && verb !== 'create' });
790
+ if (t?.profile === false && args.profile !== undefined) {
791
+ throw inputError(`${type} takes no profile: it lists this CLI's own`);
480
792
  }
793
+ if (t && args.merge) throw inputError(`${type} takes no merge: send the fields to change`);
794
+ const ownRule =
795
+ verb === 'list'
796
+ ? 'forbidden'
797
+ : verb === 'create' || (t?.optionalId?.includes(/** @type {any} */ (verb)) ?? false)
798
+ ? 'optional'
799
+ : 'required';
800
+ const at = where(
801
+ type,
802
+ args,
803
+ ownRule,
804
+ (key) => `${verb} takes no ${key}: leave it out of ids`,
805
+ );
481
806
  const ctx = {
482
807
  type,
483
- parent: args.parent,
484
- id: args.id,
808
+ ids: at.ids,
809
+ parent: at.parent,
810
+ id: at.id,
485
811
  user: args.user,
486
812
  full: args.full === true,
813
+ merge: args.merge === true,
487
814
  state: {},
488
815
  };
489
- return { type, ctx, hook: hooks[type] || {} };
816
+ return { type, at, ctx, hook: hooks[type] || {} };
490
817
  }
491
818
 
819
+ /**
820
+ * The body of a create, update or clone, without the `ids` a record
821
+ * read before carries: they must point where the call does.
822
+ */
492
823
  function bodyArg(args, verb) {
493
824
  const body = args.body ?? {};
494
825
  if (typeof body !== 'object' || body === null) {
495
826
  throw inputError('body must be a JSON object (or array where the type takes one)');
496
827
  }
497
- if (BODY_VERBS.has(verb) && verb !== 'create' && !Object.keys(body).length) {
828
+ if (Array.isArray(body)) return body;
829
+ const { ids: carried, ...rest } = body;
830
+ if (carried !== undefined && carried !== null && typeof carried === 'object') {
831
+ const given = args.ids || {};
832
+ const differ = Object.entries(carried).some(
833
+ ([k, v]) => given[k] !== undefined && String(given[k]) !== String(v),
834
+ );
835
+ if (differ) {
836
+ throw inputError(
837
+ `body.ids points at ${Object.values(carried).join('/')} but ids at ${Object.values(given).join('/')}`,
838
+ );
839
+ }
840
+ }
841
+ // A clone says which id it lacks.
842
+ if (verb === 'update' && !Object.keys(rest).length) {
498
843
  throw inputError('body is required');
499
844
  }
500
- return body;
845
+ return rest;
501
846
  }
502
847
 
503
848
  /** Actions as resource_describe shows them: how to run, what they do, their body. */
504
- function describeActions(actions) {
849
+ function describeActions(name) {
505
850
  return Object.fromEntries(
506
- Object.entries(actions).map(([a, spec]) => {
507
- const entry = {
508
- run: `${toolName('resource_call')} action "${a}"`,
509
- does: expandNotes(spec.description),
510
- };
511
- if (typeof spec.run === 'function') {
851
+ Object.entries(allActionsOf(name))
852
+ .filter(([, spec]) => !readOnly || spec.readOnly)
853
+ .map(([a, spec]) => {
854
+ const entry = {
855
+ run: `${toolName('resource_call')} type "${name}" action "${a}"`,
856
+ does: expand(spec.description),
857
+ };
512
858
  const on = spec.on || 'item';
513
- if (on !== 'item') entry.id = on === 'collection' ? 'none' : 'optional';
514
- if (spec.body) entry.body = { schema: spec.body };
515
- } else if (spec.on === 'collection') {
516
- entry.id = 'none';
517
- }
518
- return [a, entry];
519
- }),
859
+ const key = levelsOf(name).at(-1)?.key;
860
+ if (on === 'collection') entry.ids = `parents only: no ${key}`;
861
+ if (on === 'either') entry.ids = `${key} optional`;
862
+ if (spec.readOnly) entry.read_only = true;
863
+ if (spec.bodyRequired) entry.body_required = true;
864
+ if (spec.confirm) {
865
+ entry.asks_first =
866
+ 'without body.confirm: true it answers what it would do instead of doing it';
867
+ }
868
+ if (spec.local) entry.runs = 'on the machine of this CLI';
869
+ if (typeof spec.run === 'function' && spec.body)
870
+ entry.body = { schema: spec.body };
871
+ return [a, entry];
872
+ }),
520
873
  );
521
874
  }
522
875
 
523
- /** resource_describe of a CLI type: everything it says comes from its definition. */
876
+ /** The examples of a type: its own and its actions', then made-up ones for the verbs left. */
877
+ function examplesOf(name) {
878
+ const t = own(local, name) ?? CATALOG[name];
879
+ const given = [
880
+ ...(t.examples || []),
881
+ ...Object.values(allActionsOf(name)).flatMap((a) => a.examples || []),
882
+ ];
883
+ const covered = new Set(given.map((e) => e.tool));
884
+ const levels = levelsOf(name);
885
+ const all = Object.fromEntries(levels.map((l) => [l.key, sampleId(l.key)]));
886
+ const parents = hasOwnId(name) ? Object.fromEntries(Object.entries(all).slice(0, -1)) : all;
887
+ const made = [];
888
+ const idsArg = (ids) => (Object.keys(ids).length ? { ids } : {});
889
+ if ('list' in opsOf(name) && !covered.has('resource_list')) {
890
+ made.push({
891
+ tool: 'resource_list',
892
+ arguments: { type: name, ...idsArg(parents) },
893
+ does: `List the ${name} records.`,
894
+ });
895
+ }
896
+ if ('get' in opsOf(name) && !covered.has('resource_get')) {
897
+ made.push({
898
+ tool: 'resource_get',
899
+ arguments: { type: name, ...idsArg(all) },
900
+ does: `Read one ${name}.`,
901
+ });
902
+ }
903
+ return [...given, ...made].filter(allowedExample);
904
+ }
905
+
906
+ /** Whether this server runs an example: in read-only mode, only reads. */
907
+ function allowedExample(e) {
908
+ if (!readOnly) return true;
909
+ if (!READ_TOOLS.has(e.tool)) return false;
910
+ if (e.tool !== 'resource_call') return true;
911
+ return !!own(allActionsOf(e.arguments.type), e.arguments.action)?.readOnly;
912
+ }
913
+
914
+ /** Examples as describe prints them: one JSON call per line. */
915
+ const exampleLines = (examples) =>
916
+ examples
917
+ .map((e) =>
918
+ JSON.stringify({ tool: toolName(e.tool), arguments: e.arguments, does: e.does }),
919
+ )
920
+ .join('\n');
921
+
922
+ /** What a token needs to allow each verb and action of a local type, from their operations. */
923
+ function localPermissions(doc, name) {
924
+ const ops = {
925
+ ...Object.fromEntries(
926
+ Object.entries(own(local, name)?.ops || {}).map(([verb, op]) => [verb, op]),
927
+ ),
928
+ ...Object.fromEntries(
929
+ Object.entries(localActionsOf(name))
930
+ .filter(([, a]) => a.op)
931
+ .map(([a, spec]) => [a, spec.op]),
932
+ ),
933
+ };
934
+ const actions = {};
935
+ let resourceType;
936
+ for (const [what, op] of Object.entries(ops)) {
937
+ const found = [op]
938
+ .flat()
939
+ .map((id) => findOperationById(doc, /** @type {string} */ (id)))
940
+ .filter(Boolean);
941
+ if (!found.length) continue;
942
+ resourceType ??= String(found[0].operationId).split('_')[0];
943
+ actions[what] = [...new Set(found.map((o) => o.summary).filter(Boolean))].join(', ');
944
+ }
945
+ return resourceType ? { resource_type: resourceType, actions } : undefined;
946
+ }
947
+
948
+ /** resource_describe of a local type: everything it says comes from its definition. */
524
949
  async function describeLocal(name) {
525
950
  const t = local[name];
526
- const out = { type: name, description: expandNotes(t.description) };
527
- if (t.parent) out.parent = `${t.parent} (write it as "${parentOf(name)}")`;
528
- out.id = t.id ? idOf(name) : 'none: a single resource of its parent';
529
- out.operations = operationsOf(opsOf(name));
951
+ const out = { type: name, description: expand(t.description) };
952
+ out.ids = idsLine(name);
953
+ const rule = ownIdRule(name);
954
+ if (rule && !readOnly) out.own_id = rule;
955
+ out.operations = operationsOf(servedOps(name));
956
+ if (t.filter) out.filter = t.filter;
530
957
  if (t.schema) out.fields = t.schema;
531
- const actions = localActionsOf(name);
532
- if (Object.keys(actions).length) out.actions = describeActions(actions);
533
- if (t.notes) out.notes = expandNotes(t.notes);
958
+ if (Object.keys(allActionsOf(name)).length) out.actions = describeActions(name);
959
+ if (t.notes) out.notes = expand(t.notes);
960
+ if (t.local) out.runs = 'on the machine of this CLI';
961
+ if (hooks[name]?.describe) out.cli = await hooks[name].describe();
534
962
  if (t.describe) {
535
963
  try {
536
964
  Object.assign(out, await t.describe());
@@ -538,53 +966,81 @@ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: e
538
966
  out.reference = `unavailable: ${e instanceof Error ? e.message : String(e)}`;
539
967
  }
540
968
  }
541
- return Object.entries(out)
542
- .map(
543
- ([key, value]) =>
544
- `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`,
545
- )
546
- .join('\n');
969
+ const spec = await serverSpec().catch(() => ({ doc: null }));
970
+ if (spec.doc) {
971
+ const permissions = localPermissions(spec.doc, name);
972
+ if (permissions) out.permissions = permissions;
973
+ }
974
+ const lines = Object.entries(out).map(
975
+ ([key, value]) =>
976
+ `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`,
977
+ );
978
+ return `${lines.join('\n')}\nexamples:\n${exampleLines(examplesOf(name))}`;
979
+ }
980
+
981
+ /**
982
+ * The examples of the overview: the calls agents need most, taken from
983
+ * the types' own, and a property set.
984
+ */
985
+ function overviewExamples() {
986
+ const pick = (type, test) => examplesOf(type).find(test);
987
+ const picked = [
988
+ pick('bucket_data', (e) => e.tool === 'resource_list'),
989
+ pick('storage_file', (e) => e.tool === 'resource_create'),
990
+ pick('device_resource', (e) => e.tool === 'resource_call'),
991
+ pick('device', (e) => e.arguments.action === 'send'),
992
+ pick('dashboard', (e) => e.arguments.action === 'publish'),
993
+ pick('doc', (e) => e.tool === 'resource_get'),
994
+ {
995
+ tool: 'resource_update',
996
+ arguments: {
997
+ type: 'device_property',
998
+ ids: { device: 'boiler', property: 'setpoint' },
999
+ body: { value: { target: 21.5 } },
1000
+ },
1001
+ does: 'Set the value of a device property.',
1002
+ },
1003
+ ];
1004
+ return exampleLines(picked.filter((e) => e && allowedExample(e)));
547
1005
  }
548
1006
 
549
1007
  async function toolDescribe(args) {
550
- if (!args.type) {
551
- // The overview call: the types, plus the guide to the
1008
+ if (args.type === undefined || args.type === '') {
1009
+ // The overview call: the types, examples, and the guide to the
552
1010
  // platform's concepts.
553
1011
  const concepts = await readDoc('concepts').then(
554
1012
  (d) => d.text,
555
1013
  () => '',
556
1014
  );
557
- return text(
558
- concepts
559
- ? `${catalogSummary([...served])}\n\n---\n\n${concepts}`
560
- : catalogSummary([...served]),
561
- );
562
- }
563
- if (!served.has(args.type)) {
564
- throw inputError(`Unknown type "${args.type}".\n\n${catalogSummary([...served])}`);
1015
+ const examples = overviewExamples();
1016
+ const summary = `${catalogSummary([...served])}${examples ? `\n\nExamples, one call per line:\n${examples}` : ''}`;
1017
+ return text(concepts ? `${summary}\n\n---\n\n${concepts}` : summary);
565
1018
  }
1019
+ if (!served.has(args.type)) throw unknownType(args.type);
566
1020
  const name = args.type;
567
- if (own(local, name)) return text(capped(await describeLocal(name)));
1021
+ if (own(local, name)) return text(cap(name, undefined, await describeLocal(name)));
568
1022
  const type = CATALOG[name];
569
- const out = { type: name, description: expandNotes(type.description) };
570
- const chain = ancestry(name);
571
- if (chain.length) out.parent = `${chain.join(' > ')} (write it as "${parentHint(name)}")`;
572
- out.id = idNames(type).length ? idHint(name) : 'none: a single resource of its parent';
573
- out.operations = operationsOf(type.ops);
574
- const allActions = { ...(type.actions || {}), ...localActionsOf(name) };
575
- if (Object.keys(allActions).length) out.actions = describeActions(allActions);
576
- if (type.notes) out.notes = expandNotes(type.notes);
1023
+ const out = { type: name, description: expand(type.description) };
1024
+ out.ids = idsLine(name);
1025
+ const rule = ownIdRule(name);
1026
+ if (rule && !readOnly) out.own_id = rule;
1027
+ const verbs = servedOps(name);
1028
+ out.operations = operationsOf(verbs);
1029
+ const actions = describeActions(name);
1030
+ if (Object.keys(actions).length) out.actions = actions;
1031
+ if (type.notes) out.notes = expand(type.notes);
577
1032
  if (type.sdk) {
578
1033
  out.sdk = await readDoc('dashboard-sdk').then(
579
1034
  (d) => d.text,
580
1035
  (e) => `unavailable: ${e.message}`,
581
1036
  );
582
1037
  }
1038
+ if (hooks[name]?.describe) out.cli = await hooks[name].describe();
583
1039
 
584
1040
  const spec = await serverSpec();
585
1041
  if (spec.doc) {
586
1042
  for (const verb of /** @type {const} */ (['create', 'update', 'clone'])) {
587
- if (!(verb in type.ops)) continue;
1043
+ if (!(verb in verbs)) continue;
588
1044
  const { method, path } = templateFor(name, verb);
589
1045
  const body = bodyOf(spec.doc, findOperation(spec.doc, method, path)?.operation);
590
1046
  if (body) out[verb] = body;
@@ -592,28 +1048,35 @@ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: e
592
1048
  // What a token needs to allow each verb: the server names the
593
1049
  // action in each operation's summary, its ResourceType in the
594
1050
  // operationId prefix (Device_ReadStatus → Device:ReadDeviceStatus).
595
- const actions = {};
1051
+ const allowed = {};
596
1052
  let resourceType;
597
- for (const verb of Object.keys(type.ops)) {
1053
+ for (const verb of Object.keys(verbs)) {
598
1054
  const { method, path } = templateFor(name, /** @type {any} */ (verb));
599
1055
  const op = findOperation(spec.doc, method, path)?.operation;
600
1056
  if (!op?.summary) continue;
601
1057
  resourceType ??= String(op.operationId || '').split('_')[0];
602
- actions[verb] = op.summary;
1058
+ allowed[verb] = op.summary;
603
1059
  }
604
1060
  for (const [a] of Object.entries(type.actions || {})) {
1061
+ if (!actions[a]) continue;
605
1062
  const { method, path } = actionTemplate(name, a);
606
1063
  const op = findOperation(spec.doc, method, path)?.operation;
607
1064
  if (op?.summary) {
608
1065
  resourceType ??= String(op.operationId || '').split('_')[0];
609
- actions[a] = op.summary;
1066
+ allowed[a] = op.summary;
610
1067
  }
611
1068
  const body = bodyOf(spec.doc, op);
612
1069
  if (body && body.schema && JSON.stringify(body.schema) !== '{"type":"object"}') {
613
- out.actions[a].body = body;
1070
+ actions[a].body = body;
614
1071
  }
615
1072
  }
616
- if (resourceType) out.permissions = { resource_type: resourceType, actions };
1073
+ // The core's and the CLI's actions on it name their operations.
1074
+ const added = localPermissions(spec.doc, name);
1075
+ for (const [a, summary] of Object.entries(added?.actions || {})) {
1076
+ if (actions[a]) allowed[a] = summary;
1077
+ }
1078
+ resourceType ??= added?.resource_type;
1079
+ if (resourceType) out.permissions = { resource_type: resourceType, actions: allowed };
617
1080
  const read = 'get' in type.ops ? 'get' : /** @type {const} */ ('list');
618
1081
  if (read in type.ops) {
619
1082
  const { method, path } = templateFor(name, read);
@@ -624,22 +1087,37 @@ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: e
624
1087
  out.schema_source = spec.source;
625
1088
  // One line per key, schemas without indentation: half the size of
626
1089
  // pretty-printed JSON, and still easy to read.
627
- return text(
628
- Object.entries(shareSchemas(out))
629
- .map(
630
- ([key, value]) =>
631
- `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`,
632
- )
633
- .join('\n'),
1090
+ const lines = Object.entries(shareSchemas(out)).map(
1091
+ ([key, value]) =>
1092
+ `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`,
634
1093
  );
1094
+ return text(`${lines.join('\n')}\nexamples:\n${exampleLines(examplesOf(name))}`);
635
1095
  }
636
1096
 
637
1097
  async function toolList(args) {
638
- const { type, ctx } = prepare(args, 'list');
639
- if (own(local, type)) {
640
- const lctx = localContext(args, { needsId: false, state: ctx.state });
641
- const data = (await local[type].list?.(lctx)) ?? [];
642
- return text(capped(pageOf(type, data, args, true)));
1098
+ const { type, at, ctx } = prepare(args, 'list');
1099
+ // Records carry the ids to pass back; a person reading the CLI's
1100
+ // output does not need them.
1101
+ const shown = (record, key) => (args[CLI] ? record : withIds(type, record, at.ids, key));
1102
+ const mine = own(local, type);
1103
+ if (mine) {
1104
+ if (mine.filter) checkShape(args.filter, mine.filter, `filter of ${type}`);
1105
+
1106
+ if (mine.paged === false) {
1107
+ if (args.search !== undefined) {
1108
+ throw inputError(`${type} takes no search: narrow it with filter`);
1109
+ }
1110
+ if (args.index !== undefined) throw inputError(`${type} takes no index`);
1111
+ }
1112
+ const data = (await mine.list?.(localContext(args, at, { state: ctx.state }))) ?? [];
1113
+ if (mine.paged === false) {
1114
+ const said = Array.isArray(data)
1115
+ ? formatList(type, data, Infinity, shown)
1116
+ : (answer(data) ?? `No ${type} found.`);
1117
+ return text(cap(type, undefined, said), data);
1118
+ }
1119
+ const page = pageOf(type, data, args, true, shown);
1120
+ return text(cap(type, undefined, page.text), page.data);
643
1121
  }
644
1122
  const params = { ...(CATALOG[type].listQuery || {}), ...(args.filter || {}) };
645
1123
  // Search: the server matches `name` against ids and names (partial,
@@ -651,17 +1129,25 @@ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: e
651
1129
  }
652
1130
  if (args.count !== undefined) params.count = args.count;
653
1131
  if (args.index !== undefined) params.index = args.index;
654
- let data = await sendList(type, args, params);
1132
+ let data = await sendList(type, { ids: at.ids, user: args.user }, params);
655
1133
  const fields = CATALOG[type].listFields;
656
1134
  if (fields && Array.isArray(data)) data = data.map((record) => pick(record, fields));
657
- return text(pageOf(type, data, args, whole));
1135
+ // Maps (product profile categories): each entry under its id.
1136
+ if (data && typeof data === 'object' && !Array.isArray(data) && !args[CLI]) {
1137
+ const entries = Object.entries(data).map(([key, record]) => [key, shown(record, key)]);
1138
+ return text(JSON.stringify(Object.fromEntries(entries), null, 2), data);
1139
+ }
1140
+ const page = pageOf(type, data, args, whole, shown);
1141
+ return text(page.text, page.data);
658
1142
  }
659
1143
 
660
1144
  /**
661
1145
  * A list as resource_list answers it: searched here when the source
662
- * answers it whole, and paged here when it ignores count/index.
1146
+ * answers it whole, and paged here when it ignores count/index. `all`
1147
+ * (the CLI's, not a tool argument) takes every record. Returns the text
1148
+ * and the records shown.
663
1149
  */
664
- function pageOf(type, data, args, whole) {
1150
+ function pageOf(type, data, args, whole, shown) {
665
1151
  // Lists answered whole: every word must appear in what a record shows.
666
1152
  if (args.search && whole && Array.isArray(data)) {
667
1153
  const words = String(args.search).toLowerCase().split(/\s+/).filter(Boolean);
@@ -672,7 +1158,7 @@ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: e
672
1158
  }
673
1159
  // Some collections ignore count/index and answer everything: page
674
1160
  // them here so one call cannot flood the conversation.
675
- const pageSize = args.count ?? 50;
1161
+ const pageSize = args.count ?? (args.all ? Infinity : 50);
676
1162
  const from = args.index ?? 0;
677
1163
  if (Array.isArray(data) && (data.length > pageSize || (whole && from > 0))) {
678
1164
  const page = data.slice(from, from + pageSize);
@@ -680,116 +1166,211 @@ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: e
680
1166
  from + page.length < data.length
681
1167
  ? `; pass index: ${from + page.length} for more, or narrow it with filter`
682
1168
  : '';
683
- return `${formatList(type, page, Infinity)}\n(${data.length} in total, showing ${from}–${from + page.length - 1}${next})`;
1169
+ return {
1170
+ text: `${formatList(type, page, Infinity, shown)}\n(${data.length} in total, showing ${from}–${from + page.length - 1}${next})`,
1171
+ data: page,
1172
+ };
684
1173
  }
685
- return formatList(type, data, whole ? Infinity : pageSize);
1174
+ return { text: formatList(type, data, whole ? Infinity : pageSize, shown), data };
686
1175
  }
687
1176
 
1177
+ /** A plain record: what `ids` can go in front of. */
1178
+ const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
1179
+
688
1180
  async function toolGet(args) {
689
- const { type, ctx, hook } = prepare(args, 'get');
1181
+ const { type, at, ctx, hook } = prepare(args, 'get');
690
1182
  const mine = own(local, type);
691
1183
  let record = mine
692
- ? await mine.get?.(localContext(args, { state: ctx.state }))
693
- : await send(type, 'get', args);
1184
+ ? await mine.get?.(localContext(args, at, { state: ctx.state }))
1185
+ : await send(type, 'get', { ids: at.ids, user: args.user });
694
1186
  if (hook.afterGet) record = await hook.afterGet(record, ctx);
695
- if (!mine) return text(JSON.stringify(record, null, 2));
696
- return text(capped(answer(record) ?? `No ${label(type, args)}.`));
1187
+ // The ids it was read with are the ones to pass back.
1188
+ const shown = isRecord(record) && !args[CLI] ? { ids: at.ids, ...record } : record;
1189
+ if (!mine) return text(JSON.stringify(shown, null, 2), record ?? null);
1190
+ return text(
1191
+ cap(type, undefined, answer(shown) ?? `No ${label(type, at.ids)}.`),
1192
+ record ?? null,
1193
+ );
697
1194
  }
698
1195
 
1196
+ /** An error of the server on a create or update, with where to look when it is the body. */
1197
+ const withFields = (type) => (e) => {
1198
+ if (e?.status === 400) {
1199
+ e.message += `. ${toolName('resource_describe')} type "${type}" shows the fields and an example`;
1200
+ }
1201
+ throw e;
1202
+ };
1203
+
699
1204
  async function toolCreate(args) {
700
- const { type, ctx, hook } = prepare(args, 'create');
701
- if (own(local, type)) return createLocal(args, ctx, hook);
1205
+ const { type, at, ctx, hook } = prepare(args, 'create');
1206
+ if (own(local, type)) return createLocal(args, at, ctx, hook);
702
1207
  const t = CATALOG[type];
703
1208
  let body = bodyArg(args, 'create');
704
1209
  // `projects` is not a field the server takes on create: set it right
705
1210
  // after, as update does, so one call leaves the resource in place.
706
1211
  let projects;
707
- if (body && typeof body === 'object' && !Array.isArray(body) && 'projects' in body) {
1212
+ if (isRecord(body) && 'projects' in body) {
708
1213
  ({ projects, ...body } = body);
709
1214
  if (!Array.isArray(projects)) {
710
1215
  throw inputError('projects must be an array of "<owner>@<project>"');
711
1216
  }
712
1217
  if (!('projects' in t.ops)) throw inputError(`${type} cannot belong to projects`);
713
1218
  }
1219
+ const key = idKey(type);
1220
+ const field = idField(t);
1221
+ let ownId = at.id;
1222
+ // An entry stored under its name may name it in the body instead.
1223
+ if (t.upsert && ownId === undefined) ownId = bodyIdOf(t, body);
714
1224
  // The id can come apart from the body: copy it into the body's id
715
- // fields when the server takes it there (POST), unless already set.
716
- if (!t.upsert && args.id !== undefined && !Array.isArray(body)) {
717
- const fields = t.bodyId ?? idNames(t);
718
- if (!fields.length) throw inputError(`${type} ids are generated by the server`);
719
- const ids = String(args.id).split('/');
720
- if (ids.length !== fields.length) throw inputError(`id must be ${idHint(type)}`);
721
- body = { ...Object.fromEntries(fields.map((f, i) => [f, ids[i]])), ...body };
1225
+ // field when the server takes it there (POST).
1226
+ if (!t.upsert && ownId !== undefined) {
1227
+ if (!field) {
1228
+ throw inputError(
1229
+ `${type} ids are generated by the server: leave ${key} out of ids`,
1230
+ );
1231
+ }
1232
+ if (!isRecord(body)) {
1233
+ throw inputError(
1234
+ `an array creates several ${type}, each with its own ${field}: leave ${key} out of ids`,
1235
+ );
1236
+ }
1237
+ if (body[field] !== undefined && String(body[field]) !== ownId) {
1238
+ throw inputError(`ids.${key} and body.${field} differ`);
1239
+ }
1240
+ body = { [field]: ownId, ...body };
722
1241
  }
723
1242
  if (hook.beforeCreate) body = await hook.beforeCreate(body, ctx);
724
- const createdId = args.id ?? bodyIdOf(t, body);
725
- if (projects && createdId === undefined) {
726
- const where = (t.bodyId ?? idNames(t)).map((f) => `"${f}"`).join(' and ');
1243
+ if (!t.upsert) ownId ??= bodyIdOf(t, body);
1244
+ if (projects && ownId === undefined) {
727
1245
  throw inputError(
728
- `give the ${type} id to set its projects on create: \`id\`, or the body field ${where}`,
1246
+ `give the ${type} id to set its projects on create: ids.${key}, or the body field "${field}"`,
729
1247
  );
730
1248
  }
731
- const result = await send(type, 'create', args, { data: body });
732
- const lines = [`Created ${label(type, { parent: args.parent, id: createdId })}.`];
1249
+ const parents = parentIdsOf(type, at.ids);
1250
+ const target = t.upsert
1251
+ ? { ...parents, ...(ownId !== undefined ? { [key]: ownId } : {}) }
1252
+ : parents;
1253
+ const result = await send(
1254
+ type,
1255
+ 'create',
1256
+ { ids: target, user: args.user },
1257
+ { data: body },
1258
+ ).catch(withFields(type));
1259
+ // Ids the server generates are read from what it answers.
1260
+ const madeId = ownId ?? (key ? recordIds(type, result, parents)?.[key] : undefined);
1261
+ const ids =
1262
+ Array.isArray(body) || !hasOwnId(type)
1263
+ ? parents
1264
+ : madeId !== undefined
1265
+ ? { ...parents, [key]: madeId }
1266
+ : undefined;
1267
+ const lines = [`Created ${label(type, ids ?? parents)}.`];
733
1268
  if (projects) {
734
- await send(type, 'projects', { ...args, id: createdId }, { data: projects });
1269
+ await send(type, 'projects', { ids, user: args.user }, { data: projects });
735
1270
  lines[0] = lines[0].replace(/\.$/, ` in projects ${JSON.stringify(projects)}.`);
736
1271
  }
1272
+ if (ids && !args[CLI] && !Array.isArray(body)) lines.push(`ids: ${JSON.stringify(ids)}`);
737
1273
  if (result && typeof result === 'object' && Object.keys(result).length) {
738
1274
  lines.push(JSON.stringify(result, null, 2));
739
1275
  }
740
1276
  const note = hook.afterCreate && (await hook.afterCreate(result, ctx));
741
1277
  if (note) lines.push('', note);
742
- return text(lines.join('\n'));
1278
+ return text(lines.join('\n'), created(ownId, result, note, projects));
743
1279
  }
744
1280
 
745
- async function createLocal(args, ctx, hook) {
746
- const t = local[args.type];
747
- let body = bodyArg(args, 'create');
748
- if (Array.isArray(body)) throw inputError('body must be a JSON object');
749
- if (t.id && args.id !== undefined && body[t.id] === undefined)
750
- body = { [t.id]: args.id, ...body };
1281
+ /** What a create answers with --json: the id, the server's answer, the hook's note. */
1282
+ function created(id, result, note, projects) {
1283
+ const out = { id: id ?? null };
1284
+ if (projects) out.projects = projects;
1285
+ if (result !== undefined && result !== null && result !== '') out.result = result;
1286
+ if (note) out.note = note;
1287
+ return out;
1288
+ }
1289
+
1290
+ /** A body checked against the schema of a local type's records. */
1291
+ function localBody(type, body) {
1292
+ const schema = local[type].schema;
1293
+ if (schema) checkShape(body, schema, `body of ${type}`);
1294
+ else if (!isRecord(body)) throw inputError('body must be a JSON object');
1295
+ return body;
1296
+ }
1297
+
1298
+ async function createLocal(args, at, ctx, hook) {
1299
+ const type = args.type;
1300
+ const t = local[type];
1301
+ let body = localBody(type, bodyArg(args, 'create'));
1302
+ const field = t.bodyId;
1303
+ if (field && isRecord(body)) {
1304
+ if (at.id !== undefined && body[field] !== undefined && String(body[field]) !== at.id) {
1305
+ throw inputError(`ids.${t.id} and body.${field} differ`);
1306
+ }
1307
+ if (at.id !== undefined && body[field] === undefined)
1308
+ body = { [field]: at.id, ...body };
1309
+ }
751
1310
  // The hook may set or normalise the id: read it after.
752
1311
  if (hook.beforeCreate) body = await hook.beforeCreate(body, ctx);
753
- const id = args.id ?? (t.id ? body?.[t.id] : undefined);
754
- if (t.id && (id === undefined || id === null || id === '')) {
755
- throw inputError(`give the ${args.type} id: \`id\`, or the body field "${t.id}"`);
1312
+ let id = at.id ?? (field && isRecord(body) ? body[field] : undefined);
1313
+ if (id === null || id === '') id = undefined;
1314
+ if (t.id && id === undefined && !t.optionalId?.includes('create')) {
1315
+ throw inputError(
1316
+ `missing ${t.id}: give it in ids.${t.id}${field ? ` or in the body field "${field}"` : ''}`,
1317
+ );
756
1318
  }
757
- ctx.id = id;
758
- const lctx = localContext({ ...args, id }, { state: ctx.state });
759
- const result = await t.create?.(body, lctx);
760
- const lines = [`Created ${label(args.type, { parent: args.parent, id })}.`];
1319
+ const ids = id !== undefined ? { ...at.ids, [t.id]: String(id) } : at.ids;
1320
+ ctx.id = id === undefined ? undefined : String(id);
1321
+ ctx.ids = ids;
1322
+ const result = await t.create?.(
1323
+ body,
1324
+ localContext(args, { ...at, id: ctx.id, ids }, { state: ctx.state }),
1325
+ );
1326
+ // Without an id of its own it is data written, not a record made.
1327
+ const lines = [
1328
+ t.id && id !== undefined
1329
+ ? `Created ${label(type, ids)}.`
1330
+ : `Wrote to ${label(type, at.ids)}.`,
1331
+ ];
1332
+ if (t.id && id !== undefined && !args[CLI]) lines.push(`ids: ${JSON.stringify(ids)}`);
761
1333
  const said = answer(result);
762
1334
  if (said) lines.push(said);
763
1335
  const note = hook.afterCreate && (await hook.afterCreate(result, ctx));
764
1336
  if (note) lines.push('', note);
765
- return text(lines.join('\n'));
1337
+ return text(cap(type, undefined, lines.join('\n')), created(id, result, note));
766
1338
  }
767
1339
 
768
1340
  async function toolUpdate(args) {
1341
+ if (typeof args.type !== 'string' || !served.has(args.type)) throw unknownType(args.type);
769
1342
  /** @type {import('./catalog.js').Verb} */
770
1343
  const verb = args.merge ? 'patch' : 'update';
771
1344
  if (own(local, args.type)) {
772
- const { type, ctx, hook } = prepare(args, 'update');
773
- let body = bodyArg(args, 'update');
774
- if (Array.isArray(body)) throw inputError('body must be a JSON object');
1345
+ const { type, at, ctx, hook } = prepare(args, 'update');
1346
+ let body = localBody(type, bodyArg(args, 'update'));
775
1347
  if (hook.beforeUpdate) body = await hook.beforeUpdate(body, ctx);
776
1348
  const result = await local[type].update?.(
777
1349
  body,
778
- localContext(args, { state: ctx.state }),
1350
+ localContext(args, at, { state: ctx.state }),
779
1351
  );
780
1352
  const said = answer(result);
1353
+ const keys = isRecord(body) ? Object.keys(body) : [];
781
1354
  return text(
782
- `Updated ${label(type, args)}: ${Object.keys(body).join(', ')}.${said ? `\n${said}` : ''}`,
1355
+ cap(
1356
+ type,
1357
+ undefined,
1358
+ `Updated ${label(type, at.ids)}: ${keys.join(', ')}.${said ? `\n${said}` : ''}`,
1359
+ ),
1360
+ { id: at.id ?? null, fields: keys, ...(said ? { result } : {}) },
783
1361
  );
784
1362
  }
785
- const { projects, ...fields } = bodyArg(args, 'update');
786
- const { type, ctx, hook } = prepare(args, projects !== undefined ? 'projects' : verb);
1363
+ const all = bodyArg(args, 'update');
1364
+ if (!isRecord(all)) throw inputError('body must be a JSON object');
1365
+ const { projects, ...fields } = all;
1366
+ const { type, at, ctx, hook } = prepare(args, projects !== undefined ? 'projects' : verb);
1367
+ const where = { ids: at.ids, user: args.user };
787
1368
  const done = [];
788
1369
  if (projects !== undefined) {
789
1370
  if (!Array.isArray(projects)) {
790
1371
  throw inputError('projects must be an array of "<owner>@<project>"');
791
1372
  }
792
- await send(type, 'projects', args, { data: projects });
1373
+ await send(type, 'projects', where, { data: projects });
793
1374
  done.push(`projects set to ${JSON.stringify(projects)}`);
794
1375
  }
795
1376
  if (Object.keys(fields).length) {
@@ -799,40 +1380,49 @@ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: e
799
1380
  // keep the fields left out by sending them from the saved entry.
800
1381
  const t = CATALOG[type];
801
1382
  if (verb === 'update' && t.upsert && t.ops.get !== t.ops.list) {
802
- const saved = await send(type, 'get', args);
803
- if (saved && typeof saved === 'object' && !Array.isArray(saved)) {
804
- body = { ...saved, ...fields };
805
- }
1383
+ const saved = await send(type, 'get', where);
1384
+ if (isRecord(saved)) body = { ...saved, ...fields };
806
1385
  }
807
1386
  if (hook.beforeUpdate) body = await hook.beforeUpdate(body, ctx);
808
- await send(type, verb, args, { data: body });
1387
+ await send(type, verb, where, { data: body }).catch(withFields(type));
809
1388
  done.push(`${args.merge ? 'merged' : 'updated'} ${Object.keys(fields).join(', ')}`);
810
1389
  }
811
- return text(`Updated ${label(type, args)}: ${done.join('; ')}.`);
1390
+ return text(`Updated ${label(type, at.ids)}: ${done.join('; ')}.`, {
1391
+ id: at.id ?? null,
1392
+ fields: Object.keys(fields),
1393
+ ...(projects !== undefined ? { projects } : {}),
1394
+ ...(args.merge ? { merged: true } : {}),
1395
+ });
812
1396
  }
813
1397
 
814
1398
  async function toolDelete(args) {
815
- const { type, ctx, hook } = prepare(args, 'delete');
1399
+ const { type, at, ctx, hook } = prepare(args, 'delete');
816
1400
  const mine = own(local, type);
817
1401
  const result = mine
818
- ? await mine.delete?.(localContext(args, { state: ctx.state }))
819
- : await send(type, 'delete', args);
820
- const lines = [`Deleted ${label(type, args)}.`];
1402
+ ? await mine.delete?.(localContext(args, at, { state: ctx.state }))
1403
+ : await send(type, 'delete', { ids: at.ids, user: args.user });
1404
+ const lines = [`Deleted ${label(type, at.ids)}.`];
821
1405
  const said = mine && answer(result);
822
1406
  if (said) lines.push(said);
823
1407
  const note = hook.afterDelete && (await hook.afterDelete(ctx));
824
1408
  if (note) lines.push(note);
825
- return text(lines.join('\n'));
1409
+ return text(lines.join('\n'), { id: at.id ?? null, ...(note ? { note } : {}) });
826
1410
  }
827
1411
 
828
1412
  async function toolClone(args) {
829
- const { type } = prepare(args, 'clone');
830
- const body = bodyArg(args, 'clone');
831
- await send(type, 'clone', args, { data: body });
832
- const copy = bodyIdOf(CATALOG[type], body);
833
- return text(
834
- `Cloned ${label(type, args)} as ${copy ? label(type, { parent: args.parent, id: copy }) : JSON.stringify(body)}.`,
835
- );
1413
+ const { type, at } = prepare(args, 'clone');
1414
+ const t = CATALOG[type];
1415
+ let body = bodyArg(args, 'clone');
1416
+ if (!isRecord(body)) throw inputError('body must be a JSON object');
1417
+ const key = /** @type {string} */ (idKey(type));
1418
+ const field = idField(t) ?? key;
1419
+ const copy = bodyIdOf(t, body);
1420
+ if (copy === undefined) throw inputError(`clone needs the new id in body.${field}`);
1421
+ await send(type, 'clone', { ids: at.ids, user: args.user }, { data: body });
1422
+ const ids = { ...parentIdsOf(type, at.ids), [key]: copy };
1423
+ const lines = [`Cloned ${label(type, at.ids)} as ${label(type, ids)}.`];
1424
+ if (!args[CLI]) lines.push(`ids: ${JSON.stringify(ids)}`);
1425
+ return text(lines.join('\n'), { id: at.id ?? null, copy });
836
1426
  }
837
1427
 
838
1428
  /**
@@ -853,245 +1443,559 @@ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: e
853
1443
  return `${lines[lines.length - 1]}\n(${lines.length} progress updates, ${(ms / 1000).toFixed(1)} s)`;
854
1444
  };
855
1445
 
856
- async function toolCall(args) {
1446
+ /** The bundled OpenAPI document, read once: it checks the bodies of catalog actions. */
1447
+ let bundled;
1448
+ const bundledDoc = () => {
1449
+ if (bundled === undefined) {
1450
+ try {
1451
+ bundled = bundledSpec();
1452
+ } catch {
1453
+ bundled = null;
1454
+ }
1455
+ }
1456
+ return bundled;
1457
+ };
1458
+
1459
+ /** A catalog action's body against its documented schema: required keys and types only. */
1460
+ function checkActionBody(type, action, body) {
1461
+ const doc = bundledDoc();
1462
+ if (!doc || body === undefined) return;
1463
+ const { method, path } = actionTemplate(type, action);
1464
+ const op = findOperation(doc, method, path)?.operation;
1465
+ const schema = op?.requestBody?.content?.['application/json']?.schema;
1466
+ if (schema)
1467
+ checkShape(body, resolveRefs(doc, schema), `body of ${type}.${action}`, {
1468
+ unknown: false,
1469
+ });
1470
+ }
1471
+
1472
+ /** "a, b and c", or "none". */
1473
+ const some = (words) => (words.length ? listed(words) : 'none');
1474
+
1475
+ /**
1476
+ * What a catalog action that asks first would do, as concrete as a
1477
+ * read can make it: the entries a replace_all removes, the settings it
1478
+ * changes.
1479
+ */
1480
+ async function planOf(type, action, spec, at, body, user) {
1481
+ const where = { ids: at.ids, user };
1482
+ const target = label(type, at.ids);
1483
+ if (action === 'replace_all') {
1484
+ const now = await send(type, 'list', where).catch(() => undefined);
1485
+ const before = isRecord(now) ? Object.keys(now) : [];
1486
+ const after = isRecord(body) ? Object.keys(body) : [];
1487
+ return `It would replace the ${before.length} ${type} entries with ${after.length}: removes ${some(before.filter((k) => !after.includes(k)))}; adds ${some(after.filter((k) => !before.includes(k)))}; overwrites ${some(after.filter((k) => before.includes(k)))}.`;
1488
+ }
1489
+ if (action === 'update_settings' && own(allActionsOf(type), 'read_settings')) {
1490
+ const read = actionRequest(type, 'read_settings', where);
1491
+ const now = await api
1492
+ .request({ method: read.method, url: read.url })
1493
+ .then((r) => r.data)
1494
+ .catch(() => undefined);
1495
+ if (isRecord(now) && isRecord(body)) {
1496
+ // The server merges the body (JSON merge-patch): keys left
1497
+ // out stay, a null removes one.
1498
+ const removed = Object.keys(body).filter((k) => body[k] === null && k in now);
1499
+ const changed = Object.keys(body).filter(
1500
+ (k) => body[k] !== null && JSON.stringify(now[k]) !== JSON.stringify(body[k]),
1501
+ );
1502
+ return `It would patch the settings of ${target}: changes ${some(changed)}; removes ${some(removed)}; the rest stays.`;
1503
+ }
1504
+ }
1505
+ if (action === 'test_notification') {
1506
+ const rule = await send(type, 'get', where).catch(() => undefined);
1507
+ const to = isRecord(rule)
1508
+ ? (rule.notification ?? rule.notifications ?? rule.endpoints)
1509
+ : undefined;
1510
+ if (to !== undefined) {
1511
+ return `It would send the notification of ${target} for real, to: ${JSON.stringify(to)}.`;
1512
+ }
1513
+ }
1514
+ return `It would run ${action} on ${target}: ${expand(spec.description)}`;
1515
+ }
1516
+
1517
+ /**
1518
+ * An action that asks first and was not confirmed: ask the user when the
1519
+ * client can (elicitation), or answer the plan. Returns true to go on.
1520
+ *
1521
+ * @param {string} plan
1522
+ * @param {ToolExtra} [extra]
1523
+ */
1524
+ async function ask(plan, extra) {
1525
+ if (!extra?.elicit) return false;
1526
+ try {
1527
+ return await extra.elicit(plan);
1528
+ } catch {
1529
+ return false;
1530
+ }
1531
+ }
1532
+
1533
+ /** The answer of an action that asked first and was not done. */
1534
+ const planned = (plan, extra) =>
1535
+ text(
1536
+ extra?.elicit
1537
+ ? `${plan}\nThe user did not confirm it: nothing was done.`
1538
+ : `${plan}\nNothing was done. ${CONFIRM_HINT}`,
1539
+ { plan },
1540
+ );
1541
+
1542
+ /**
1543
+ * @param {Record<string, any>} args
1544
+ * @param {ToolExtra} [extra]
1545
+ */
1546
+ async function toolCall(args, extra) {
857
1547
  const type = args.type;
858
- const known = typeof type === 'string' && served.has(type);
859
- const mine = known ? own(localActionsOf(type), args.action) : undefined;
860
- if (mine) {
861
- const on = mine.on || 'item';
862
- if (on === 'collection' && args.id !== undefined) {
863
- throw inputError(`${args.action} takes no id`);
1548
+ if (typeof type !== 'string' || !served.has(type)) throw unknownType(type);
1549
+ const names = actionNames(type);
1550
+ if (!names.length) {
1551
+ throw inputError(
1552
+ `Type "${type}" has no actions${readOnly ? ' served read-only' : ''}. Types with actions: ${actionTypes().join(', ')}.`,
1553
+ );
1554
+ }
1555
+ const spec = own(allActionsOf(type), args.action);
1556
+ if (!spec || (readOnly && !spec.readOnly)) {
1557
+ throw inputError(
1558
+ `Type "${type}" has no action "${args.action ?? ''}"${spec ? ' served read-only' : ''} (it has: ${names.join(', ')})`,
1559
+ );
1560
+ }
1561
+ const on = spec.on || 'item';
1562
+ const at = where(
1563
+ type,
1564
+ args,
1565
+ on === 'collection' ? 'forbidden' : on === 'either' ? 'optional' : 'required',
1566
+ (key) => `${args.action} acts on every ${type}: leave ${key} out of ids`,
1567
+ );
1568
+ let body = args.body;
1569
+ if (spec.bodyRequired && (body === undefined || body === null)) {
1570
+ throw inputError(
1571
+ `${type}.${args.action} needs a body: ${expand(spec.description)} (${toolName('resource_describe')} type "${type}" shows it)`,
1572
+ );
1573
+ }
1574
+ // Actions that ask first: body.confirm is the answer, not their input.
1575
+ let confirmed = false;
1576
+ if (spec.confirm && isRecord(body) && 'confirm' in body) {
1577
+ const { confirm, ...rest } = body;
1578
+ confirmed = confirm === true;
1579
+ body = rest;
1580
+ }
1581
+ const ran = `Ran ${args.action} on ${label(type, at.ids)}.`;
1582
+
1583
+ if (typeof spec.run === 'function') {
1584
+ // A refused input: show a call that works, when the action has one.
1585
+ const withExample = (e) => {
1586
+ const ex = spec.examples?.[0];
1587
+ if (e?.code === 'input_error' && ex && !e.message.includes('\nExample: ')) {
1588
+ e.message += `\nExample: ${toolName(ex.tool)} ${JSON.stringify(ex.arguments)} (${ex.does})`;
1589
+ }
1590
+ return e;
1591
+ };
1592
+ const state = {};
1593
+ const run = (ok) => spec.run(body, localContext(args, at, { state, confirmed: ok }));
1594
+ let result;
1595
+ try {
1596
+ // An id in the body (device_exec's old `device`): it goes in
1597
+ // ids, unless the action takes none.
1598
+ if (
1599
+ on !== 'collection' &&
1600
+ isRecord(body) &&
1601
+ spec.body?.properties &&
1602
+ spec.body.additionalProperties !== true
1603
+ ) {
1604
+ const key = levelsOf(type)
1605
+ .map((l) => l.key)
1606
+ .find((k) => k in body && !Object.hasOwn(spec.body.properties, k));
1607
+ if (key) {
1608
+ throw inputError(
1609
+ `body of ${type}.${args.action} has no "${key}": pass the ${key} in ids ({"${key}": ${JSON.stringify(body[key])}})`,
1610
+ );
1611
+ }
1612
+ }
1613
+ const required = spec.body?.required?.length;
1614
+ checkShape(
1615
+ body ?? (required ? {} : undefined),
1616
+ spec.body,
1617
+ `body of ${type}.${args.action}`,
1618
+ );
1619
+ result = await run(confirmed);
1620
+ if (result && typeof result === 'object' && PLAN in result) {
1621
+ if (!(await ask(result[PLAN], extra))) return planned(result[PLAN], extra);
1622
+ result = await run(true);
1623
+ }
1624
+ } catch (e) {
1625
+ throw withExample(e);
864
1626
  }
865
- const ctx = localContext(args, { needsId: on === 'item' });
866
- const result = await mine.run(args.body, ctx);
1627
+
867
1628
  if (isToolResult(result)) {
868
1629
  return {
869
1630
  ...result,
870
1631
  content: result.content.map((c) =>
871
- c.type === 'text' ? { ...c, text: capped(c.text) } : c,
1632
+ c.type === 'text' ? { ...c, text: cap(type, args.action, c.text) } : c,
872
1633
  ),
873
1634
  };
874
1635
  }
875
1636
  const said = answer(result);
876
- return text(capped(said ?? `Ran ${args.action} on ${label(type, args)}.`));
877
- }
878
- if (!known || !actionNames(type).length) {
879
- throw inputError(
880
- `Type "${type ?? ''}" has no actions. ${toolName('resource_describe')} lists them after |.`,
881
- );
1637
+ return text(cap(type, args.action, said ?? ran), result ?? null);
882
1638
  }
883
- if (!own(CATALOG[type]?.actions, args.action)) {
884
- throw inputError(
885
- `Type "${type}" has no action "${args.action}" (it has: ${actionNames(type).join(', ')})`,
886
- );
1639
+
1640
+ checkActionBody(type, args.action, body);
1641
+ const { method, url, query } = actionRequest(type, args.action, {
1642
+ ids: at.ids,
1643
+ user: args.user,
1644
+ });
1645
+ if (spec.confirm && !confirmed) {
1646
+ const plan = await planOf(type, args.action, spec, at, body, args.user);
1647
+ if (!(await ask(plan, extra))) return planned(plan, extra);
887
1648
  }
888
- const { method, url, query } = actionRequest(type, args.action, args);
889
- const spec = CATALOG[type].actions?.[args.action];
890
1649
  const started = Date.now();
891
1650
  try {
892
1651
  // Actions that evaluate a resource (test_rule) take the saved one
893
1652
  // when no body is given.
894
- const body =
895
- args.body === undefined && spec?.bodyFromRecord
896
- ? pick(await send(type, 'get', args), spec.bodyFromRecord)
897
- : args.body;
1653
+ const data =
1654
+ body === undefined && spec.bodyFromRecord
1655
+ ? pick(
1656
+ await send(type, 'get', { ids: at.ids, user: args.user }),
1657
+ spec.bodyFromRecord,
1658
+ )
1659
+ : body;
898
1660
  const res = await api.request({
899
1661
  method,
900
1662
  url,
901
- data: method === 'get' ? undefined : (body ?? {}),
1663
+ data: method === 'get' ? undefined : (data ?? {}),
902
1664
  params: { ...(args.query || {}), ...(query || {}) },
903
1665
  paramsSerializer: { indexes: null },
904
1666
  timeout: TIMEOUTS.API_LONG_MS,
905
1667
  });
906
- const data = res.data;
907
- if (data === undefined || data === null || data === '') {
908
- return text(`Ran ${args.action} on ${label(type, args)}.`);
909
- }
910
- if (typeof data !== 'string') return text(JSON.stringify(data, null, 2));
911
- return text(lastProgress(data, Date.now() - started) ?? data);
1668
+ const answered = res.data;
1669
+ if (answered === undefined || answered === null || answered === '')
1670
+ return text(ran, null);
1671
+ if (typeof answered !== 'string')
1672
+ return text(JSON.stringify(answered, null, 2), answered);
1673
+ return text(lastProgress(answered, Date.now() - started) ?? answered, answered);
912
1674
  } catch (e) {
913
- const err = apiError(e, { notFound: `Not found: ${label(type, args)}` });
1675
+ const err = apiError(e, { notFound: `Not found: ${label(type, at.ids)}` });
914
1676
  // A 400 on an action called without a body is nearly always the
915
1677
  // missing body: say what it takes instead of leaving the guess.
916
- if (e?.response?.status === 400 && args.body === undefined && method !== 'get') {
917
- err.message += `. ${args.action} takes a body: ${expandNotes(spec?.description || '')} (${toolName('resource_describe')} ${type} shows its schema)`;
1678
+ if (e?.response?.status === 400 && body === undefined && method !== 'get') {
1679
+ err.message += `. ${args.action} takes a body: ${expand(spec.description || '')} (${toolName('resource_describe')} type "${type}" shows its schema)`;
918
1680
  }
919
1681
  throw err;
920
1682
  }
921
1683
  }
922
1684
 
1685
+ /**
1686
+ * Where a key the tool does not take belongs, when it is a filter, body
1687
+ * or id key of the type: the model put it one level too high.
1688
+ *
1689
+ * @param {string} base Bare tool name.
1690
+ * @param {Record<string, any>} args
1691
+ * @param {string[]} unknown
1692
+ */
1693
+ function explainUnknown(base, args, unknown) {
1694
+ const type = typeof args.type === 'string' && served.has(args.type) ? args.type : undefined;
1695
+ if (!type) return undefined;
1696
+ const mine = own(local, type);
1697
+ const ids = new Set(levelsOf(type).map((l) => l.key));
1698
+ const bodyKeys = new Set();
1699
+ const doc = bundledDoc();
1700
+ if (base === 'resource_call') {
1701
+ const spec = own(allActionsOf(type), args.action);
1702
+ Object.keys(spec?.body?.properties || {}).forEach((k) => bodyKeys.add(k));
1703
+ if (spec && !spec.run && doc && CATALOG[type]?.actions?.[args.action]) {
1704
+ const { method, path } = actionTemplate(type, args.action);
1705
+ const op = findOperation(doc, method, path)?.operation;
1706
+ const schema = op?.requestBody?.content?.['application/json']?.schema;
1707
+ Object.keys(schema ? resolveRefs(doc, schema).properties || {} : {}).forEach((k) =>
1708
+ bodyKeys.add(k),
1709
+ );
1710
+ }
1711
+ } else if (BODY_VERBS.has(base.replace('resource_', ''))) {
1712
+ const verb = /** @type {any} */ (base.replace('resource_', ''));
1713
+ if (mine) Object.keys(mine.schema?.properties || {}).forEach((k) => bodyKeys.add(k));
1714
+ else if (doc && verb in CATALOG[type].ops) {
1715
+ const { method, path } = templateFor(type, verb);
1716
+ const schema = findOperation(doc, method, path)?.operation?.requestBody?.content?.[
1717
+ 'application/json'
1718
+ ]?.schema;
1719
+ Object.keys(schema ? resolveRefs(doc, schema).properties || {} : {}).forEach((k) =>
1720
+ bodyKeys.add(k),
1721
+ );
1722
+ }
1723
+ }
1724
+ const value = (k) => JSON.stringify(args[k]);
1725
+ const hints = unknown.map((k) => {
1726
+ if (ids.has(k)) return `\`${k}\` goes in ids: ids: {"${k}": ${value(k)}}`;
1727
+ if (base === 'resource_list' && (mine ? mine.filter?.properties?.[k] : true)) {
1728
+ return `\`${k}\` goes in filter for type ${type}: filter: {"${k}": ${value(k)}}`;
1729
+ }
1730
+ if (bodyKeys.has(k)) return `\`${k}\` goes in body: body: {"${k}": ${value(k)}}`;
1731
+ return undefined;
1732
+ });
1733
+ return hints.filter(Boolean).join('; ') || undefined;
1734
+ }
1735
+
923
1736
  const typeParam = (verb) => ({
924
1737
  type: 'string',
925
1738
  enum: verb ? namesWith(verb) : [...served],
926
1739
  description: 'Resource type (see {tool:resource_describe}).',
927
1740
  });
928
- const common = {
929
- parent: {
930
- type: 'string',
931
- description:
932
- 'Parent id, for child types: the device of a device_property, "<device>/<bucket>" for a device_bucket_tag.',
933
- },
934
- user: {
935
- type: 'string',
936
- description: 'Account to act on (admin only). Omit for your own.',
937
- },
1741
+ const user = {
1742
+ type: 'string',
1743
+ description: 'Account to act on (admin only). Omit for your own.',
1744
+ };
1745
+ const ids = {
1746
+ type: 'object',
1747
+ additionalProperties: { type: 'string' },
1748
+ description:
1749
+ 'Ids of the record and of its parents, each under its name: {"device": "boiler"}; {"device": "boiler", "property": "setpoint"}; {"device": "boiler", "bucket": "temps", "tag": "location"}. {tool:resource_describe} gives the names of each type. Leave out the record\'s own id to list, to create with the id in the body, or for actions on the whole collection.',
938
1750
  };
939
- const id = { type: 'string', description: 'Resource id.' };
940
1751
  const body = (description) => ({ type: 'object', description });
1752
+ const actionEnum = [...new Set(actionTypes().flatMap((n) => actionNames(n)))].sort();
1753
+
1754
+ /** The actions this CLI adds, one line each, for resource_call's description. */
1755
+ /** @type {Array<[string, string, LocalAction]>} */
1756
+ const cliAdded = Object.entries(cliActions)
1757
+ .flatMap(([type, list]) =>
1758
+ Object.entries(list).map(([a, spec]) => /** @type {any} */ ([type, a, spec])),
1759
+ )
1760
+ .concat(
1761
+ Object.entries(cliTypes).flatMap(([type, t]) =>
1762
+ Object.entries(t.actions || {}).map(
1763
+ ([a, spec]) => /** @type {any} */ ([type, a, spec]),
1764
+ ),
1765
+ ),
1766
+ );
1767
+ const added = cliAdded
1768
+ .filter(([, , spec]) => !readOnly || spec.readOnly)
1769
+ .map(
1770
+ ([type, a, spec]) =>
1771
+ `\n- type "${type}", action "${a}": ${expand(spec.description).split(/(?<=\.)\s/)[0]}`,
1772
+ )
1773
+ .join('');
941
1774
 
942
- return [
1775
+ /** @type {Array<Record<string, any>>} */
1776
+ const tools = [
943
1777
  {
944
1778
  name: 'resource_describe',
945
1779
  description:
946
- "Describe a resource type: its parent and id, the operations it supports and, from this server's API, the request schemas and examples for create, update and clone. Call it before writing a type for the first time. Without `type`: all types and a guide to the platform's concepts (start here).",
1780
+ "Describe a resource type: its ids, its operations and actions, the filter its list takes, and the bodies of create, update, clone and each action, with the schemas and examples of this server's API, the permission each needs, and ready-to-send example calls. Call it before writing a type or running an action for the first time. Without `type`: every type with its operations and actions, example calls, and the guide to the platform's concepts (start here).",
947
1781
  inputSchema: {
948
1782
  type: 'object',
949
1783
  properties: { type: typeParam() },
950
1784
  required: [],
951
1785
  },
1786
+ annotations: {
1787
+ title: 'Describe resource types',
1788
+ readOnlyHint: true,
1789
+ openWorldHint: false,
1790
+ },
952
1791
  handler: toolDescribe,
953
1792
  },
954
1793
  {
955
1794
  name: 'resource_list',
956
1795
  description:
957
- 'List resources of a type, one JSON record per line, in pages of 50 (`count`, `index`). `search` finds by id or name. `filter` narrows by product, type, asset_type, asset_group, project (its short id, "plant_a", not "<owner>@plant_a") or labels (key present), e.g. {"product": "boiler"}; alarm instances also take state and severity. A field of the listed records filters by exact value, nested ones with dots ({"alarm.rule": "r1"}); an array matches any of its values.',
1796
+ 'List the records of a type, one JSON record per line with the `ids` to pass back, in pages of 50 (`count`, `index`). `search` finds by id or name. `filter` narrows by product, type, asset_type, asset_group, project (its short id, "plant_a", not "<owner>@plant_a") or labels (key present), e.g. {"product": "boiler"}; alarm instances also take state and severity. A field of the listed records filters by exact value, nested ones with dots ({"alarm.rule": "r1"}); an array matches any of its values. Child types take their parent\'s ids: {"device": "boiler"} lists a device_property.\n\nAlso: the time series of a bucket (type "bucket_data", ids {"bucket": …}, filter {items, minutes or min_ts/max_ts, device, agg, agg_type, tz, fields, group_by, sort}), the files of a storage (type "storage_file", ids {"storage": …}, filter {path: folder}), what a connected device exposes (type "device_resource", ids {"device": …}), the guides (type "doc") and this CLI\'s profiles (type "profile"). {tool:resource_describe} shows the filter each type takes.',
958
1797
  inputSchema: {
959
1798
  type: 'object',
960
1799
  properties: {
961
1800
  type: typeParam('list'),
962
- parent: common.parent,
963
- filter: { type: 'object', description: 'Query filters (see above).' },
1801
+ ids,
1802
+ filter: {
1803
+ type: 'object',
1804
+ description:
1805
+ 'Query filters (see above). Types with their own filter (bucket_data, storage_file…) take only its keys.',
1806
+ },
964
1807
  search: {
965
1808
  type: 'string',
966
1809
  description:
967
1810
  'Text to look for in ids and names (partial, any case; descriptions are not searched). On the plugin catalog, words matched against every field shown.',
968
1811
  },
969
- count: { type: 'number', description: 'Page size (default 50).' },
1812
+ count: {
1813
+ type: 'number',
1814
+ description:
1815
+ 'Page size (default 50); for bucket_data, the samples to return (filter.items).',
1816
+ },
970
1817
  index: { type: 'number', description: 'Offset of the first record.' },
971
- user: common.user,
1818
+ user,
972
1819
  },
973
1820
  required: ['type'],
974
1821
  },
1822
+ annotations: { title: 'List resources', readOnlyHint: true, openWorldHint: false },
975
1823
  handler: toolList,
976
1824
  },
977
1825
  {
978
1826
  name: 'resource_get',
979
1827
  description:
980
- 'Read one resource as JSON. Single resources (device_callback, product_profile_code) take only `parent`.',
1828
+ 'Read one record as JSON, located by `ids`. Single records (device_callback, product_profile_code) take only their parent\'s ids. Also reads a text file of a storage (type "storage_file", ids {"storage", "file": its path}) and a guide (type "doc", ids {"doc"}, from resource_list type "doc"): read the relevant guide before building something new.',
981
1829
  inputSchema: {
982
1830
  type: 'object',
983
1831
  properties: {
984
1832
  type: typeParam('get'),
985
- parent: common.parent,
986
- id,
1833
+ ids,
987
1834
  full: {
988
1835
  type: 'boolean',
989
1836
  description:
990
1837
  'Plugin catalog manifests: also include the product code and the console dashboard (large). Types that take it say so in their notes.',
991
1838
  },
992
- user: common.user,
1839
+ user,
993
1840
  },
994
1841
  required: ['type'],
995
1842
  },
1843
+ annotations: { title: 'Read a resource', readOnlyHint: true, openWorldHint: false },
996
1844
  handler: toolGet,
997
1845
  },
998
1846
  {
999
1847
  name: 'resource_create',
1000
1848
  description:
1001
- 'Create a resource from `body`, shaped as {tool:resource_describe} shows. `id` goes into the body\'s id field when the body lacks it; product_profile_* and product_service entries are stored under `id`, replacing any entry of that name. `projects` in the body (["<owner>@<project>"]) also puts the new resource in those projects.',
1849
+ 'Create a record from `body`, shaped as {tool:resource_describe} shows. The record\'s own id goes in `ids` or in the body\'s id field; product_profile_* and product_service entries are stored under their id in `ids`, replacing any entry of that name. `projects` in the body (["<owner>@<project>"]) also puts the new record in those projects. Writes data too: samples to a bucket (type "bucket_data", ids {"bucket"}; body one sample, stored now or at its `ts`, or an array of samples each with its `ts`; rate limited: import a CSV for long histories) and files to a storage (type "storage_file", ids {"storage", "file": the path}; body {content} for text, or {local} for a file or folder of this machine, binary too, where ids.file is optional: a file keeps its name, a folder goes to the root), overwriting what is there.',
1002
1850
  inputSchema: {
1003
1851
  type: 'object',
1004
1852
  properties: {
1005
1853
  type: typeParam('create'),
1006
- parent: common.parent,
1007
- id,
1854
+ ids,
1008
1855
  body: {
1009
1856
  type: ['object', 'array'],
1010
1857
  description:
1011
- 'Fields of the new resource; an array of them creates several at once where the type allows it (properties).',
1858
+ 'Fields of the new record; an array creates several at once where the type allows it (properties, bucket_data samples).',
1012
1859
  },
1013
- user: common.user,
1860
+ user,
1014
1861
  },
1015
1862
  required: ['type'],
1016
1863
  },
1864
+ annotations: {
1865
+ title: 'Create a resource',
1866
+ readOnlyHint: false,
1867
+ destructiveHint: false,
1868
+ idempotentHint: false,
1869
+ openWorldHint: false,
1870
+ },
1017
1871
  handler: toolCreate,
1018
1872
  },
1019
1873
  {
1020
1874
  name: 'resource_update',
1021
1875
  description:
1022
- 'Update a resource with the fields in `body`; fields left out are kept, except for property values, which are replaced unless `merge` is true. `projects` in the body (["<owner>@<project>"]) sets the projects the resource belongs to.',
1876
+ 'Update the record `ids` locates with the fields in `body`; fields left out are kept, except for property values, which are replaced unless `merge` is true. `projects` in the body (["<owner>@<project>"]) sets the projects the record belongs to. A storage_file is overwritten, with the body of create.',
1023
1877
  inputSchema: {
1024
1878
  type: 'object',
1025
1879
  properties: {
1026
1880
  type: typeParam('update'),
1027
- parent: common.parent,
1028
- id,
1881
+ ids,
1029
1882
  body: body('Fields to change.'),
1030
1883
  merge: {
1031
1884
  type: 'boolean',
1032
1885
  description:
1033
1886
  'Patch (JSON merge) instead of replacing: property types and brands.',
1034
1887
  },
1035
- user: common.user,
1888
+ user,
1036
1889
  },
1037
1890
  required: ['type', 'body'],
1038
1891
  },
1892
+ annotations: {
1893
+ title: 'Update a resource',
1894
+ readOnlyHint: false,
1895
+ destructiveHint: false,
1896
+ idempotentHint: true,
1897
+ openWorldHint: false,
1898
+ },
1039
1899
  handler: toolUpdate,
1040
1900
  },
1041
1901
  {
1042
1902
  name: 'resource_delete',
1043
- description: 'Delete a resource. Immediate and permanent: confirm with the user first.',
1903
+ description:
1904
+ 'Delete the record `ids` locates (a storage_file: that file). Immediate and permanent: confirm with the user first.',
1044
1905
  inputSchema: {
1045
1906
  type: 'object',
1046
- properties: {
1047
- type: typeParam('delete'),
1048
- parent: common.parent,
1049
- id,
1050
- user: common.user,
1051
- },
1907
+ properties: { type: typeParam('delete'), ids, user },
1052
1908
  required: ['type'],
1053
1909
  },
1910
+ annotations: {
1911
+ title: 'Delete a resource',
1912
+ readOnlyHint: false,
1913
+ destructiveHint: true,
1914
+ idempotentHint: true,
1915
+ openWorldHint: false,
1916
+ },
1054
1917
  handler: toolDelete,
1055
1918
  },
1056
1919
  {
1057
1920
  name: 'resource_clone',
1058
1921
  description:
1059
- 'Copy a resource under a new id. `body` holds the new id in the type\'s id field (e.g. {"device": "copy_1"}) and optionally name, description and enabled.',
1922
+ 'Copy the record `ids` locates under a new id: `body` holds the new id in the record\'s id field (e.g. {"device": "copy_1"}, {"rule": "copy_1"} for an alarm_rule; {tool:resource_describe} shows it) and optionally name, description and enabled.',
1060
1923
  inputSchema: {
1061
1924
  type: 'object',
1062
1925
  properties: {
1063
1926
  type: typeParam('clone'),
1064
- parent: common.parent,
1065
- id,
1927
+ ids,
1066
1928
  body: body('New id and overrides.'),
1067
- user: common.user,
1929
+ user,
1068
1930
  },
1069
- required: ['type', 'id', 'body'],
1931
+ required: ['type', 'ids', 'body'],
1932
+ },
1933
+ annotations: {
1934
+ title: 'Copy a resource',
1935
+ readOnlyHint: false,
1936
+ destructiveHint: false,
1937
+ idempotentHint: false,
1938
+ openWorldHint: false,
1070
1939
  },
1071
1940
  handler: toolClone,
1072
1941
  },
1073
1942
  {
1074
1943
  name: 'resource_call',
1075
- description:
1076
- "Run an action of a resource that is not create/read/update/delete: verify a domain, restart a host, call an endpoint, export a product, test an alarm rule… {tool:resource_describe} lists each type's actions (after |) and their bodies.",
1944
+ description: readOnly
1945
+ ? `Run a read-only action of a resource: statistics, an overview, logs, a test that changes nothing. \`body\` is its input and \`query\` its query parameters; {tool:resource_describe} lists each type's actions (after |) with their bodies. The most used: type "device", action "overview" (no ids; body {locations: true} adds positions).${added}`
1946
+ : `Run an action of a resource: what is not create/read/update/delete. \`body\` is its input and \`query\` its query parameters; {tool:resource_describe} lists each type's actions (after |) with their bodies. The most used:
1947
+ - type "device_resource", ids {"device", "resource"}, action "call": run a resource of a connected device with \`body\` as its input; without a body its output is read (a resource without input runs);
1948
+ - type "device", action "send" (ids {"device"}; body the data an HTTP device sends) and "overview" (no ids; body {locations: true} adds positions);
1949
+ - type "product_profile_api", ids {"product", "resource"}, action "call": call it for the whole product;
1950
+ - type "plugin", ids {"plugin"}, action "call": its own API, body {path, method, body}, and \`query\`;
1951
+ - type "dashboard", actions "init", "dev" and "publish" (no ids): create, develop and publish dashboard apps on the dashboard SDK; publish body {dir: "./dist", storage: <app storage>, to: [{"dashboard": <dashboard id>}]}.${added}
1952
+
1953
+
1954
+ Others: verify a domain, restart a host, export a product, test an alarm rule, install a plugin… Actions act at once: confirm with the user before those that change, remove or notify. Those with a high impact answer what they would do until called again with confirm: true in the body.`,
1077
1955
  inputSchema: {
1078
1956
  type: 'object',
1079
1957
  properties: {
1080
1958
  type: {
1081
1959
  type: 'string',
1082
- enum: [...served].filter((n) => actionNames(n).length),
1960
+ enum: actionTypes(),
1083
1961
  description: 'Resource type.',
1084
1962
  },
1085
- action: { type: 'string', description: 'Action name, e.g. "verify".' },
1086
- parent: common.parent,
1087
- id,
1088
- body: { description: 'Action input, when it takes one.' },
1089
- query: { type: 'object', description: 'Query parameters, when it takes them.' },
1090
- user: common.user,
1963
+ action: {
1964
+ type: 'string',
1965
+ enum: actionEnum,
1966
+ description:
1967
+ 'Action name: "call", "send", "verify"… (each type has its own: resource_describe).',
1968
+ },
1969
+ ids: {
1970
+ ...ids,
1971
+ description: `${ids.description} Actions on the whole collection (overview, init, stats…) take only the parents' ids.`,
1972
+ },
1973
+ body: {
1974
+ description:
1975
+ 'Action input, when it takes one (its shape: resource_describe); confirm: true for an action that asks first.',
1976
+ },
1977
+ query: {
1978
+ type: 'object',
1979
+ description:
1980
+ "Query parameters, when it takes them (a plugin call's, a host's logs).",
1981
+ },
1982
+ user,
1091
1983
  },
1092
1984
  required: ['type', 'action'],
1093
1985
  },
1986
+ annotations: readOnly
1987
+ ? { title: 'Run a read-only action', readOnlyHint: true, openWorldHint: true }
1988
+ : {
1989
+ title: 'Run a resource action',
1990
+ readOnlyHint: false,
1991
+ destructiveHint: true,
1992
+ idempotentHint: false,
1993
+ openWorldHint: true,
1994
+ },
1094
1995
  handler: toolCall,
1095
1996
  },
1096
1997
  ];
1998
+ return tools
1999
+ .filter((t) => !readOnly || READ_TOOLS.has(t.name))
2000
+ .map((t) => ({ ...t, explain: (args, unknown) => explainUnknown(t.name, args, unknown) }));
1097
2001
  }