@thinger-io/cli-core 0.1.1 → 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.
- package/CHANGELOG.md +93 -0
- package/README.md +37 -5
- package/lib/api.js +3 -0
- package/lib/auth.js +29 -9
- package/lib/bucket.js +3 -0
- package/lib/cli.js +15 -2
- package/lib/config.js +55 -5
- package/lib/constants.js +8 -0
- package/lib/dashboard.js +605 -0
- package/lib/docs.js +182 -0
- package/lib/errors.js +29 -4
- package/lib/mcp/catalog.js +1235 -0
- package/lib/mcp/helpers.js +1 -1
- package/lib/mcp/registry.js +72 -30
- package/lib/mcp/resource-hooks.js +145 -0
- package/lib/mcp/server.js +46 -5
- package/lib/mcp/tools-buckets.js +79 -80
- package/lib/mcp/tools-dashboards.js +123 -0
- package/lib/mcp/tools-devices.js +88 -234
- package/lib/mcp/tools-misc.js +48 -0
- package/lib/mcp/tools-plugins.js +63 -0
- package/lib/mcp/tools-products.js +32 -385
- package/lib/mcp/tools-resource.js +760 -0
- package/lib/mcp/tools-storage.js +173 -0
- package/lib/openapi.js +169 -0
- package/lib/paths.js +8 -0
- package/lib/resource.js +2 -0
- package/lib/storage.js +2 -1
- package/openapi/swagger.json +1 -0
- package/package.json +2 -1
- package/lib/mcp/tools-alarms.js +0 -507
- package/lib/mcp/tools-profile.js +0 -635
- package/lib/mcp/tools-profiles.js +0 -30
- package/lib/mcp/tools-properties.js +0 -84
- package/lib/mcp/tools-resources.js +0 -83
- package/lib/mcp/tools-tokens.js +0 -370
|
@@ -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
|
+
];
|
package/lib/mcp/tools-devices.js
CHANGED
|
@@ -1,280 +1,134 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
98
|
-
if (!args.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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: '
|
|
79
|
+
name: 'device_resource_list',
|
|
169
80
|
description:
|
|
170
|
-
|
|
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
|
-
|
|
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:
|
|
87
|
+
handler: toolResourceList,
|
|
193
88
|
},
|
|
194
89
|
{
|
|
195
|
-
name: '
|
|
90
|
+
name: 'device_resource_call',
|
|
196
91
|
description:
|
|
197
|
-
'
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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: ['
|
|
101
|
+
required: ['device', 'resource'],
|
|
245
102
|
},
|
|
246
|
-
handler:
|
|
103
|
+
handler: toolResourceCall,
|
|
247
104
|
},
|
|
248
105
|
{
|
|
249
|
-
name: '
|
|
106
|
+
name: 'device_data_send',
|
|
250
107
|
description:
|
|
251
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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: ['
|
|
116
|
+
required: ['device', 'data'],
|
|
263
117
|
},
|
|
264
|
-
handler:
|
|
118
|
+
handler: toolDataSend,
|
|
265
119
|
},
|
|
266
120
|
{
|
|
267
|
-
name: '
|
|
121
|
+
name: 'device_overview',
|
|
268
122
|
description:
|
|
269
|
-
'
|
|
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
|
-
|
|
274
|
-
user:
|
|
127
|
+
locations: { type: 'boolean', description: 'Include device locations.' },
|
|
128
|
+
user: userParam,
|
|
275
129
|
},
|
|
276
|
-
required: [
|
|
130
|
+
required: [],
|
|
277
131
|
},
|
|
278
|
-
handler:
|
|
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
|
+
];
|