@thinger-io/cli-core 0.1.0 → 0.2.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.
@@ -0,0 +1,123 @@
1
+ // @ts-check
2
+ import { DASHBOARD_TARGETS, collectFiles, publishApp } from '../dashboard.js';
3
+ import { inputError } from '../errors.js';
4
+
5
+ async function toolPublish(args) {
6
+ if (args.dir && args.files) throw inputError('pass dir or files, not both');
7
+ if (args.files) {
8
+ if (typeof args.files !== 'object' || Array.isArray(args.files)) {
9
+ throw inputError('files must be an object of { "path": "content" }');
10
+ }
11
+ for (const [file, content] of Object.entries(args.files)) {
12
+ if (typeof content !== 'string') throw inputError(`files["${file}"] must be a string`);
13
+ }
14
+ }
15
+ const result = await publishApp({
16
+ storage: args.storage,
17
+ files: args.dir ? collectFiles(args.dir) : args.files,
18
+ prune: args.prune,
19
+ to: args.to,
20
+ preview: args.preview,
21
+ point: args.point,
22
+ bump: args.bump,
23
+ user: args.user,
24
+ });
25
+ const lines = [];
26
+ if (result.uploaded.length) {
27
+ lines.push(
28
+ `Uploaded ${result.uploaded.length} file(s) to ${result.storageCreated ? 'new public' : 'public'} storage ${result.storage}` +
29
+ (result.deleted.length
30
+ ? `; deleted ${result.deleted.length} no longer in the app.`
31
+ : '.'),
32
+ );
33
+ }
34
+ for (const p of result.pointed) lines.push(`${p.to} → ${p.url}\n open: ${p.open}`);
35
+ if (!result.pointed.length && args.point !== false) {
36
+ lines.push(
37
+ 'No dashboard was pointed: those already showing this storage load the new files the next time they open.',
38
+ );
39
+ }
40
+ if (result.previews.length) {
41
+ const until = new Date(result.previews[0].expire)
42
+ .toISOString()
43
+ .slice(0, 16)
44
+ .replace('T', ' ');
45
+ lines.push(
46
+ `Preview links (standalone pages for a browser, read-only token valid until ${until} UTC; servers that ignore token expiry keep it until the next preview replaces it; the previous preview links of this storage stop working):`,
47
+ );
48
+ for (const p of result.previews) {
49
+ lines.push(`${p.to} → ${p.url}\n layout check (website and phone sizes): ${p.layout}`);
50
+ }
51
+ }
52
+ return { content: [{ type: 'text', text: lines.join('\n') }], isError: false };
53
+ }
54
+
55
+ export const tools = [
56
+ {
57
+ name: 'dashboard_publish',
58
+ description: `Publish an HTML app to a public storage and point dashboards at it. Upload (dir or files) and point (to) are both optional: upload a new build, point more dashboards at an app already uploaded, or both. Read the dashboard SDK reference with {tool:resource_describe} type "dashboard" first.
59
+
60
+ Destinations (to): {dashboard} for the dashboards collection (created if missing, empty context), or {type, id} to set the "dashboard" property of a device, product, asset_type, asset_group or project, whose id the page gets in Thinger.context. Each may open its own entry page and an initial hash route.
61
+
62
+ One app can serve many dashboards: name its storage (e.g. app_<project>) and upload the build output folder with prune: true (hashed file names change between builds). Dashboards point at the storage folder (…/files/, served as the storage's index) or at another entry page by name, without version queries: files are served with no-cache and an ETag, so viewers always get the latest upload. Apps in the iframe: no <base> tag (relative asset paths resolve next to the entry page), hash routing (Angular: withHashLocation()), wait for Thinger.ready() before rendering (Angular: provideAppInitializer(() => Thinger.ready())), <script src="/sdk/0.1/thinger-dashboard.js"> in index.html (the SDK version the app was written for; /sdk/thinger-dashboard.js is always the latest), and no localStorage or cookies (opaque origin: keep state in properties through the SDK).`,
63
+ inputSchema: {
64
+ type: 'object',
65
+ properties: {
66
+ storage: {
67
+ type: 'string',
68
+ description:
69
+ 'Public storage of the app (created if missing). Required unless there is one destination.',
70
+ },
71
+ dir: {
72
+ type: 'string',
73
+ description:
74
+ 'Local folder to upload, subfolders included (e.g. dist/app/browser).',
75
+ },
76
+ files: {
77
+ type: 'object',
78
+ description: 'Or files inline: {"index.html": "<html>…"}.',
79
+ },
80
+ prune: {
81
+ type: 'boolean',
82
+ description: 'Delete files of the storage that are not in this upload.',
83
+ },
84
+ to: {
85
+ type: 'array',
86
+ description: 'Dashboards to point at the app.',
87
+ items: {
88
+ type: 'object',
89
+ properties: {
90
+ dashboard: { type: 'string', description: 'Collection dashboard id.' },
91
+ type: { type: 'string', enum: DASHBOARD_TARGETS },
92
+ id: { type: 'string' },
93
+ entry: { type: 'string', description: 'Page (default index.html).' },
94
+ route: {
95
+ type: 'string',
96
+ description: 'Initial route, e.g. "/devices".',
97
+ },
98
+ name: { type: 'string', description: 'Collection dashboard name.' },
99
+ },
100
+ },
101
+ },
102
+ bump: {
103
+ type: 'boolean',
104
+ description:
105
+ 'Add ?v=<publish time> to the dashboard urls so consoles that have the app open reload it at once (otherwise they get the new files on their next load).',
106
+ },
107
+ preview: {
108
+ type: ['boolean', 'number'],
109
+ description:
110
+ 'Also return standalone links to review or share each destination in a browser (whole page, scrollable, unlike the console iframe). true: valid 1 h; a number: that many hours (up to 720). Each link carries a read-only access token, one per app and project, replaced by the next preview (older links stop working); actions in the app (device resource calls, writes) answer 403 there. With only storage and preview, it links to the app without context.',
111
+ },
112
+ point: {
113
+ type: 'boolean',
114
+ description:
115
+ 'false: only build preview links for `to`, without pointing those dashboards again.',
116
+ },
117
+ user: { type: 'string', description: 'Account to act on (admin only).' },
118
+ },
119
+ required: [],
120
+ },
121
+ handler: toolPublish,
122
+ },
123
+ ];
@@ -1,280 +1,134 @@
1
1
  // @ts-check
2
- import {
3
- CREDENTIAL_DEVICE_TYPES,
4
- DEVICE_TYPES,
5
- createDevice,
6
- deleteDevice,
7
- generateDeviceCredentials,
8
- getDevice,
9
- getDevices,
10
- } from '../devices.js';
11
- import { setDeviceProduct } from '../product.js';
12
- import { inputError } from '../errors.js';
2
+ import api from '../api.js';
3
+ import { apiError, inputError } from '../errors.js';
4
+ import { device, v1 } from '../paths.js';
5
+ import { callDeviceResource, listDeviceResourcesWithSchemas } from '../resource.js';
13
6
 
14
- async function toolDevices(args) {
15
- const query = args.query ? String(args.query).trim() : '';
16
- const filter = query ? { name: query } : {};
17
- const devices = await getDevices(filter, args.user);
18
-
19
- // `connection.ts` is when the state last changed, so it reads as
20
- // "connected since" for a live device and "offline since" for a dead
21
- // one: it tells at a glance whether something dropped a minute ago
22
- // or back in July.
23
- const since = (d) => {
24
- const ts = d.connection?.ts;
25
- if (!ts) return '';
26
- const when = new Date(ts).toISOString().replace('T', ' ').slice(0, 16);
27
- return d.connection?.active ? ` connected since ${when}` : ` offline since ${when}`;
28
- };
29
-
30
- const renderLine = (d) => {
31
- const status = d.connection?.active ? 'online ' : 'offline';
32
- const label = d.name ? ` (${d.name})` : '';
33
- const type = d.type && d.type !== 'Generic' ? ` <${d.type}>` : '';
34
- const product = d.product ? ` [${d.product}]` : '';
35
- return `${status} ${d.device ?? '(record without id)'}${label}${type}${product}${since(d)}`;
36
- };
37
-
38
- if (!query) {
39
- return {
40
- content: [
41
- { type: 'text', text: devices.map(renderLine).join('\n') || 'No devices found' },
42
- ],
43
- isError: false,
44
- };
45
- }
7
+ const text = (t) => ({ content: [{ type: 'text', text: t }], isError: false });
8
+ const json = (data) =>
9
+ text(
10
+ data === undefined || data === null || data === ''
11
+ ? '(no output)'
12
+ : typeof data === 'string'
13
+ ? data
14
+ : JSON.stringify(data, null, 2),
15
+ );
46
16
 
47
- const limit = Number.isFinite(args.limit) && args.limit > 0 ? Math.floor(args.limit) : 20;
48
- const limited = devices.slice(0, limit);
49
- const header =
50
- devices.length === 0
51
- ? `No devices match "${query}"`
52
- : `${devices.length} match(es) for "${query}"${devices.length > limit ? ` — showing first ${limit}` : ''}:`;
53
- return {
54
- content: [{ type: 'text', text: [header, ...limited.map(renderLine)].join('\n') }],
55
- isError: false,
56
- };
17
+ function requireDevice(args) {
18
+ if (!args.device) throw inputError('device is required');
19
+ return args.device;
57
20
  }
58
21
 
59
- async function toolDeviceInfo(args) {
60
- const device = args.device_id;
61
- if (!device) throw inputError('device_id is required');
62
- const record = await getDevice(device, args.user);
63
- const c = record.connection || {};
64
- const meta = [];
65
- const add = (key, value) => {
66
- if (value !== undefined && value !== null && value !== '') {
67
- meta.push(`${(key + ':').padEnd(15)}${value}`);
68
- }
69
- };
70
- add('device', record.device);
71
- add('name', record.name);
72
- add('type', record.type);
73
- add('protocol', record.protocol);
74
- add('product', record.product);
75
- add('asset_group', record.asset_group);
76
- add('asset_type', record.asset_type);
77
- add('description', record.description);
78
- add('enabled', record.enabled);
79
- if (record.labels && Object.keys(record.labels).length) {
80
- add('labels', JSON.stringify(record.labels));
81
- }
82
- if (record.connection) {
83
- const since = c.ts ? ` since ${new Date(c.ts).toISOString()}` : '';
84
- add('connection', `${c.active ? 'online' : 'offline'}${since}`);
85
- add('ip_address', c.ip ?? c.ip_address);
86
- }
87
- // Everything else the server reports (traffic counters, location,
88
- // security, timeouts…) as JSON: the fields vary with the device type.
89
- const shown = new Set(['ts', 'active', 'ip', 'ip_address']);
90
- const rest = Object.fromEntries(Object.entries(c).filter(([k]) => !shown.has(k)));
91
- const text = Object.keys(rest).length
92
- ? `${meta.join('\n')}\n\nconnection details:\n${JSON.stringify(rest, null, 2)}`
93
- : meta.join('\n');
94
- return { content: [{ type: 'text', text }], isError: false };
22
+ async function toolResourceList(args) {
23
+ const entries = await listDeviceResourcesWithSchemas(requireDevice(args), {
24
+ user: args.user,
25
+ });
26
+ const kind = (f) =>
27
+ ({ 1: 'no params', 2: 'input', 3: 'output', 4: 'input/output' })[f] || 'unknown';
28
+ const lines = entries.map((e) => {
29
+ const parts = [`${e.name} [${kind(e.fn)}]`];
30
+ if (e.in !== undefined) parts.push(` in: ${JSON.stringify(e.in)}`);
31
+ if (e.out !== undefined) parts.push(` out: ${JSON.stringify(e.out)}`);
32
+ return parts.join('\n');
33
+ });
34
+ return text(lines.length ? lines.join('\n\n') : '(no resources)');
95
35
  }
96
36
 
97
- async function toolDeviceCreate(args) {
98
- if (!args.device_id) throw inputError('device_id is required');
99
- const type = args.type || 'Generic';
100
- if (!DEVICE_TYPES.includes(type)) {
101
- throw inputError(`type must be one of ${DEVICE_TYPES.join(', ')} (got "${type}")`);
102
- }
103
- const needsCredentials = CREDENTIAL_DEVICE_TYPES.has(type);
104
- if (!needsCredentials && args.credentials) {
105
- throw inputError(`${type} devices do not take credentials`);
106
- }
107
- if (needsCredentials && args.credentials && args.credentials.length < 6) {
108
- throw inputError('credentials must be at least 6 characters');
109
- }
110
- const credentials = needsCredentials
111
- ? args.credentials || generateDeviceCredentials()
112
- : undefined;
113
- const sent = await createDevice(
114
- {
115
- device: args.device_id,
116
- type,
117
- name: args.name,
118
- description: args.description,
119
- credentials,
120
- product: args.product,
121
- asset_group: args.asset_group,
122
- asset_type: args.asset_type,
123
- labels: args.labels,
124
- },
125
- args.user,
37
+ async function toolResourceCall(args) {
38
+ if (!args.resource) throw inputError('resource is required');
39
+ return json(
40
+ await callDeviceResource(requireDevice(args), args.resource, args.inputs, {
41
+ user: args.user,
42
+ }),
126
43
  );
127
- const lines = [`Created ${type} device "${sent.device}".`];
128
- if (credentials) {
129
- lines.push(
130
- `credentials: ${credentials}`,
131
- '',
132
- 'The server stores credentials hashed: this is the only time they can be read. Hand them to whoever flashes the device now.',
133
- );
134
- }
135
- return { content: [{ type: 'text', text: lines.join('\n') }], isError: false };
136
44
  }
137
45
 
138
- async function toolDeviceSetProduct(args) {
139
- const device = args.device_id;
140
- if (!device) throw inputError('device is required');
141
- if (args.product === undefined || args.product === null)
142
- throw inputError('product is required (pass "" to unassign)');
143
- await setDeviceProduct(device, args.product, args.user);
144
- const label =
145
- args.product === ''
146
- ? `Unassigned ${device} from its product.`
147
- : `Assigned ${device} → product "${args.product}".`;
148
- return { content: [{ type: 'text', text: label }], isError: false };
46
+ async function toolDataSend(args) {
47
+ const id = requireDevice(args);
48
+ if (args.data === undefined) throw inputError('data is required');
49
+ try {
50
+ const res = await api.post(`${device(args.user, id)}/callback/data`, args.data);
51
+ const answer = res.data;
52
+ const shown =
53
+ answer !== undefined &&
54
+ answer !== '' &&
55
+ (typeof answer !== 'object' || (answer && Object.keys(answer).length));
56
+ return text(`Sent data to ${id}.${shown ? `\nAnswer: ${JSON.stringify(answer)}` : ''}`);
57
+ } catch (e) {
58
+ throw apiError(e, { notFound: `Device not found: ${id}` });
59
+ }
149
60
  }
150
61
 
151
- async function toolDeviceDelete(args) {
152
- const device = args.device_id;
153
- if (!device) throw inputError('device is required');
154
- const removed = await deleteDevice(device, args.user);
155
- return {
156
- content: [
157
- {
158
- type: 'text',
159
- text: removed ? `Deleted ${device}` : `Device ${device} did not exist`,
160
- },
161
- ],
162
- isError: false,
163
- };
62
+ async function toolOverview(args) {
63
+ try {
64
+ const [stats, locations] = await Promise.all([
65
+ api.get(`${v1(args.user)}/info/devices/stats`),
66
+ args.locations ? api.get(`${v1(args.user)}/info/devices/locations`) : null,
67
+ ]);
68
+ return json(locations ? { stats: stats.data, locations: locations.data } : stats.data);
69
+ } catch (e) {
70
+ throw apiError(e);
71
+ }
164
72
  }
165
73
 
74
+ const deviceParam = { type: 'string', description: 'Device id.' };
75
+ const userParam = { type: 'string', description: 'Account to act on (admin only).' };
76
+
166
77
  export const tools = [
167
78
  {
168
- name: 'devices',
79
+ name: 'device_resource_list',
169
80
  description:
170
- 'List devices with their online/offline status (and since when), type <in angle brackets> when not Generic, and assigned [product]. Pass `query` to filter by device id or name (case-insensitive regex on the server side, so plain substrings, anchors, wildcards and alternations all work). Use this before any other device tool to discover or narrow down ids.',
81
+ "List the resources a connected device exposes, with their input/output shapes: the device's own API, declared by its firmware (a sensor to read, an output to switch), plus the API resources its product adds. Not to be confused with server resources ({family:resource}). The device must be online.",
171
82
  inputSchema: {
172
83
  type: 'object',
173
- properties: {
174
- query: {
175
- type: 'string',
176
- description:
177
- 'Optional pattern matched against device id and name. Plain substrings work ("office"); regex metacharacters are also honoured ("^raspberry", "mac|book"). Case-insensitive.',
178
- },
179
- limit: {
180
- type: 'number',
181
- description:
182
- 'Maximum matches to return when `query` is set (default: 20). Ignored when listing without a query.',
183
- },
184
- user: {
185
- type: 'string',
186
- description:
187
- 'API user (for admin impersonation). Omit to use your own account.',
188
- },
189
- },
190
- required: [],
84
+ properties: { device: deviceParam, user: userParam },
85
+ required: ['device'],
191
86
  },
192
- handler: toolDevices,
87
+ handler: toolResourceList,
193
88
  },
194
89
  {
195
- name: 'device_info',
90
+ name: 'device_resource_call',
196
91
  description:
197
- 'Get the server record of a device: type, protocol, assigned product, asset group/type, labels, description, and its connection state (online/offline since when, IP address, traffic counters and whatever else the server reports for that device type).',
198
- inputSchema: {
199
- type: 'object',
200
- properties: {
201
- device_id: { type: 'string', description: 'Device ID' },
202
- user: { type: 'string', description: 'API user' },
203
- },
204
- required: ['device_id'],
205
- },
206
- handler: toolDeviceInfo,
207
- },
208
- {
209
- name: 'device_create',
210
- description: `Register a new device on the server.
211
-
212
- Types: "Generic" (default; devices using the platform client libraries over IOTMP), "MQTT", "HTTP" (devices pushing data through HTTP callbacks), "Virtual", "Subdevice". Generic and MQTT devices authenticate with credentials: pass \`credentials\` (6+ characters) or omit them to get random ones generated. The server stores them hashed, so the response is the only place they can be read — give them to the user right away.
213
-
214
- Pass \`product\` to have the device inherit that product's profile (buckets, API resources, property handlers) from the start. Fails with code already_exists if the id is taken.`,
92
+ 'Call a resource of a connected device and return its output. With `inputs` it runs the resource; without them it only reads its output. See {tool:device_resource_list} for names and inputs.',
215
93
  inputSchema: {
216
94
  type: 'object',
217
95
  properties: {
218
- device_id: {
219
- type: 'string',
220
- description: 'New device ID (letters, digits, dash, underscore).',
221
- },
222
- type: {
223
- type: 'string',
224
- enum: ['Generic', 'MQTT', 'HTTP', 'Virtual', 'Subdevice'],
225
- description: 'Device type. Default: Generic.',
226
- },
227
- credentials: {
228
- type: 'string',
229
- description:
230
- 'Device credentials for Generic/MQTT devices (6+ characters). Omit to generate random ones.',
231
- },
232
- name: { type: 'string', description: 'Display name.' },
233
- description: { type: 'string', description: 'Free-form description.' },
234
- product: { type: 'string', description: 'Product ID to assign the device to.' },
235
- asset_group: { type: 'string', description: 'Asset group ID.' },
236
- asset_type: { type: 'string', description: 'Asset type ID.' },
237
- labels: {
238
- type: 'object',
239
- description: 'Key/value string labels, e.g. {"site": "madrid"}.',
240
- additionalProperties: { type: 'string' },
241
- },
242
- user: { type: 'string', description: 'API user' },
96
+ device: deviceParam,
97
+ resource: { type: 'string', description: 'Resource name.' },
98
+ inputs: { description: 'Input value or object the resource declares.' },
99
+ user: userParam,
243
100
  },
244
- required: ['device_id'],
101
+ required: ['device', 'resource'],
245
102
  },
246
- handler: toolDeviceCreate,
103
+ handler: toolResourceCall,
247
104
  },
248
105
  {
249
- name: 'device_set_product',
106
+ name: 'device_data_send',
250
107
  description:
251
- "Assign a device to a product, or unassign it by passing an empty string. Devices inherit the product's API resources and profile configuration (scripts, buckets, etc.) once assigned. Typical flow: configure the product profile with {tool:product_profile_bucket_set} / {tool:product_profile_api_set}, then assign devices here.",
108
+ 'Send data to the server on behalf of an HTTP device, as the device itself would: the server runs the callback configured for it (write a bucket, set a property, call an endpoint; see type device_callback).',
252
109
  inputSchema: {
253
110
  type: 'object',
254
111
  properties: {
255
- device_id: { type: 'string', description: 'Device ID' },
256
- product: {
257
- type: 'string',
258
- description: 'Product ID to assign, or empty string "" to unassign.',
259
- },
260
- user: { type: 'string', description: 'API user' },
112
+ device: deviceParam,
113
+ data: { description: 'JSON payload, e.g. {"temperature": 21.5}.' },
114
+ user: userParam,
261
115
  },
262
- required: ['device_id', 'product'],
116
+ required: ['device', 'data'],
263
117
  },
264
- handler: toolDeviceSetProduct,
118
+ handler: toolDataSend,
265
119
  },
266
120
  {
267
- name: 'device_delete',
121
+ name: 'device_overview',
268
122
  description:
269
- 'Delete a device record from the platform. Destructive and irreversible — drops the device, its server-side metadata, and stored properties. A device still running with the old credentials will fail to connect until it is registered again. Idempotent: returns success when the device was already absent.',
123
+ 'Usage of all the devices of the account over the last 30 days, per day; with `locations`, also the last known position of each connected device.',
270
124
  inputSchema: {
271
125
  type: 'object',
272
126
  properties: {
273
- device_id: { type: 'string', description: 'Device ID to delete.' },
274
- user: { type: 'string', description: 'API user' },
127
+ locations: { type: 'boolean', description: 'Include device locations.' },
128
+ user: userParam,
275
129
  },
276
- required: ['device_id'],
130
+ required: [],
277
131
  },
278
- handler: toolDeviceDelete,
132
+ handler: toolOverview,
279
133
  },
280
134
  ];
@@ -0,0 +1,48 @@
1
+ // @ts-check
2
+ import { getActiveProfile, getProfile, listProfiles } from '../config.js';
3
+ import { listDocs, readDoc } from '../docs.js';
4
+
5
+ /** The tools about the CLI itself: its documentation and its profiles. */
6
+
7
+ const text = (t) => ({ content: [{ type: 'text', text: t }], isError: false });
8
+
9
+ async function toolDocRead(args) {
10
+ if (args.id) return text((await readDoc(args.id)).text);
11
+ const docs = await listDocs();
12
+ if (!docs.length) return text('No documentation available.');
13
+ return text(
14
+ docs
15
+ .map((d) => `${d.id}: ${d.title}${d.description ? ` — ${d.description}` : ''}`)
16
+ .join('\n'),
17
+ );
18
+ }
19
+
20
+ async function toolProfileList() {
21
+ const active = getActiveProfile();
22
+ const lines = listProfiles().map((name) => {
23
+ const data = getProfile(name) || {};
24
+ return `${name === active ? '* ' : ' '}${name} (${data.username || ''}@${data.server || name})`;
25
+ });
26
+ return text(lines.join('\n') || 'No profiles configured');
27
+ }
28
+
29
+ export const tools = [
30
+ {
31
+ name: 'doc_read',
32
+ description:
33
+ "Read the guides for agents and app developers: those this CLI ships and those the server publishes (the platform's concepts, how-tos, the dashboard SDK reference…). Without `id`, lists them. Read the relevant guide before building something new.",
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: { id: { type: 'string', description: 'Document id from the list.' } },
37
+ required: [],
38
+ },
39
+ handler: toolDocRead,
40
+ },
41
+ {
42
+ name: 'profile_list',
43
+ description:
44
+ 'List the configured CLI profiles: each one is a server with its own credentials. Pass a profile name as `profile` on any other tool to use that server for one call.\n\nProfiles are not accounts: another account on the same server is reached with `user` (admin only), not with a profile.',
45
+ inputSchema: { type: 'object', properties: {}, required: [] },
46
+ handler: toolProfileList,
47
+ },
48
+ ];
@@ -0,0 +1,63 @@
1
+ // @ts-check
2
+ import api from '../api.js';
3
+ import { TIMEOUTS } from '../constants.js';
4
+ import { apiError, inputError } from '../errors.js';
5
+ import { pluginApi } from '../paths.js';
6
+
7
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
8
+
9
+ /** Call the API a plugin serves (its routes are its own: no catalog). */
10
+ async function toolPluginCall(args) {
11
+ if (!args.plugin) throw inputError('plugin is required');
12
+ const method = (args.method || 'get').toLowerCase();
13
+ if (!METHODS.includes(method)) throw inputError(`method must be one of ${METHODS.join(', ')}`);
14
+ const path = args.path ? `/${String(args.path).replace(/^\/+/, '')}` : '';
15
+ if (path.split('/').includes('..')) throw inputError('path must not contain ".."');
16
+ try {
17
+ const res = await api.request({
18
+ method,
19
+ url: `${pluginApi(args.user, args.plugin)}${path}`,
20
+ data: method === 'get' || method === 'delete' ? undefined : (args.body ?? {}),
21
+ params: args.query,
22
+ paramsSerializer: { indexes: null },
23
+ transformResponse: [(x) => x],
24
+ responseType: 'text',
25
+ timeout: TIMEOUTS.API_LONG_MS,
26
+ });
27
+ const raw = String(res.data ?? '');
28
+ let text = raw;
29
+ try {
30
+ text = JSON.stringify(JSON.parse(raw), null, 2);
31
+ } catch {}
32
+ return {
33
+ content: [{ type: 'text', text: text || `${res.status} (no content)` }],
34
+ isError: false,
35
+ };
36
+ } catch (e) {
37
+ throw apiError(e, { notFound: `Not found: plugin ${args.plugin}${path}` });
38
+ }
39
+ }
40
+
41
+ export const tools = [
42
+ {
43
+ name: 'plugin_call',
44
+ description:
45
+ 'Call the API an installed plugin serves, at its own `path` (what it offers depends on the plugin; see its documentation or try GET on its root). Find plugins with {tool:resource_list} type "plugin".',
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: {
49
+ plugin: { type: 'string', description: 'Plugin id.' },
50
+ path: {
51
+ type: 'string',
52
+ description: 'Path inside the plugin, e.g. "/api/status".',
53
+ },
54
+ method: { type: 'string', enum: METHODS, description: 'Default get.' },
55
+ body: { description: 'JSON body for post / put / patch.' },
56
+ query: { type: 'object', description: 'Query parameters.' },
57
+ user: { type: 'string', description: 'Account to act on (admin only).' },
58
+ },
59
+ required: ['plugin'],
60
+ },
61
+ handler: toolPluginCall,
62
+ },
63
+ ];