@thinremote/thinr-cli 1.3.3 → 1.3.4
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 +28 -0
- package/lib/mcp/tools-devices.js +12 -1
- package/lib/mcp/tools-filesystem.js +26 -4
- package/lib/mcp/tools-profiles.js +1 -1
- package/lib/mcp/tools-scripts.js +47 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.4] - 2026-09-24
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `thinr_script_delete` resolves the script by the name the agent reports, so
|
|
8
|
+
both `battery` and `battery.sh` remove the same file. It used to build the
|
|
9
|
+
path from the argument verbatim, which meant following its own description
|
|
10
|
+
("without extension") returned a bare 404. A name that matches nothing now
|
|
11
|
+
lists the scripts that are registered.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- `thinr_script_write` refuses a name that would replace one of the device's
|
|
16
|
+
built-in resources (`monitoring`, `system`, `cmd`, `update`, `agent`) unless
|
|
17
|
+
`force: true` is passed. Installing `monitoring.sh` used to shadow the real
|
|
18
|
+
monitoring resource silently. Its description now also states that the
|
|
19
|
+
script runs with the agent's privileges, root in a system install.
|
|
20
|
+
- `thinr_devices` shows when each device last changed state: `connected since`
|
|
21
|
+
for a live one, `offline since` for a dead one. The timestamp was already in
|
|
22
|
+
the response; finding out whether something dropped minutes or months ago
|
|
23
|
+
meant a trip to the monitoring history.
|
|
24
|
+
- `thinr_ls` explains that `/` resolves to the agent's base directory while
|
|
25
|
+
every other absolute path is literal, and a listing of `/` now says which
|
|
26
|
+
directory it came from. Nothing is confined to that directory, so reading a
|
|
27
|
+
`/` listing as the root filesystem was an easy wrong conclusion to draw.
|
|
28
|
+
- `thinr_profiles` states that profiles are not accounts, and that `user`
|
|
29
|
+
addresses another account on the same server.
|
|
30
|
+
|
|
3
31
|
## [1.3.3] - 2026-09-24
|
|
4
32
|
|
|
5
33
|
### Changed
|
package/lib/mcp/tools-devices.js
CHANGED
|
@@ -10,11 +10,22 @@ async function toolDevices(args) {
|
|
|
10
10
|
const filter = query ? { name: query } : {};
|
|
11
11
|
const devices = await getDevices(filter, args.user);
|
|
12
12
|
|
|
13
|
+
// `connection.ts` is when the state last changed, so it reads as
|
|
14
|
+
// "connected since" for a live device and "offline since" for a dead
|
|
15
|
+
// one. Showing it saves a trip to the monitoring history to find out
|
|
16
|
+
// whether something dropped a minute ago or back in July.
|
|
17
|
+
const since = (d) => {
|
|
18
|
+
const ts = d.connection?.ts;
|
|
19
|
+
if (!ts) return '';
|
|
20
|
+
const when = new Date(ts).toISOString().replace('T', ' ').slice(0, 16);
|
|
21
|
+
return d.connection?.active ? ` connected since ${when}` : ` offline since ${when}`;
|
|
22
|
+
};
|
|
23
|
+
|
|
13
24
|
const renderLine = (d) => {
|
|
14
25
|
const status = d.connection?.active ? 'online ' : 'offline';
|
|
15
26
|
const label = d.name ? ` (${d.name})` : '';
|
|
16
27
|
const product = d.type && d.type !== 'Generic' ? ` [${d.type}]` : '';
|
|
17
|
-
return `${status} ${d.device}${label}${product}`;
|
|
28
|
+
return `${status} ${d.device}${label}${product}${since(d)}`;
|
|
18
29
|
};
|
|
19
30
|
|
|
20
31
|
if (!query) {
|
|
@@ -24,7 +24,25 @@ async function toolWrite(args) {
|
|
|
24
24
|
|
|
25
25
|
async function toolLs(args) {
|
|
26
26
|
const { api } = getAPI(args.device_id, args.user);
|
|
27
|
-
const
|
|
27
|
+
const requested = args.path || '/';
|
|
28
|
+
const entries = await api.listDir(requested, args.include_hidden || false);
|
|
29
|
+
|
|
30
|
+
// "/" is the agent's base directory, not the filesystem root, while every
|
|
31
|
+
// other absolute path is literal. Listing "/" on an agent based in
|
|
32
|
+
// /var/log and reading it as the root is an easy and wrong conclusion to
|
|
33
|
+
// draw, so say which directory these entries actually came from.
|
|
34
|
+
let header = '';
|
|
35
|
+
if (requested === '/') {
|
|
36
|
+
try {
|
|
37
|
+
const root = await api.info('/');
|
|
38
|
+
if (root?.path && root.path !== '/') {
|
|
39
|
+
header = `listing ${root.path} (the agent's base directory, which "/" resolves to)\n`;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Labelling is a convenience; a listing without it still stands.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
const lines = entries.map((e) => {
|
|
29
47
|
const type = e.type === 'directory' ? 'd' : '-';
|
|
30
48
|
const mode = e.mode || 'rwxr-xr-x';
|
|
@@ -32,7 +50,7 @@ async function toolLs(args) {
|
|
|
32
50
|
return `${type}${mode} ${size} ${e.name}`;
|
|
33
51
|
});
|
|
34
52
|
return {
|
|
35
|
-
content: [{ type: 'text', text: lines.join('\n') || '(empty directory)' }],
|
|
53
|
+
content: [{ type: 'text', text: header + (lines.join('\n') || '(empty directory)') }],
|
|
36
54
|
isError: false,
|
|
37
55
|
};
|
|
38
56
|
}
|
|
@@ -145,11 +163,15 @@ export const tools = [
|
|
|
145
163
|
},
|
|
146
164
|
{
|
|
147
165
|
name: 'thinr_ls',
|
|
148
|
-
description: `List directory contents on a remote device. Paths are absolute on the device filesystem (e.g., "/", "/
|
|
166
|
+
description: `List directory contents on a remote device. Paths are absolute on the device filesystem (e.g., "/etc", "/var/log"), with one exception: "/" on its own resolves to the agent's base directory (\`fs_base_path\` in thinr_device_info), which for a system install is usually /var/log. The listing says which directory it came from when the two differ. Nothing is confined to that directory — "/etc" reads the real /etc — so do not read a "/" listing as the root filesystem. Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths. Use include_hidden to see dotfiles.`,
|
|
149
167
|
inputSchema: {
|
|
150
168
|
type: 'object',
|
|
151
169
|
properties: {
|
|
152
|
-
path: {
|
|
170
|
+
path: {
|
|
171
|
+
type: 'string',
|
|
172
|
+
description:
|
|
173
|
+
'Directory path. Absolute and literal, except "/", which means the agent\'s base directory (default: "/").',
|
|
174
|
+
},
|
|
153
175
|
include_hidden: {
|
|
154
176
|
type: 'boolean',
|
|
155
177
|
description: 'Include hidden files (default: false)',
|
|
@@ -19,7 +19,7 @@ export const tools = [
|
|
|
19
19
|
{
|
|
20
20
|
name: 'thinr_profiles',
|
|
21
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.',
|
|
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.\n\nProfiles are not accounts. Every tool also takes an optional `user`, which addresses another account on the same server when the profile has admin rights — so a device belonging to `demo` is reached with `user: "demo"`, not by looking for a profile of that name.',
|
|
23
23
|
inputSchema: {
|
|
24
24
|
type: 'object',
|
|
25
25
|
properties: {},
|
package/lib/mcp/tools-scripts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { callDeviceResource, readDeviceResource } from '../resource.js';
|
|
2
|
+
import { callDeviceResource, getDeviceResources, readDeviceResource } from '../resource.js';
|
|
3
3
|
import { inputError } from '../errors.js';
|
|
4
4
|
import { getAPI } from './helpers.js';
|
|
5
5
|
|
|
@@ -45,6 +45,27 @@ async function toolScriptWrite(args) {
|
|
|
45
45
|
}
|
|
46
46
|
const scriptPath = `${baseDir}/${args.name}`;
|
|
47
47
|
|
|
48
|
+
// The agent registers scripts at the root of the device's resource
|
|
49
|
+
// namespace, so a script named after a built-in resource replaces it and
|
|
50
|
+
// the device silently stops answering the real one. Refuse unless the
|
|
51
|
+
// caller says that is the intent. A name that already belongs to one of
|
|
52
|
+
// this device's own scripts is an update, not a collision.
|
|
53
|
+
const registeredName = args.name.replace(/\.[^./]+$/, '');
|
|
54
|
+
if (!args.force) {
|
|
55
|
+
const [native, current] = await Promise.all([
|
|
56
|
+
getDeviceResources(device, { user: args.user }).catch(() => ({})),
|
|
57
|
+
Promise.resolve(Array.isArray(info?.scripts) ? info.scripts : []),
|
|
58
|
+
]);
|
|
59
|
+
const isOwnScript = current.some((s) => s.name === registeredName);
|
|
60
|
+
if (!isOwnScript && Object.prototype.hasOwnProperty.call(native, registeredName)) {
|
|
61
|
+
throw inputError(
|
|
62
|
+
`"${registeredName}" is already a resource on this device, and installing this ` +
|
|
63
|
+
`script would replace it. Rename the script, or pass force: true if replacing ` +
|
|
64
|
+
`it is what you want.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
await deviceApi.writeFile(scriptPath, args.content, true);
|
|
49
70
|
|
|
50
71
|
const chmod = await deviceApi.exec(`chmod +x ${JSON.stringify(scriptPath)}`, 10);
|
|
@@ -53,9 +74,6 @@ async function toolScriptWrite(args) {
|
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
const reloaded = await callDeviceResource(device, '$scripts/reload', {}, { user: args.user });
|
|
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
77
|
const found = Array.isArray(reloaded?.scripts)
|
|
60
78
|
? reloaded.scripts.find((s) => s.name === registeredName)
|
|
61
79
|
: null;
|
|
@@ -90,7 +108,21 @@ async function toolScriptDelete(args) {
|
|
|
90
108
|
if (!/^[A-Za-z0-9._-]+$/.test(args.name)) {
|
|
91
109
|
throw inputError(`Invalid script name "${args.name}"`);
|
|
92
110
|
}
|
|
93
|
-
|
|
111
|
+
|
|
112
|
+
// Callers name a script either way round: `battery` as it appears in the
|
|
113
|
+
// resource list, or `battery.sh` as it sits on disk. Resolve against what
|
|
114
|
+
// the agent reports so both land on the same file, and say what is there
|
|
115
|
+
// when neither matches instead of letting a 404 through.
|
|
116
|
+
const scripts = Array.isArray(info?.scripts) ? info.scripts : [];
|
|
117
|
+
const stem = args.name.replace(/\.[^./]+$/, '');
|
|
118
|
+
const match = scripts.find((s) => s.name === stem || s.path?.endsWith(`/${args.name}`));
|
|
119
|
+
if (!match) {
|
|
120
|
+
const registered = scripts.map((s) => s.name).join(', ') || 'none';
|
|
121
|
+
throw inputError(
|
|
122
|
+
`No script named "${args.name}" on this device. Registered: ${registered}.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const scriptPath = match.path || `${baseDir}/${args.name}`;
|
|
94
126
|
|
|
95
127
|
await deviceApi.delete(scriptPath, false);
|
|
96
128
|
const reloaded = await callDeviceResource(device, '$scripts/reload', {}, { user: args.user });
|
|
@@ -99,7 +131,7 @@ async function toolScriptDelete(args) {
|
|
|
99
131
|
content: [
|
|
100
132
|
{
|
|
101
133
|
type: 'text',
|
|
102
|
-
text: `Removed script "${
|
|
134
|
+
text: `Removed script "${match.name}" (${scriptPath}). ${remaining} script(s) remain registered.`,
|
|
103
135
|
},
|
|
104
136
|
],
|
|
105
137
|
isError: false,
|
|
@@ -124,12 +156,15 @@ export const tools = [
|
|
|
124
156
|
name: 'thinr_script_write',
|
|
125
157
|
description: `Install or replace a custom script on the device and reload the scripts extension so the new resource is immediately callable.
|
|
126
158
|
|
|
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
|
|
159
|
+
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\`. A name that already belongs to a built-in resource (\`monitoring\`, \`system\`, \`cmd\`, \`update\`, \`agent\`) would replace it and leave the device no longer answering the real one, so this tool refuses that unless you pass \`force: true\`.
|
|
160
|
+
|
|
161
|
+
The script runs with the agent's privileges, which for a system install means root. Installing one grants permanent remote execution of that code on the device; treat it like adding a service, not like running a command.
|
|
128
162
|
|
|
129
163
|
Script contract:
|
|
130
164
|
- Must be executable code (any interpreter works — add a shebang like \`#!/bin/bash\` or \`#!/usr/bin/env python3\`).
|
|
131
165
|
- 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
166
|
- 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.
|
|
167
|
+
- Emit JSON that matches the \`--describe\` schema. Interpolating values you do not control (hostnames, command output, file contents) into a hand-built string breaks on the first quote or backslash; use \`jq\`, or \`python3 -c\` with \`json.dumps\` and the value passed through the environment. Output landing under \`output\` means the JSON was invalid, not that the raw form was accepted.
|
|
133
168
|
|
|
134
169
|
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
170
|
inputSchema: {
|
|
@@ -140,6 +175,11 @@ This tool writes the script, makes it executable, and calls the agent's \`$scrip
|
|
|
140
175
|
description:
|
|
141
176
|
'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
177
|
},
|
|
178
|
+
force: {
|
|
179
|
+
type: 'boolean',
|
|
180
|
+
description:
|
|
181
|
+
"Install even when the resulting resource name replaces one of the device's built-in resources.",
|
|
182
|
+
},
|
|
143
183
|
content: {
|
|
144
184
|
type: 'string',
|
|
145
185
|
description: 'Full script source code, including shebang.',
|