@thinger-io/cli-core 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +195 -0
- package/README.md +82 -31
- package/lib/bucket-export.js +91 -0
- package/lib/cli.js +106 -50
- package/lib/commands/_shared.js +89 -1
- package/lib/commands/api.js +90 -0
- package/lib/commands/arguments.js +62 -0
- package/lib/commands/bucket.js +52 -44
- package/lib/commands/dashboard.js +5 -2
- package/lib/commands/data.js +539 -0
- package/lib/commands/device.js +128 -241
- package/lib/commands/grammar.js +279 -0
- package/lib/commands/product.js +104 -214
- package/lib/commands/profile.js +1 -0
- package/lib/commands/resources.js +975 -0
- package/lib/dashboard-init.js +4 -3
- package/lib/dashboard.js +10 -2
- package/lib/mcp/catalog.js +301 -63
- package/lib/mcp/core-types.js +36 -0
- package/lib/mcp/helpers.js +23 -2
- package/lib/mcp/naming.js +20 -0
- package/lib/mcp/registry.js +18 -67
- package/lib/mcp/resource-hooks.js +19 -5
- package/lib/mcp/server.js +43 -11
- package/lib/mcp/tools-resource.js +1232 -330
- package/lib/mcp/types/bucket-data.js +151 -0
- package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
- package/lib/mcp/types/device-resource.js +117 -0
- package/lib/mcp/types/doc.js +35 -0
- package/lib/mcp/types/plugin-call.js +75 -0
- package/lib/mcp/types/product-api.js +44 -0
- package/lib/mcp/types/profile.js +37 -0
- package/lib/mcp/types/storage-file.js +170 -0
- package/lib/openapi.js +18 -0
- package/lib/product-apply.js +121 -0
- package/lib/raw-api.js +111 -0
- package/lib/storage.js +41 -13
- package/lib/tokens.js +16 -2
- package/package.json +1 -1
- package/lib/mcp/tools-buckets.js +0 -130
- package/lib/mcp/tools-devices.js +0 -134
- package/lib/mcp/tools-misc.js +0 -48
- package/lib/mcp/tools-plugins.js +0 -63
- package/lib/mcp/tools-products.js +0 -52
- 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 {
|
|
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
|
-
|
|
13
|
-
|
|
18
|
+
idKey,
|
|
19
|
+
idLevels,
|
|
14
20
|
idNames,
|
|
15
|
-
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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: `
|
|
37
|
-
*
|
|
38
|
-
* characters),
|
|
39
|
-
* input, to be
|
|
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}
|
|
44
|
-
* @property {string} [
|
|
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
|
|
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
|
|
63
|
-
*
|
|
64
|
-
* @property {string} [id] Name of
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
111
|
-
* clashes with
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
315
|
+
* @param {Record<string, any>} [ids]
|
|
172
316
|
*/
|
|
173
|
-
function label(type,
|
|
174
|
-
const where =
|
|
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
|
|
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
|
|
181
|
-
if (!
|
|
182
|
-
|
|
183
|
-
|
|
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 {{
|
|
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
|
-
/**
|
|
253
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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({
|
|
361
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
* `
|
|
398
|
-
*
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (
|
|
405
|
-
|
|
406
|
-
if (
|
|
407
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
|
425
|
-
const
|
|
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}${
|
|
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
|
|
436
|
-
function localContext(args, {
|
|
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
|
-
|
|
441
|
-
|
|
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
|
|
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
|
|
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
|
-
`
|
|
783
|
+
`Type "${type}" does not support ${verb} (it supports: ${allowed}). Types with ${verb}: ${others}.`,
|
|
469
784
|
);
|
|
470
785
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
throw inputError(
|
|
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
|
-
|
|
476
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
849
|
+
function describeActions(name) {
|
|
505
850
|
return Object.fromEntries(
|
|
506
|
-
Object.entries(
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
514
|
-
if (
|
|
515
|
-
|
|
516
|
-
entry.
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
/**
|
|
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:
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
out.
|
|
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
|
-
|
|
532
|
-
if (
|
|
533
|
-
if (t.
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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 (
|
|
551
|
-
// The overview call: the types,
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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(
|
|
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:
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if (
|
|
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
|
|
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
|
|
1051
|
+
const allowed = {};
|
|
596
1052
|
let resourceType;
|
|
597
|
-
for (const verb of Object.keys(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1070
|
+
actions[a].body = body;
|
|
614
1071
|
}
|
|
615
1072
|
}
|
|
616
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
.
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
696
|
-
|
|
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 (
|
|
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
|
-
//
|
|
716
|
-
if (!t.upsert &&
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
725
|
-
if (projects &&
|
|
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:
|
|
1246
|
+
`give the ${type} id to set its projects on create: ids.${key}, or the body field "${field}"`,
|
|
729
1247
|
);
|
|
730
1248
|
}
|
|
731
|
-
const
|
|
732
|
-
const
|
|
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', {
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
if (
|
|
749
|
-
if (
|
|
750
|
-
|
|
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
|
-
|
|
754
|
-
if (
|
|
755
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const
|
|
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
|
-
|
|
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
|
|
786
|
-
|
|
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',
|
|
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',
|
|
803
|
-
if (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,
|
|
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,
|
|
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,
|
|
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
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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,557 @@ 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
|
-
|
|
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
|
+
const changed = Object.keys(body).filter(
|
|
1497
|
+
(k) => JSON.stringify(now[k]) !== JSON.stringify(body[k]),
|
|
1498
|
+
);
|
|
1499
|
+
const dropped = Object.keys(now).filter((k) => !(k in body));
|
|
1500
|
+
return `It would replace the settings of ${target}: changes ${some(changed)}; removes ${some(dropped)} (left out of the body).`;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
if (action === 'test_notification') {
|
|
1504
|
+
const rule = await send(type, 'get', where).catch(() => undefined);
|
|
1505
|
+
const to = isRecord(rule)
|
|
1506
|
+
? (rule.notification ?? rule.notifications ?? rule.endpoints)
|
|
1507
|
+
: undefined;
|
|
1508
|
+
if (to !== undefined) {
|
|
1509
|
+
return `It would send the notification of ${target} for real, to: ${JSON.stringify(to)}.`;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return `It would run ${action} on ${target}: ${expand(spec.description)}`;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* An action that asks first and was not confirmed: ask the user when the
|
|
1517
|
+
* client can (elicitation), or answer the plan. Returns true to go on.
|
|
1518
|
+
*
|
|
1519
|
+
* @param {string} plan
|
|
1520
|
+
* @param {ToolExtra} [extra]
|
|
1521
|
+
*/
|
|
1522
|
+
async function ask(plan, extra) {
|
|
1523
|
+
if (!extra?.elicit) return false;
|
|
1524
|
+
try {
|
|
1525
|
+
return await extra.elicit(plan);
|
|
1526
|
+
} catch {
|
|
1527
|
+
return false;
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** The answer of an action that asked first and was not done. */
|
|
1532
|
+
const planned = (plan, extra) =>
|
|
1533
|
+
text(
|
|
1534
|
+
extra?.elicit
|
|
1535
|
+
? `${plan}\nThe user did not confirm it: nothing was done.`
|
|
1536
|
+
: `${plan}\nNothing was done. ${CONFIRM_HINT}`,
|
|
1537
|
+
{ plan },
|
|
1538
|
+
);
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* @param {Record<string, any>} args
|
|
1542
|
+
* @param {ToolExtra} [extra]
|
|
1543
|
+
*/
|
|
1544
|
+
async function toolCall(args, extra) {
|
|
857
1545
|
const type = args.type;
|
|
858
|
-
|
|
859
|
-
const
|
|
860
|
-
if (
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1546
|
+
if (typeof type !== 'string' || !served.has(type)) throw unknownType(type);
|
|
1547
|
+
const names = actionNames(type);
|
|
1548
|
+
if (!names.length) {
|
|
1549
|
+
throw inputError(
|
|
1550
|
+
`Type "${type}" has no actions${readOnly ? ' served read-only' : ''}. Types with actions: ${actionTypes().join(', ')}.`,
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
const spec = own(allActionsOf(type), args.action);
|
|
1554
|
+
if (!spec || (readOnly && !spec.readOnly)) {
|
|
1555
|
+
throw inputError(
|
|
1556
|
+
`Type "${type}" has no action "${args.action ?? ''}"${spec ? ' served read-only' : ''} (it has: ${names.join(', ')})`,
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
const on = spec.on || 'item';
|
|
1560
|
+
const at = where(
|
|
1561
|
+
type,
|
|
1562
|
+
args,
|
|
1563
|
+
on === 'collection' ? 'forbidden' : on === 'either' ? 'optional' : 'required',
|
|
1564
|
+
(key) => `${args.action} acts on every ${type}: leave ${key} out of ids`,
|
|
1565
|
+
);
|
|
1566
|
+
let body = args.body;
|
|
1567
|
+
if (spec.bodyRequired && (body === undefined || body === null)) {
|
|
1568
|
+
throw inputError(
|
|
1569
|
+
`${type}.${args.action} needs a body: ${expand(spec.description)} (${toolName('resource_describe')} type "${type}" shows it)`,
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
// Actions that ask first: body.confirm is the answer, not their input.
|
|
1573
|
+
let confirmed = false;
|
|
1574
|
+
if (spec.confirm && isRecord(body) && 'confirm' in body) {
|
|
1575
|
+
const { confirm, ...rest } = body;
|
|
1576
|
+
confirmed = confirm === true;
|
|
1577
|
+
body = rest;
|
|
1578
|
+
}
|
|
1579
|
+
const ran = `Ran ${args.action} on ${label(type, at.ids)}.`;
|
|
1580
|
+
|
|
1581
|
+
if (typeof spec.run === 'function') {
|
|
1582
|
+
// A refused input: show a call that works, when the action has one.
|
|
1583
|
+
const withExample = (e) => {
|
|
1584
|
+
const ex = spec.examples?.[0];
|
|
1585
|
+
if (e?.code === 'input_error' && ex && !e.message.includes('\nExample: ')) {
|
|
1586
|
+
e.message += `\nExample: ${toolName(ex.tool)} ${JSON.stringify(ex.arguments)} (${ex.does})`;
|
|
1587
|
+
}
|
|
1588
|
+
return e;
|
|
1589
|
+
};
|
|
1590
|
+
const state = {};
|
|
1591
|
+
const run = (ok) => spec.run(body, localContext(args, at, { state, confirmed: ok }));
|
|
1592
|
+
let result;
|
|
1593
|
+
try {
|
|
1594
|
+
// An id in the body (device_exec's old `device`): it goes in
|
|
1595
|
+
// ids, unless the action takes none.
|
|
1596
|
+
if (
|
|
1597
|
+
on !== 'collection' &&
|
|
1598
|
+
isRecord(body) &&
|
|
1599
|
+
spec.body?.properties &&
|
|
1600
|
+
spec.body.additionalProperties !== true
|
|
1601
|
+
) {
|
|
1602
|
+
const key = levelsOf(type)
|
|
1603
|
+
.map((l) => l.key)
|
|
1604
|
+
.find((k) => k in body && !Object.hasOwn(spec.body.properties, k));
|
|
1605
|
+
if (key) {
|
|
1606
|
+
throw inputError(
|
|
1607
|
+
`body of ${type}.${args.action} has no "${key}": pass the ${key} in ids ({"${key}": ${JSON.stringify(body[key])}})`,
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
const required = spec.body?.required?.length;
|
|
1612
|
+
checkShape(
|
|
1613
|
+
body ?? (required ? {} : undefined),
|
|
1614
|
+
spec.body,
|
|
1615
|
+
`body of ${type}.${args.action}`,
|
|
1616
|
+
);
|
|
1617
|
+
result = await run(confirmed);
|
|
1618
|
+
if (result && typeof result === 'object' && PLAN in result) {
|
|
1619
|
+
if (!(await ask(result[PLAN], extra))) return planned(result[PLAN], extra);
|
|
1620
|
+
result = await run(true);
|
|
1621
|
+
}
|
|
1622
|
+
} catch (e) {
|
|
1623
|
+
throw withExample(e);
|
|
864
1624
|
}
|
|
865
|
-
|
|
866
|
-
const result = await mine.run(args.body, ctx);
|
|
1625
|
+
|
|
867
1626
|
if (isToolResult(result)) {
|
|
868
1627
|
return {
|
|
869
1628
|
...result,
|
|
870
1629
|
content: result.content.map((c) =>
|
|
871
|
-
c.type === 'text' ? { ...c, text:
|
|
1630
|
+
c.type === 'text' ? { ...c, text: cap(type, args.action, c.text) } : c,
|
|
872
1631
|
),
|
|
873
1632
|
};
|
|
874
1633
|
}
|
|
875
1634
|
const said = answer(result);
|
|
876
|
-
return text(
|
|
877
|
-
}
|
|
878
|
-
if (!known || !actionNames(type).length) {
|
|
879
|
-
throw inputError(
|
|
880
|
-
`Type "${type ?? ''}" has no actions. ${toolName('resource_describe')} lists them after |.`,
|
|
881
|
-
);
|
|
1635
|
+
return text(cap(type, args.action, said ?? ran), result ?? null);
|
|
882
1636
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1637
|
+
|
|
1638
|
+
checkActionBody(type, args.action, body);
|
|
1639
|
+
const { method, url, query } = actionRequest(type, args.action, {
|
|
1640
|
+
ids: at.ids,
|
|
1641
|
+
user: args.user,
|
|
1642
|
+
});
|
|
1643
|
+
if (spec.confirm && !confirmed) {
|
|
1644
|
+
const plan = await planOf(type, args.action, spec, at, body, args.user);
|
|
1645
|
+
if (!(await ask(plan, extra))) return planned(plan, extra);
|
|
887
1646
|
}
|
|
888
|
-
const { method, url, query } = actionRequest(type, args.action, args);
|
|
889
|
-
const spec = CATALOG[type].actions?.[args.action];
|
|
890
1647
|
const started = Date.now();
|
|
891
1648
|
try {
|
|
892
1649
|
// Actions that evaluate a resource (test_rule) take the saved one
|
|
893
1650
|
// when no body is given.
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
? pick(
|
|
897
|
-
|
|
1651
|
+
const data =
|
|
1652
|
+
body === undefined && spec.bodyFromRecord
|
|
1653
|
+
? pick(
|
|
1654
|
+
await send(type, 'get', { ids: at.ids, user: args.user }),
|
|
1655
|
+
spec.bodyFromRecord,
|
|
1656
|
+
)
|
|
1657
|
+
: body;
|
|
898
1658
|
const res = await api.request({
|
|
899
1659
|
method,
|
|
900
1660
|
url,
|
|
901
|
-
data: method === 'get' ? undefined : (
|
|
1661
|
+
data: method === 'get' ? undefined : (data ?? {}),
|
|
902
1662
|
params: { ...(args.query || {}), ...(query || {}) },
|
|
903
1663
|
paramsSerializer: { indexes: null },
|
|
904
1664
|
timeout: TIMEOUTS.API_LONG_MS,
|
|
905
1665
|
});
|
|
906
|
-
const
|
|
907
|
-
if (
|
|
908
|
-
return text(
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
return text(lastProgress(
|
|
1666
|
+
const answered = res.data;
|
|
1667
|
+
if (answered === undefined || answered === null || answered === '')
|
|
1668
|
+
return text(ran, null);
|
|
1669
|
+
if (typeof answered !== 'string')
|
|
1670
|
+
return text(JSON.stringify(answered, null, 2), answered);
|
|
1671
|
+
return text(lastProgress(answered, Date.now() - started) ?? answered, answered);
|
|
912
1672
|
} catch (e) {
|
|
913
|
-
const err = apiError(e, { notFound: `Not found: ${label(type,
|
|
1673
|
+
const err = apiError(e, { notFound: `Not found: ${label(type, at.ids)}` });
|
|
914
1674
|
// A 400 on an action called without a body is nearly always the
|
|
915
1675
|
// missing body: say what it takes instead of leaving the guess.
|
|
916
|
-
if (e?.response?.status === 400 &&
|
|
917
|
-
err.message += `. ${args.action} takes a body: ${
|
|
1676
|
+
if (e?.response?.status === 400 && body === undefined && method !== 'get') {
|
|
1677
|
+
err.message += `. ${args.action} takes a body: ${expand(spec.description || '')} (${toolName('resource_describe')} type "${type}" shows its schema)`;
|
|
918
1678
|
}
|
|
919
1679
|
throw err;
|
|
920
1680
|
}
|
|
921
1681
|
}
|
|
922
1682
|
|
|
1683
|
+
/**
|
|
1684
|
+
* Where a key the tool does not take belongs, when it is a filter, body
|
|
1685
|
+
* or id key of the type: the model put it one level too high.
|
|
1686
|
+
*
|
|
1687
|
+
* @param {string} base Bare tool name.
|
|
1688
|
+
* @param {Record<string, any>} args
|
|
1689
|
+
* @param {string[]} unknown
|
|
1690
|
+
*/
|
|
1691
|
+
function explainUnknown(base, args, unknown) {
|
|
1692
|
+
const type = typeof args.type === 'string' && served.has(args.type) ? args.type : undefined;
|
|
1693
|
+
if (!type) return undefined;
|
|
1694
|
+
const mine = own(local, type);
|
|
1695
|
+
const ids = new Set(levelsOf(type).map((l) => l.key));
|
|
1696
|
+
const bodyKeys = new Set();
|
|
1697
|
+
const doc = bundledDoc();
|
|
1698
|
+
if (base === 'resource_call') {
|
|
1699
|
+
const spec = own(allActionsOf(type), args.action);
|
|
1700
|
+
Object.keys(spec?.body?.properties || {}).forEach((k) => bodyKeys.add(k));
|
|
1701
|
+
if (spec && !spec.run && doc && CATALOG[type]?.actions?.[args.action]) {
|
|
1702
|
+
const { method, path } = actionTemplate(type, args.action);
|
|
1703
|
+
const op = findOperation(doc, method, path)?.operation;
|
|
1704
|
+
const schema = op?.requestBody?.content?.['application/json']?.schema;
|
|
1705
|
+
Object.keys(schema ? resolveRefs(doc, schema).properties || {} : {}).forEach((k) =>
|
|
1706
|
+
bodyKeys.add(k),
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
} else if (BODY_VERBS.has(base.replace('resource_', ''))) {
|
|
1710
|
+
const verb = /** @type {any} */ (base.replace('resource_', ''));
|
|
1711
|
+
if (mine) Object.keys(mine.schema?.properties || {}).forEach((k) => bodyKeys.add(k));
|
|
1712
|
+
else if (doc && verb in CATALOG[type].ops) {
|
|
1713
|
+
const { method, path } = templateFor(type, verb);
|
|
1714
|
+
const schema = findOperation(doc, method, path)?.operation?.requestBody?.content?.[
|
|
1715
|
+
'application/json'
|
|
1716
|
+
]?.schema;
|
|
1717
|
+
Object.keys(schema ? resolveRefs(doc, schema).properties || {} : {}).forEach((k) =>
|
|
1718
|
+
bodyKeys.add(k),
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
const value = (k) => JSON.stringify(args[k]);
|
|
1723
|
+
const hints = unknown.map((k) => {
|
|
1724
|
+
if (ids.has(k)) return `\`${k}\` goes in ids: ids: {"${k}": ${value(k)}}`;
|
|
1725
|
+
if (base === 'resource_list' && (mine ? mine.filter?.properties?.[k] : true)) {
|
|
1726
|
+
return `\`${k}\` goes in filter for type ${type}: filter: {"${k}": ${value(k)}}`;
|
|
1727
|
+
}
|
|
1728
|
+
if (bodyKeys.has(k)) return `\`${k}\` goes in body: body: {"${k}": ${value(k)}}`;
|
|
1729
|
+
return undefined;
|
|
1730
|
+
});
|
|
1731
|
+
return hints.filter(Boolean).join('; ') || undefined;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
923
1734
|
const typeParam = (verb) => ({
|
|
924
1735
|
type: 'string',
|
|
925
1736
|
enum: verb ? namesWith(verb) : [...served],
|
|
926
1737
|
description: 'Resource type (see {tool:resource_describe}).',
|
|
927
1738
|
});
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
},
|
|
1739
|
+
const user = {
|
|
1740
|
+
type: 'string',
|
|
1741
|
+
description: 'Account to act on (admin only). Omit for your own.',
|
|
1742
|
+
};
|
|
1743
|
+
const ids = {
|
|
1744
|
+
type: 'object',
|
|
1745
|
+
additionalProperties: { type: 'string' },
|
|
1746
|
+
description:
|
|
1747
|
+
'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
1748
|
};
|
|
939
|
-
const id = { type: 'string', description: 'Resource id.' };
|
|
940
1749
|
const body = (description) => ({ type: 'object', description });
|
|
1750
|
+
const actionEnum = [...new Set(actionTypes().flatMap((n) => actionNames(n)))].sort();
|
|
1751
|
+
|
|
1752
|
+
/** The actions this CLI adds, one line each, for resource_call's description. */
|
|
1753
|
+
/** @type {Array<[string, string, LocalAction]>} */
|
|
1754
|
+
const cliAdded = Object.entries(cliActions)
|
|
1755
|
+
.flatMap(([type, list]) =>
|
|
1756
|
+
Object.entries(list).map(([a, spec]) => /** @type {any} */ ([type, a, spec])),
|
|
1757
|
+
)
|
|
1758
|
+
.concat(
|
|
1759
|
+
Object.entries(cliTypes).flatMap(([type, t]) =>
|
|
1760
|
+
Object.entries(t.actions || {}).map(
|
|
1761
|
+
([a, spec]) => /** @type {any} */ ([type, a, spec]),
|
|
1762
|
+
),
|
|
1763
|
+
),
|
|
1764
|
+
);
|
|
1765
|
+
const added = cliAdded
|
|
1766
|
+
.filter(([, , spec]) => !readOnly || spec.readOnly)
|
|
1767
|
+
.map(
|
|
1768
|
+
([type, a, spec]) =>
|
|
1769
|
+
`\n- type "${type}", action "${a}": ${expand(spec.description).split(/(?<=\.)\s/)[0]}`,
|
|
1770
|
+
)
|
|
1771
|
+
.join('');
|
|
941
1772
|
|
|
942
|
-
|
|
1773
|
+
/** @type {Array<Record<string, any>>} */
|
|
1774
|
+
const tools = [
|
|
943
1775
|
{
|
|
944
1776
|
name: 'resource_describe',
|
|
945
1777
|
description:
|
|
946
|
-
"Describe a resource type: its
|
|
1778
|
+
"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
1779
|
inputSchema: {
|
|
948
1780
|
type: 'object',
|
|
949
1781
|
properties: { type: typeParam() },
|
|
950
1782
|
required: [],
|
|
951
1783
|
},
|
|
1784
|
+
annotations: {
|
|
1785
|
+
title: 'Describe resource types',
|
|
1786
|
+
readOnlyHint: true,
|
|
1787
|
+
openWorldHint: false,
|
|
1788
|
+
},
|
|
952
1789
|
handler: toolDescribe,
|
|
953
1790
|
},
|
|
954
1791
|
{
|
|
955
1792
|
name: 'resource_list',
|
|
956
1793
|
description:
|
|
957
|
-
'List
|
|
1794
|
+
'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
1795
|
inputSchema: {
|
|
959
1796
|
type: 'object',
|
|
960
1797
|
properties: {
|
|
961
1798
|
type: typeParam('list'),
|
|
962
|
-
|
|
963
|
-
filter: {
|
|
1799
|
+
ids,
|
|
1800
|
+
filter: {
|
|
1801
|
+
type: 'object',
|
|
1802
|
+
description:
|
|
1803
|
+
'Query filters (see above). Types with their own filter (bucket_data, storage_file…) take only its keys.',
|
|
1804
|
+
},
|
|
964
1805
|
search: {
|
|
965
1806
|
type: 'string',
|
|
966
1807
|
description:
|
|
967
1808
|
'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
1809
|
},
|
|
969
|
-
count: {
|
|
1810
|
+
count: {
|
|
1811
|
+
type: 'number',
|
|
1812
|
+
description:
|
|
1813
|
+
'Page size (default 50); for bucket_data, the samples to return (filter.items).',
|
|
1814
|
+
},
|
|
970
1815
|
index: { type: 'number', description: 'Offset of the first record.' },
|
|
971
|
-
user
|
|
1816
|
+
user,
|
|
972
1817
|
},
|
|
973
1818
|
required: ['type'],
|
|
974
1819
|
},
|
|
1820
|
+
annotations: { title: 'List resources', readOnlyHint: true, openWorldHint: false },
|
|
975
1821
|
handler: toolList,
|
|
976
1822
|
},
|
|
977
1823
|
{
|
|
978
1824
|
name: 'resource_get',
|
|
979
1825
|
description:
|
|
980
|
-
'Read one
|
|
1826
|
+
'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
1827
|
inputSchema: {
|
|
982
1828
|
type: 'object',
|
|
983
1829
|
properties: {
|
|
984
1830
|
type: typeParam('get'),
|
|
985
|
-
|
|
986
|
-
id,
|
|
1831
|
+
ids,
|
|
987
1832
|
full: {
|
|
988
1833
|
type: 'boolean',
|
|
989
1834
|
description:
|
|
990
1835
|
'Plugin catalog manifests: also include the product code and the console dashboard (large). Types that take it say so in their notes.',
|
|
991
1836
|
},
|
|
992
|
-
user
|
|
1837
|
+
user,
|
|
993
1838
|
},
|
|
994
1839
|
required: ['type'],
|
|
995
1840
|
},
|
|
1841
|
+
annotations: { title: 'Read a resource', readOnlyHint: true, openWorldHint: false },
|
|
996
1842
|
handler: toolGet,
|
|
997
1843
|
},
|
|
998
1844
|
{
|
|
999
1845
|
name: 'resource_create',
|
|
1000
1846
|
description:
|
|
1001
|
-
'Create a
|
|
1847
|
+
'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
1848
|
inputSchema: {
|
|
1003
1849
|
type: 'object',
|
|
1004
1850
|
properties: {
|
|
1005
1851
|
type: typeParam('create'),
|
|
1006
|
-
|
|
1007
|
-
id,
|
|
1852
|
+
ids,
|
|
1008
1853
|
body: {
|
|
1009
1854
|
type: ['object', 'array'],
|
|
1010
1855
|
description:
|
|
1011
|
-
'Fields of the new
|
|
1856
|
+
'Fields of the new record; an array creates several at once where the type allows it (properties, bucket_data samples).',
|
|
1012
1857
|
},
|
|
1013
|
-
user
|
|
1858
|
+
user,
|
|
1014
1859
|
},
|
|
1015
1860
|
required: ['type'],
|
|
1016
1861
|
},
|
|
1862
|
+
annotations: {
|
|
1863
|
+
title: 'Create a resource',
|
|
1864
|
+
readOnlyHint: false,
|
|
1865
|
+
destructiveHint: false,
|
|
1866
|
+
idempotentHint: false,
|
|
1867
|
+
openWorldHint: false,
|
|
1868
|
+
},
|
|
1017
1869
|
handler: toolCreate,
|
|
1018
1870
|
},
|
|
1019
1871
|
{
|
|
1020
1872
|
name: 'resource_update',
|
|
1021
1873
|
description:
|
|
1022
|
-
'Update
|
|
1874
|
+
'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
1875
|
inputSchema: {
|
|
1024
1876
|
type: 'object',
|
|
1025
1877
|
properties: {
|
|
1026
1878
|
type: typeParam('update'),
|
|
1027
|
-
|
|
1028
|
-
id,
|
|
1879
|
+
ids,
|
|
1029
1880
|
body: body('Fields to change.'),
|
|
1030
1881
|
merge: {
|
|
1031
1882
|
type: 'boolean',
|
|
1032
1883
|
description:
|
|
1033
1884
|
'Patch (JSON merge) instead of replacing: property types and brands.',
|
|
1034
1885
|
},
|
|
1035
|
-
user
|
|
1886
|
+
user,
|
|
1036
1887
|
},
|
|
1037
1888
|
required: ['type', 'body'],
|
|
1038
1889
|
},
|
|
1890
|
+
annotations: {
|
|
1891
|
+
title: 'Update a resource',
|
|
1892
|
+
readOnlyHint: false,
|
|
1893
|
+
destructiveHint: false,
|
|
1894
|
+
idempotentHint: true,
|
|
1895
|
+
openWorldHint: false,
|
|
1896
|
+
},
|
|
1039
1897
|
handler: toolUpdate,
|
|
1040
1898
|
},
|
|
1041
1899
|
{
|
|
1042
1900
|
name: 'resource_delete',
|
|
1043
|
-
description:
|
|
1901
|
+
description:
|
|
1902
|
+
'Delete the record `ids` locates (a storage_file: that file). Immediate and permanent: confirm with the user first.',
|
|
1044
1903
|
inputSchema: {
|
|
1045
1904
|
type: 'object',
|
|
1046
|
-
properties: {
|
|
1047
|
-
type: typeParam('delete'),
|
|
1048
|
-
parent: common.parent,
|
|
1049
|
-
id,
|
|
1050
|
-
user: common.user,
|
|
1051
|
-
},
|
|
1905
|
+
properties: { type: typeParam('delete'), ids, user },
|
|
1052
1906
|
required: ['type'],
|
|
1053
1907
|
},
|
|
1908
|
+
annotations: {
|
|
1909
|
+
title: 'Delete a resource',
|
|
1910
|
+
readOnlyHint: false,
|
|
1911
|
+
destructiveHint: true,
|
|
1912
|
+
idempotentHint: true,
|
|
1913
|
+
openWorldHint: false,
|
|
1914
|
+
},
|
|
1054
1915
|
handler: toolDelete,
|
|
1055
1916
|
},
|
|
1056
1917
|
{
|
|
1057
1918
|
name: 'resource_clone',
|
|
1058
1919
|
description:
|
|
1059
|
-
'Copy
|
|
1920
|
+
'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
1921
|
inputSchema: {
|
|
1061
1922
|
type: 'object',
|
|
1062
1923
|
properties: {
|
|
1063
1924
|
type: typeParam('clone'),
|
|
1064
|
-
|
|
1065
|
-
id,
|
|
1925
|
+
ids,
|
|
1066
1926
|
body: body('New id and overrides.'),
|
|
1067
|
-
user
|
|
1927
|
+
user,
|
|
1068
1928
|
},
|
|
1069
|
-
required: ['type', '
|
|
1929
|
+
required: ['type', 'ids', 'body'],
|
|
1930
|
+
},
|
|
1931
|
+
annotations: {
|
|
1932
|
+
title: 'Copy a resource',
|
|
1933
|
+
readOnlyHint: false,
|
|
1934
|
+
destructiveHint: false,
|
|
1935
|
+
idempotentHint: false,
|
|
1936
|
+
openWorldHint: false,
|
|
1070
1937
|
},
|
|
1071
1938
|
handler: toolClone,
|
|
1072
1939
|
},
|
|
1073
1940
|
{
|
|
1074
1941
|
name: 'resource_call',
|
|
1075
|
-
description:
|
|
1076
|
-
|
|
1942
|
+
description: readOnly
|
|
1943
|
+
? `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}`
|
|
1944
|
+
: `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:
|
|
1945
|
+
- 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);
|
|
1946
|
+
- type "device", action "send" (ids {"device"}; body the data an HTTP device sends) and "overview" (no ids; body {locations: true} adds positions);
|
|
1947
|
+
- type "product_profile_api", ids {"product", "resource"}, action "call": call it for the whole product;
|
|
1948
|
+
- type "plugin", ids {"plugin"}, action "call": its own API, body {path, method, body}, and \`query\`;
|
|
1949
|
+
- 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}
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
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
1953
|
inputSchema: {
|
|
1078
1954
|
type: 'object',
|
|
1079
1955
|
properties: {
|
|
1080
1956
|
type: {
|
|
1081
1957
|
type: 'string',
|
|
1082
|
-
enum:
|
|
1958
|
+
enum: actionTypes(),
|
|
1083
1959
|
description: 'Resource type.',
|
|
1084
1960
|
},
|
|
1085
|
-
action: {
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1961
|
+
action: {
|
|
1962
|
+
type: 'string',
|
|
1963
|
+
enum: actionEnum,
|
|
1964
|
+
description:
|
|
1965
|
+
'Action name: "call", "send", "verify"… (each type has its own: resource_describe).',
|
|
1966
|
+
},
|
|
1967
|
+
ids: {
|
|
1968
|
+
...ids,
|
|
1969
|
+
description: `${ids.description} Actions on the whole collection (overview, init, stats…) take only the parents' ids.`,
|
|
1970
|
+
},
|
|
1971
|
+
body: {
|
|
1972
|
+
description:
|
|
1973
|
+
'Action input, when it takes one (its shape: resource_describe); confirm: true for an action that asks first.',
|
|
1974
|
+
},
|
|
1975
|
+
query: {
|
|
1976
|
+
type: 'object',
|
|
1977
|
+
description:
|
|
1978
|
+
"Query parameters, when it takes them (a plugin call's, a host's logs).",
|
|
1979
|
+
},
|
|
1980
|
+
user,
|
|
1091
1981
|
},
|
|
1092
1982
|
required: ['type', 'action'],
|
|
1093
1983
|
},
|
|
1984
|
+
annotations: readOnly
|
|
1985
|
+
? { title: 'Run a read-only action', readOnlyHint: true, openWorldHint: true }
|
|
1986
|
+
: {
|
|
1987
|
+
title: 'Run a resource action',
|
|
1988
|
+
readOnlyHint: false,
|
|
1989
|
+
destructiveHint: true,
|
|
1990
|
+
idempotentHint: false,
|
|
1991
|
+
openWorldHint: true,
|
|
1992
|
+
},
|
|
1094
1993
|
handler: toolCall,
|
|
1095
1994
|
},
|
|
1096
1995
|
];
|
|
1996
|
+
return tools
|
|
1997
|
+
.filter((t) => !readOnly || READ_TOOLS.has(t.name))
|
|
1998
|
+
.map((t) => ({ ...t, explain: (args, unknown) => explainUnknown(t.name, args, unknown) }));
|
|
1097
1999
|
}
|