@thinremote/thinr-cli 1.3.3 → 1.3.5

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 CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.5] - 2026-09-24
4
+
5
+ ### Fixed
6
+
7
+ - `thinr_ls` describes what "/" actually resolves to: the agent process's
8
+ working directory, which is normally the filesystem root. 1.3.4 said it was
9
+ a base directory "usually /var/log", which was a stale service definition on
10
+ one device rather than how installs behave.
11
+
12
+ ## [1.3.4] - 2026-09-24
13
+
14
+ ### Fixed
15
+
16
+ - `thinr_script_delete` resolves the script by the name the agent reports, so
17
+ both `battery` and `battery.sh` remove the same file. It used to build the
18
+ path from the argument verbatim, which meant following its own description
19
+ ("without extension") returned a bare 404. A name that matches nothing now
20
+ lists the scripts that are registered.
21
+
22
+ ### Changed
23
+
24
+ - `thinr_script_write` refuses a name that would replace one of the device's
25
+ built-in resources (`monitoring`, `system`, `cmd`, `update`, `agent`) unless
26
+ `force: true` is passed. Installing `monitoring.sh` used to shadow the real
27
+ monitoring resource silently. Its description now also states that the
28
+ script runs with the agent's privileges, root in a system install.
29
+ - `thinr_devices` shows when each device last changed state: `connected since`
30
+ for a live one, `offline since` for a dead one. The timestamp was already in
31
+ the response; finding out whether something dropped minutes or months ago
32
+ meant a trip to the monitoring history.
33
+ - `thinr_ls` explains that `/` resolves to the agent's base directory while
34
+ every other absolute path is literal, and a listing of `/` now says which
35
+ directory it came from. Nothing is confined to that directory, so reading a
36
+ `/` listing as the root filesystem was an easy wrong conclusion to draw.
37
+ - `thinr_profiles` states that profiles are not accounts, and that `user`
38
+ addresses another account on the same server.
39
+
3
40
  ## [1.3.3] - 2026-09-24
4
41
 
5
42
  ### 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,26 @@ async function toolWrite(args) {
24
24
 
25
25
  async function toolLs(args) {
26
26
  const { api } = getAPI(args.device_id, args.user);
27
- const entries = await api.listDir(args.path || '/', args.include_hidden || false);
27
+ const requested = args.path || '/';
28
+ const entries = await api.listDir(requested, args.include_hidden || false);
29
+
30
+ // "/" resolves through the agent process's working directory, normally
31
+ // the filesystem root, while every other absolute path is literal. An
32
+ // agent started elsewhere answers "/" with that directory instead, which
33
+ // reads as the device's root and is not, so name the directory these
34
+ // entries actually came from.
35
+ let header = '';
36
+ if (requested === '/') {
37
+ try {
38
+ const root = await api.info('/');
39
+ if (root?.path && root.path !== '/') {
40
+ header = `listing ${root.path} (the agent's working directory, which "/" resolves to)\n`;
41
+ }
42
+ } catch {
43
+ // Labelling is a convenience; a listing without it still stands.
44
+ }
45
+ }
46
+
28
47
  const lines = entries.map((e) => {
29
48
  const type = e.type === 'directory' ? 'd' : '-';
30
49
  const mode = e.mode || 'rwxr-xr-x';
@@ -32,7 +51,7 @@ async function toolLs(args) {
32
51
  return `${type}${mode} ${size} ${e.name}`;
33
52
  });
34
53
  return {
35
- content: [{ type: 'text', text: lines.join('\n') || '(empty directory)' }],
54
+ content: [{ type: 'text', text: header + (lines.join('\n') || '(empty directory)') }],
36
55
  isError: false,
37
56
  };
38
57
  }
@@ -145,11 +164,15 @@ export const tools = [
145
164
  },
146
165
  {
147
166
  name: 'thinr_ls',
148
- description: `List directory contents on a remote device. Paths are absolute on the device filesystem (e.g., "/", "/etc", "/var/log"). Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths like /etc. Use include_hidden to see dotfiles.`,
167
+ 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 process's working directory, normally the filesystem root, reported as \`fs_base_path\` by thinr_device_info. When it is not the root — an agent started from elsewhere — the listing says which directory it came from. Nothing is confined to it: "/etc" reads the real /etc. 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
168
  inputSchema: {
150
169
  type: 'object',
151
170
  properties: {
152
- path: { type: 'string', description: 'Directory path (default: /)' },
171
+ path: {
172
+ type: 'string',
173
+ description:
174
+ 'Directory path. Absolute and literal, except "/", which means the agent\'s working directory, normally the filesystem root (default: "/").',
175
+ },
153
176
  include_hidden: {
154
177
  type: 'boolean',
155
178
  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: {},
@@ -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
- const scriptPath = `${baseDir}/${args.name}`;
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 "${args.name}". ${remaining} script(s) remain registered.`,
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\` — collisions with native resources (e.g. \`monitoring\`, \`system\`) overwrite them, so pick a name that doesn't clash.
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.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinremote/thinr-cli",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "CLI for ThinRemote — remote management for IoT devices, with an MCP server for AI clients",
5
5
  "main": "bin/thinr.js",
6
6
  "bin": {