@thinremote/thinr-cli 1.0.2 → 1.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.md +362 -87
- package/bin/thinr.js +51 -11
- package/commands/device/_shared.js +48 -0
- package/commands/device/console.js +15 -0
- package/commands/device/exec.js +76 -0
- package/commands/device/index.js +31 -0
- package/commands/device/list.js +43 -0
- package/commands/device/property.js +50 -0
- package/commands/device/proxy.js +42 -0
- package/commands/device/resource.js +51 -0
- package/commands/device/status.js +26 -0
- package/commands/device/update.js +53 -0
- package/commands/logout.js +12 -5
- package/commands/product.js +146 -177
- package/commands/profile.js +105 -0
- package/lib/api.js +54 -31
- package/lib/auth.js +225 -309
- package/lib/config.js +176 -45
- package/lib/console.js +87 -108
- package/lib/device-api.js +257 -0
- package/lib/devices.js +69 -0
- package/lib/errors.js +53 -0
- package/lib/mcp/helpers.js +34 -0
- package/lib/mcp/registry.js +32 -0
- package/lib/mcp/server.js +63 -0
- package/lib/mcp/tools-devices.js +194 -0
- package/lib/mcp/tools-exec.js +213 -0
- package/lib/mcp/tools-filesystem.js +163 -0
- package/lib/mcp/tools-products.js +232 -0
- package/lib/mcp/tools-profiles.js +30 -0
- package/lib/mcp/tools-properties.js +79 -0
- package/lib/mcp/tools-resources.js +76 -0
- package/lib/mcp/tools-scripts.js +172 -0
- package/lib/monitoring.js +72 -0
- package/lib/output.js +114 -0
- package/lib/product.js +373 -0
- package/lib/property.js +41 -57
- package/lib/proxy.js +58 -90
- package/lib/resource.js +106 -72
- package/lib/status.js +7 -33
- package/lib/storage.js +128 -0
- package/package.json +68 -37
- package/.github/workflows/publish.yml +0 -79
- package/commands/device.js +0 -268
- package/lib/device.js +0 -76
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import {
|
|
3
|
+
getProducts,
|
|
4
|
+
deleteProductWithStorage,
|
|
5
|
+
installProductScript,
|
|
6
|
+
removeProductScript,
|
|
7
|
+
listProductScripts,
|
|
8
|
+
readProductScript,
|
|
9
|
+
getProductApi,
|
|
10
|
+
} from '../product.js';
|
|
11
|
+
import { inputError } from '../errors.js';
|
|
12
|
+
|
|
13
|
+
async function toolProducts(args) {
|
|
14
|
+
const user = args.user;
|
|
15
|
+
const products = await getProducts(user);
|
|
16
|
+
if (products.length === 0) {
|
|
17
|
+
return { content: [{ type: 'text', text: 'No products configured' }], isError: false };
|
|
18
|
+
}
|
|
19
|
+
const lines = await Promise.all(
|
|
20
|
+
products.map(async (p) => {
|
|
21
|
+
const id = p.product;
|
|
22
|
+
const label = p.name && p.name !== id ? ` (${p.name})` : '';
|
|
23
|
+
const enabled = p.enabled ? '✓' : '✗';
|
|
24
|
+
const apis = await getProductApi(id, user);
|
|
25
|
+
const scripts = Object.keys(apis).length;
|
|
26
|
+
const scriptsLabel = scripts > 0 ? ` — ${scripts} api-resource(s)` : '';
|
|
27
|
+
return `${enabled} ${id}${label}${scriptsLabel}`;
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: 'text', text: `${products.length} product(s):\n${lines.join('\n')}` }],
|
|
32
|
+
isError: false,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function toolProductDelete(args) {
|
|
37
|
+
if (!args.product) throw inputError('product is required');
|
|
38
|
+
const { steps } = await deleteProductWithStorage(args.product, args.user, {
|
|
39
|
+
keepStorage: !!args.keep_storage,
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{ type: 'text', text: `Removed "${args.product}":\n - ${steps.join('\n - ')}` },
|
|
44
|
+
],
|
|
45
|
+
isError: false,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function toolProductScriptList(args) {
|
|
50
|
+
if (!args.product) throw inputError('product is required');
|
|
51
|
+
const entries = await listProductScripts(args.product, args.user);
|
|
52
|
+
const header =
|
|
53
|
+
entries.length === 0
|
|
54
|
+
? `No scripts in product "${args.product}"`
|
|
55
|
+
: `${entries.length} script(s) in product "${args.product}":`;
|
|
56
|
+
const lines = entries.map((e) => {
|
|
57
|
+
const marker = e.registered ? '[api]' : '[orphan]';
|
|
58
|
+
return ` ${marker} ${e.name} (${e.size} bytes) → resource: ${e.stem}`;
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: 'text', text: [header, ...lines].join('\n') }],
|
|
62
|
+
isError: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function toolProductScriptRead(args) {
|
|
67
|
+
if (!args.product) throw inputError('product is required');
|
|
68
|
+
if (!args.name) throw inputError('name is required');
|
|
69
|
+
const body = await readProductScript(args.product, args.name, args.user);
|
|
70
|
+
return {
|
|
71
|
+
content: [{ type: 'text', text: body }],
|
|
72
|
+
isError: false,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function toolProductScriptWrite(args) {
|
|
77
|
+
const { steps, stem } = await installProductScript({
|
|
78
|
+
product: args.product,
|
|
79
|
+
name: args.name,
|
|
80
|
+
content: args.content,
|
|
81
|
+
user: args.user,
|
|
82
|
+
icon: args.icon,
|
|
83
|
+
});
|
|
84
|
+
const lines = [
|
|
85
|
+
`Installed product script "${args.name}" on "${args.product}".`,
|
|
86
|
+
`Steps:`,
|
|
87
|
+
...steps.map((s) => ` - ${s}`),
|
|
88
|
+
`Invoke on any device of this product with thinr_resource_call(device=<id>, resource="${stem}", inputs={input: {...}}).`,
|
|
89
|
+
];
|
|
90
|
+
return {
|
|
91
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
92
|
+
isError: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function toolProductScriptDelete(args) {
|
|
97
|
+
const { steps } = await removeProductScript({
|
|
98
|
+
product: args.product,
|
|
99
|
+
name: args.name,
|
|
100
|
+
user: args.user,
|
|
101
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
content: [
|
|
104
|
+
{
|
|
105
|
+
type: 'text',
|
|
106
|
+
text: `Removed product script "${args.name}" from "${args.product}":\n - ${steps.join('\n - ')}`,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
isError: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const tools = [
|
|
114
|
+
{
|
|
115
|
+
name: 'thinr_products',
|
|
116
|
+
description:
|
|
117
|
+
'List all products configured on the server, with their device count and whether they have product-level scripts configured. Use this before assigning a device to a product with thinr_device_set_product, or before managing product scripts with thinr_product_script_*.',
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
user: { type: 'string', description: 'API user' },
|
|
122
|
+
},
|
|
123
|
+
required: [],
|
|
124
|
+
},
|
|
125
|
+
handler: toolProducts,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'thinr_product_delete',
|
|
129
|
+
description:
|
|
130
|
+
'Delete a product and, by default, its file storage with all scripts. Destructive: confirm with the user before calling. Any device currently assigned to the product is left as-is (unassigned from it by the server) — this tool does not touch the user-level monitoring bucket, which is shared across products.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: {
|
|
134
|
+
product: { type: 'string', description: 'Product ID to delete.' },
|
|
135
|
+
keep_storage: {
|
|
136
|
+
type: 'boolean',
|
|
137
|
+
description:
|
|
138
|
+
"When true, preserve the product's file storage (scripts + index.js). Default: false (also delete the storage).",
|
|
139
|
+
},
|
|
140
|
+
user: { type: 'string', description: 'API user' },
|
|
141
|
+
},
|
|
142
|
+
required: ['product'],
|
|
143
|
+
},
|
|
144
|
+
handler: toolProductDelete,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'thinr_product_script_list',
|
|
148
|
+
description: `List product-level scripts stored under the product's file storage, cross-referenced with the API resources they expose.
|
|
149
|
+
|
|
150
|
+
Product scripts are executable files kept in a per-product storage ("<product>") under scripts/. Each script file is exposed as a product API resource (same name without the extension) that can be invoked on any device of the product via thinr_resource_call. Compared to device-level scripts (thinr_script_*), product scripts scale to large fleets because the source of truth lives server-side and is downloaded inline on each invocation — no per-device deployment, no drift.`,
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: {
|
|
154
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
155
|
+
user: { type: 'string', description: 'API user' },
|
|
156
|
+
},
|
|
157
|
+
required: ['product'],
|
|
158
|
+
},
|
|
159
|
+
handler: toolProductScriptList,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'thinr_product_script_read',
|
|
163
|
+
description: `Read the current content of a product script from the product storage. Useful to inspect or edit an existing script before rewriting it with thinr_product_script_write.`,
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
168
|
+
name: {
|
|
169
|
+
type: 'string',
|
|
170
|
+
description:
|
|
171
|
+
'Script filename as stored under scripts/ (with extension if any, e.g. "backup.sh").',
|
|
172
|
+
},
|
|
173
|
+
user: { type: 'string', description: 'API user' },
|
|
174
|
+
},
|
|
175
|
+
required: ['product', 'name'],
|
|
176
|
+
},
|
|
177
|
+
handler: toolProductScriptRead,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'thinr_product_script_write',
|
|
181
|
+
description: `Install or replace a product-level script. Orchestrates everything transparently: creates the product's storage if missing, writes the generic index.js wrapper on first use, wires profile.code.storage, enables the product, uploads the script under scripts/<name>, and creates/updates the API resource so the script is immediately callable on any device of the product.
|
|
182
|
+
|
|
183
|
+
Script contract:
|
|
184
|
+
- Any executable source. If the body starts with a shebang (#!/usr/bin/env python3, #!/bin/bash, …) the agent materializes it to a temp file and runs it with the declared interpreter. Without a shebang, the body runs through the shell (pipes, redirects, && all work).
|
|
185
|
+
- The caller's optional "input" object is forwarded to the script on stdin as JSON. Parse with jq / json.load / JSON.parse.
|
|
186
|
+
- Stdout/stderr/exit code are returned in the response.
|
|
187
|
+
|
|
188
|
+
The file extension (.sh, .py, .js, …) is preserved in the storage for editor highlighting, but the API resource name is the stem without extension. So scripts/backup.sh is callable as the resource "backup".`,
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: 'object',
|
|
191
|
+
properties: {
|
|
192
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
193
|
+
name: {
|
|
194
|
+
type: 'string',
|
|
195
|
+
description:
|
|
196
|
+
'Script filename (e.g. "backup.sh"). The stem ("backup") becomes the API resource name.',
|
|
197
|
+
},
|
|
198
|
+
content: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
description:
|
|
201
|
+
'Full script source code. Include a shebang to use a non-shell interpreter.',
|
|
202
|
+
},
|
|
203
|
+
icon: {
|
|
204
|
+
type: 'string',
|
|
205
|
+
description:
|
|
206
|
+
'Optional Font Awesome 5 icon class to use when the product is created (only applied on first write). Default: "fab fa-linux". Examples: "fab fa-apple", "fab fa-raspberry-pi", "fas fa-server".',
|
|
207
|
+
},
|
|
208
|
+
user: { type: 'string', description: 'API user' },
|
|
209
|
+
},
|
|
210
|
+
required: ['product', 'name', 'content'],
|
|
211
|
+
},
|
|
212
|
+
handler: toolProductScriptWrite,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'thinr_product_script_delete',
|
|
216
|
+
description: `Remove a product script: deletes the file under scripts/ in the product storage and the associated API resource.`,
|
|
217
|
+
inputSchema: {
|
|
218
|
+
type: 'object',
|
|
219
|
+
properties: {
|
|
220
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
221
|
+
name: {
|
|
222
|
+
type: 'string',
|
|
223
|
+
description:
|
|
224
|
+
'Script filename as stored under scripts/ (with extension if any).',
|
|
225
|
+
},
|
|
226
|
+
user: { type: 'string', description: 'API user' },
|
|
227
|
+
},
|
|
228
|
+
required: ['product', 'name'],
|
|
229
|
+
},
|
|
230
|
+
handler: toolProductScriptDelete,
|
|
231
|
+
},
|
|
232
|
+
];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { listProfiles, getActiveProfile, getProfile } from '../config.js';
|
|
3
|
+
|
|
4
|
+
async function toolProfiles() {
|
|
5
|
+
const names = listProfiles();
|
|
6
|
+
const active = getActiveProfile();
|
|
7
|
+
const lines = names.map((n) => {
|
|
8
|
+
const data = getProfile(n) || {};
|
|
9
|
+
const marker = n === active ? '* ' : ' ';
|
|
10
|
+
return `${marker}${n} (${data.username || ''}@${data.server || n})`;
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: 'text', text: lines.join('\n') || 'No profiles configured' }],
|
|
14
|
+
isError: false,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const tools = [
|
|
19
|
+
{
|
|
20
|
+
name: 'thinr_profiles',
|
|
21
|
+
description:
|
|
22
|
+
'List configured CLI profiles (each profile targets a different ThinRemote server). Use the returned profile name as the optional `profile` parameter on any other tool to target that environment for a single call.',
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {},
|
|
26
|
+
required: [],
|
|
27
|
+
},
|
|
28
|
+
handler: toolProfiles,
|
|
29
|
+
},
|
|
30
|
+
];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getDeviceProperties, getDeviceProperty, setDeviceProperty } from '../property.js';
|
|
3
|
+
import { inputError } from '../errors.js';
|
|
4
|
+
|
|
5
|
+
async function toolPropertyGet(args) {
|
|
6
|
+
const device = args.device;
|
|
7
|
+
if (!device)
|
|
8
|
+
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
9
|
+
if (args.property) {
|
|
10
|
+
const value = await getDeviceProperty(device, args.property);
|
|
11
|
+
return {
|
|
12
|
+
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
13
|
+
isError: false,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const list = await getDeviceProperties(device);
|
|
17
|
+
const names = Array.isArray(list) ? list.map((p) => p.property).filter(Boolean) : [];
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: 'text', text: names.length ? names.join('\n') : '(no properties)' }],
|
|
20
|
+
isError: false,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function toolPropertySet(args) {
|
|
25
|
+
const device = args.device;
|
|
26
|
+
if (!device)
|
|
27
|
+
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
28
|
+
if (!args.property) throw inputError('property is required');
|
|
29
|
+
if (args.value === undefined) throw inputError('value is required');
|
|
30
|
+
const saved = await setDeviceProperty(device, args.property, args.value);
|
|
31
|
+
return {
|
|
32
|
+
content: [
|
|
33
|
+
{
|
|
34
|
+
type: 'text',
|
|
35
|
+
text: `Saved property "${saved.property}" on ${saved.device} (value: ${JSON.stringify(saved.value)})`,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
isError: false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const tools = [
|
|
43
|
+
{
|
|
44
|
+
name: 'thinr_property_get',
|
|
45
|
+
description: `Read a device property. Properties are the device's structured state (e.g. configuration, last values, calibration). Call without \`property\` to list the names of every property the device has. Prefer this over thinr_exec when you need config data — it returns the raw typed value without shell parsing.`,
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
property: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description:
|
|
52
|
+
'Property name. Omit to list the names of all properties on the device.',
|
|
53
|
+
},
|
|
54
|
+
device: { type: 'string', description: 'Device ID' },
|
|
55
|
+
user: { type: 'string', description: 'API user' },
|
|
56
|
+
},
|
|
57
|
+
required: ['device'],
|
|
58
|
+
},
|
|
59
|
+
handler: toolPropertyGet,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'thinr_property_set',
|
|
63
|
+
description: `Create or overwrite a device property. The value can be any JSON-serialisable shape (string, number, object, array). Existing properties are replaced wholesale; missing ones are created. Use this instead of thinr_exec when you want to persist structured state on the device record, since properties survive device restarts and are visible from the dashboard.`,
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
property: { type: 'string', description: 'Property name to create or overwrite.' },
|
|
68
|
+
value: {
|
|
69
|
+
description:
|
|
70
|
+
'Any JSON-serialisable value (string, number, boolean, object, array).',
|
|
71
|
+
},
|
|
72
|
+
device: { type: 'string', description: 'Device ID' },
|
|
73
|
+
user: { type: 'string', description: 'API user' },
|
|
74
|
+
},
|
|
75
|
+
required: ['property', 'value', 'device'],
|
|
76
|
+
},
|
|
77
|
+
handler: toolPropertySet,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { callDeviceResource, listDeviceResourcesWithSchemas } from '../resource.js';
|
|
3
|
+
import { inputError } from '../errors.js';
|
|
4
|
+
|
|
5
|
+
async function toolResourceList(args) {
|
|
6
|
+
const device = args.device;
|
|
7
|
+
if (!device)
|
|
8
|
+
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
9
|
+
const entries = await listDeviceResourcesWithSchemas(device);
|
|
10
|
+
const fnLabel = (f) =>
|
|
11
|
+
({ 1: 'no params', 2: 'input', 3: 'output', 4: 'input/output' })[f] || 'unknown';
|
|
12
|
+
const lines = entries.map((e) => {
|
|
13
|
+
const parts = [`${e.name} [${fnLabel(e.fn)}]`];
|
|
14
|
+
if (e.in !== undefined) parts.push(` in: ${JSON.stringify(e.in)}`);
|
|
15
|
+
if (e.out !== undefined) parts.push(` out: ${JSON.stringify(e.out)}`);
|
|
16
|
+
return parts.join('\n');
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: 'text', text: lines.length ? lines.join('\n\n') : '(no resources)' }],
|
|
20
|
+
isError: false,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function toolResourceCall(args) {
|
|
25
|
+
const device = args.device;
|
|
26
|
+
if (!device)
|
|
27
|
+
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
28
|
+
if (!args.resource) throw inputError('resource is required');
|
|
29
|
+
const result = await callDeviceResource(device, args.resource, args.inputs);
|
|
30
|
+
return {
|
|
31
|
+
content: [
|
|
32
|
+
{
|
|
33
|
+
type: 'text',
|
|
34
|
+
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
isError: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const tools = [
|
|
42
|
+
{
|
|
43
|
+
name: 'thinr_resource_list',
|
|
44
|
+
description: `List the callable resources exposed by a device, with their input/output schemas when available. Resources are the device's public API — typed endpoints like "cmd" (run a shell command), "monitoring" (live metrics), "update" (upgrade the agent), "system" (OS info), etc. Prefer this before calling thinr_resource_call to discover what a device offers and how to call each resource.`,
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
device: { type: 'string', description: 'Device ID' },
|
|
49
|
+
user: { type: 'string', description: 'API user' },
|
|
50
|
+
},
|
|
51
|
+
required: ['device'],
|
|
52
|
+
},
|
|
53
|
+
handler: toolResourceList,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'thinr_resource_call',
|
|
57
|
+
description: `Invoke a resource on a device. Pass \`inputs\` when the resource declares an input schema (e.g. cmd, update); omit for read-only resources like monitoring/system/agent. Returns the resource's output. Use thinr_resource_list first to discover available resources and their expected inputs.`,
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
resource: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: 'Resource name as returned by thinr_resource_list.',
|
|
64
|
+
},
|
|
65
|
+
inputs: {
|
|
66
|
+
description:
|
|
67
|
+
'JSON object with the input parameters declared by the resource. Omit or pass {} for input-less resources.',
|
|
68
|
+
},
|
|
69
|
+
device: { type: 'string', description: 'Device ID' },
|
|
70
|
+
user: { type: 'string', description: 'API user' },
|
|
71
|
+
},
|
|
72
|
+
required: ['resource', 'device'],
|
|
73
|
+
},
|
|
74
|
+
handler: toolResourceCall,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { callDeviceResource, readDeviceResource } from '../resource.js';
|
|
3
|
+
import { inputError } from '../errors.js';
|
|
4
|
+
import { getAPI } from './helpers.js';
|
|
5
|
+
|
|
6
|
+
async function toolScriptList(args) {
|
|
7
|
+
const device = args.device;
|
|
8
|
+
if (!device)
|
|
9
|
+
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
10
|
+
const info = await readDeviceResource(device, '$scripts/info');
|
|
11
|
+
const scripts = Array.isArray(info?.scripts) ? info.scripts : [];
|
|
12
|
+
const header = `Scripts directory: ${info?.path || '(unknown)'}\n${scripts.length} script(s) registered:`;
|
|
13
|
+
const entries = scripts.map((s) => {
|
|
14
|
+
const parts = [`\n- ${s.name} (${s.path})`];
|
|
15
|
+
if (s.describe?.input !== undefined)
|
|
16
|
+
parts.push(` in: ${JSON.stringify(s.describe.input)}`);
|
|
17
|
+
if (s.describe?.output !== undefined)
|
|
18
|
+
parts.push(` out: ${JSON.stringify(s.describe.output)}`);
|
|
19
|
+
return parts.join('\n');
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
content: [{ type: 'text', text: [header, ...entries].join('\n') }],
|
|
23
|
+
isError: false,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function toolScriptWrite(args) {
|
|
28
|
+
const device = args.device;
|
|
29
|
+
if (!device)
|
|
30
|
+
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
31
|
+
if (!args.name) throw inputError('name is required');
|
|
32
|
+
if (typeof args.content !== 'string') throw inputError('content is required (string)');
|
|
33
|
+
|
|
34
|
+
const { api: deviceApi } = getAPI(device, args.user);
|
|
35
|
+
|
|
36
|
+
const info = await readDeviceResource(device, '$scripts/info');
|
|
37
|
+
const baseDir = info?.path;
|
|
38
|
+
if (!baseDir)
|
|
39
|
+
throw new Error('Agent did not return a scripts directory (is $scripts/info supported?)');
|
|
40
|
+
|
|
41
|
+
if (!/^[A-Za-z0-9._-]+$/.test(args.name)) {
|
|
42
|
+
throw inputError(
|
|
43
|
+
`Invalid script name "${args.name}" (allowed: letters, digits, dot, dash, underscore)`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const scriptPath = `${baseDir}/${args.name}`;
|
|
47
|
+
|
|
48
|
+
await deviceApi.writeFile(scriptPath, args.content, true);
|
|
49
|
+
|
|
50
|
+
const chmod = await deviceApi.exec(`chmod +x ${JSON.stringify(scriptPath)}`, 10);
|
|
51
|
+
if (chmod.retcode !== 0) {
|
|
52
|
+
throw new Error(`chmod +x failed: ${chmod.stderr || `exit ${chmod.retcode}`}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const reloaded = await callDeviceResource(device, '$scripts/reload', {});
|
|
56
|
+
// The agent registers resources by the file's stem, so "battery.sh"
|
|
57
|
+
// becomes scripts/battery. Match the same way to echo back the schema.
|
|
58
|
+
const registeredName = args.name.replace(/\.[^./]+$/, '');
|
|
59
|
+
const found = Array.isArray(reloaded?.scripts)
|
|
60
|
+
? reloaded.scripts.find((s) => s.name === registeredName)
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
const lines = [`Installed script "${registeredName}" at ${scriptPath}`];
|
|
64
|
+
if (found?.describe) {
|
|
65
|
+
lines.push(`Schema: ${JSON.stringify(found.describe)}`);
|
|
66
|
+
} else {
|
|
67
|
+
lines.push(
|
|
68
|
+
'Schema: (no --describe provided; script will accept any input and return raw output)',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
lines.push(`Resource: ${registeredName} — call it with thinr_resource_call.`);
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
74
|
+
isError: false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function toolScriptDelete(args) {
|
|
79
|
+
const device = args.device;
|
|
80
|
+
if (!device)
|
|
81
|
+
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
82
|
+
if (!args.name) throw inputError('name is required');
|
|
83
|
+
|
|
84
|
+
const { api: deviceApi } = getAPI(device, args.user);
|
|
85
|
+
const info = await readDeviceResource(device, '$scripts/info');
|
|
86
|
+
const baseDir = info?.path;
|
|
87
|
+
if (!baseDir)
|
|
88
|
+
throw new Error('Agent did not return a scripts directory (is $scripts/info supported?)');
|
|
89
|
+
|
|
90
|
+
if (!/^[A-Za-z0-9._-]+$/.test(args.name)) {
|
|
91
|
+
throw inputError(`Invalid script name "${args.name}"`);
|
|
92
|
+
}
|
|
93
|
+
const scriptPath = `${baseDir}/${args.name}`;
|
|
94
|
+
|
|
95
|
+
await deviceApi.delete(scriptPath, false);
|
|
96
|
+
const reloaded = await callDeviceResource(device, '$scripts/reload', {});
|
|
97
|
+
const remaining = Array.isArray(reloaded?.scripts) ? reloaded.scripts.length : 0;
|
|
98
|
+
return {
|
|
99
|
+
content: [
|
|
100
|
+
{
|
|
101
|
+
type: 'text',
|
|
102
|
+
text: `Removed script "${args.name}". ${remaining} script(s) remain registered.`,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
isError: false,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const tools = [
|
|
110
|
+
{
|
|
111
|
+
name: 'thinr_script_list',
|
|
112
|
+
description: `List the custom scripts registered by the agent's scripts extension, including the on-device scripts directory and each script's --describe schema. Scripts are user-provided executables in <agent_base>/scripts/ that the agent registers as first-class resources under their own names (e.g. a file named \`battery.sh\` becomes the resource \`battery\`). Use this to discover which scripted capabilities the device exposes.`,
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
device: { type: 'string', description: 'Device ID' },
|
|
117
|
+
user: { type: 'string', description: 'API user' },
|
|
118
|
+
},
|
|
119
|
+
required: ['device'],
|
|
120
|
+
},
|
|
121
|
+
handler: toolScriptList,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'thinr_script_write',
|
|
125
|
+
description: `Install or replace a custom script on the device and reload the scripts extension so the new resource is immediately callable.
|
|
126
|
+
|
|
127
|
+
Scripts turn an ad-hoc operation into a reusable, typed, discoverable resource on the device. Prefer this over thinr_exec when you want to formalise a recurring task (read sensors, rotate a log, run a backup, collect a health report) so it becomes a persistent resource with an input/output schema that future tool calls can introspect and invoke via thinr_resource_call. A script named \`battery.sh\` is registered as the resource \`battery\` — collisions with native resources (e.g. \`monitoring\`, \`system\`) overwrite them, so pick a name that doesn't clash.
|
|
128
|
+
|
|
129
|
+
Script contract:
|
|
130
|
+
- Must be executable code (any interpreter works — add a shebang like \`#!/bin/bash\` or \`#!/usr/bin/env python3\`).
|
|
131
|
+
- When invoked with \`--describe\`, it should print a JSON object with \`input\` and/or \`output\` sample objects on stdout (e.g. \`{"input": {"path": ""}, "output": {"size": 0, "mtime": 0}}\`). These populate the resource's schema.
|
|
132
|
+
- When invoked normally, the full input JSON object is passed on stdin; stdout is parsed as JSON. If stdout is a JSON object, its keys become the output fields; otherwise the value is exposed under \`output\`. Non-zero exit codes surface as errors.
|
|
133
|
+
|
|
134
|
+
This tool writes the script, makes it executable, and calls the agent's \`$scripts/reload\` so the change takes effect without restarting the agent.`,
|
|
135
|
+
inputSchema: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: {
|
|
138
|
+
name: {
|
|
139
|
+
type: 'string',
|
|
140
|
+
description:
|
|
141
|
+
'Script filename. The file extension (e.g. .sh, .py) is stripped to form the resource name, so battery.sh registers as the resource "battery".',
|
|
142
|
+
},
|
|
143
|
+
content: {
|
|
144
|
+
type: 'string',
|
|
145
|
+
description: 'Full script source code, including shebang.',
|
|
146
|
+
},
|
|
147
|
+
device: { type: 'string', description: 'Device ID' },
|
|
148
|
+
user: { type: 'string', description: 'API user' },
|
|
149
|
+
},
|
|
150
|
+
required: ['name', 'content', 'device'],
|
|
151
|
+
},
|
|
152
|
+
handler: toolScriptWrite,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'thinr_script_delete',
|
|
156
|
+
description: `Remove a previously installed custom script from the device and reload the scripts extension so the corresponding resource is unregistered. Use this to retire a scripted capability you no longer want the device to expose.`,
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: 'object',
|
|
159
|
+
properties: {
|
|
160
|
+
name: {
|
|
161
|
+
type: 'string',
|
|
162
|
+
description:
|
|
163
|
+
'Name of the script to remove (must match the file basename, without extension).',
|
|
164
|
+
},
|
|
165
|
+
device: { type: 'string', description: 'Device ID' },
|
|
166
|
+
user: { type: 'string', description: 'API user' },
|
|
167
|
+
},
|
|
168
|
+
required: ['name', 'device'],
|
|
169
|
+
},
|
|
170
|
+
handler: toolScriptDelete,
|
|
171
|
+
},
|
|
172
|
+
];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import api from './api.js';
|
|
2
|
+
import { apiError } from './errors.js';
|
|
3
|
+
import { requireConfig } from './config.js';
|
|
4
|
+
|
|
5
|
+
function resolveUser(user) {
|
|
6
|
+
return user || requireConfig().username;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Query the monitoring bucket. When `device` is provided, scopes to
|
|
11
|
+
* that device's bucket; otherwise queries the user-level bucket
|
|
12
|
+
* across every device of the account.
|
|
13
|
+
*
|
|
14
|
+
* Time range:
|
|
15
|
+
* - `minutes`: shortcut for "last N minutes" (sets min_ts to
|
|
16
|
+
* `Date.now() - minutes * 60_000`, max_ts to 0).
|
|
17
|
+
* - `min_ts` / `max_ts`: explicit unix-millis range when `minutes`
|
|
18
|
+
* is not supplied; either may be omitted for an open bound.
|
|
19
|
+
*
|
|
20
|
+
* Aggregation: `agg` is the bucket size (e.g. "5m"), `agg_type` the
|
|
21
|
+
* reducer (e.g. "avg"), and `fields` a comma-separated allowlist to
|
|
22
|
+
* trim the response down.
|
|
23
|
+
*
|
|
24
|
+
* @param {{
|
|
25
|
+
* device?: string | null,
|
|
26
|
+
* user?: string | null,
|
|
27
|
+
* items?: number,
|
|
28
|
+
* sort?: string,
|
|
29
|
+
* minutes?: number,
|
|
30
|
+
* min_ts?: number,
|
|
31
|
+
* max_ts?: number,
|
|
32
|
+
* agg?: string,
|
|
33
|
+
* agg_type?: string,
|
|
34
|
+
* fields?: string,
|
|
35
|
+
* }} [opts]
|
|
36
|
+
*/
|
|
37
|
+
export async function getMonitoringData({
|
|
38
|
+
device = null,
|
|
39
|
+
user = null,
|
|
40
|
+
items = 10,
|
|
41
|
+
sort = 'desc',
|
|
42
|
+
minutes,
|
|
43
|
+
min_ts,
|
|
44
|
+
max_ts,
|
|
45
|
+
agg,
|
|
46
|
+
agg_type,
|
|
47
|
+
fields,
|
|
48
|
+
} = {}) {
|
|
49
|
+
const apiUser = resolveUser(user);
|
|
50
|
+
const params = { items, sort };
|
|
51
|
+
if (minutes) {
|
|
52
|
+
params.min_ts = Date.now() - minutes * 60 * 1000;
|
|
53
|
+
params.max_ts = 0;
|
|
54
|
+
} else {
|
|
55
|
+
if (min_ts) params.min_ts = min_ts;
|
|
56
|
+
if (max_ts !== undefined) params.max_ts = max_ts;
|
|
57
|
+
}
|
|
58
|
+
if (agg) params.agg = agg;
|
|
59
|
+
if (agg_type) params.agg_type = agg_type;
|
|
60
|
+
if (fields) params.fields = fields;
|
|
61
|
+
|
|
62
|
+
const url = device
|
|
63
|
+
? `/v3/users/${apiUser}/devices/${device}/buckets/monitoring/data`
|
|
64
|
+
: `/v2/users/${apiUser}/buckets/monitoring/data`;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const res = await api.get(url, { params });
|
|
68
|
+
return res.data;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw apiError(e);
|
|
71
|
+
}
|
|
72
|
+
}
|