@thinremote/thinr-cli 1.3.2 → 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 CHANGED
@@ -1,5 +1,46 @@
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
+
31
+ ## [1.3.3] - 2026-09-24
32
+
33
+ ### Changed
34
+
35
+ - MCP tools take the target device as `device_id`. A tool argument named
36
+ `device` never reaches this server through Claude Desktop's remote-devices
37
+ bridge, which claims that name for its own call routing and strips it, so
38
+ every tool needing a device failed with `device is required` however the
39
+ caller spelled the call, while tools without one worked. Reproduced on one
40
+ machine with one binary: a direct JSON-RPC call succeeded, the same call
41
+ through Claude Desktop arrived without the field, and renaming the argument
42
+ fixed it with nothing else changed. `device` is no longer accepted.
43
+
3
44
  ## [1.3.2] - 2026-09-24
4
45
 
5
46
  ### Fixed
@@ -43,9 +43,7 @@ function normalizeSeverityInput(value) {
43
43
  for (const v of arr) {
44
44
  const parsed = parseAlarmSeverity(v);
45
45
  if (parsed === undefined) {
46
- throw inputError(
47
- `Unknown alarm severity "${v}". Valid: low, medium, high, critical.`,
48
- );
46
+ throw inputError(`Unknown alarm severity "${v}". Valid: low, medium, high, critical.`);
49
47
  }
50
48
  out.push(parsed);
51
49
  }
@@ -195,7 +193,8 @@ async function toolAlarmInstanceUpdate(args) {
195
193
  content: [
196
194
  {
197
195
  type: 'text',
198
- text: `Updated ${args.instance} → state=${stateLabel}` +
196
+ text:
197
+ `Updated ${args.instance} → state=${stateLabel}` +
199
198
  (updated?.annotation ? `, annotation="${updated.annotation}"` : ''),
200
199
  },
201
200
  ],
@@ -228,9 +227,7 @@ async function toolAlarmRules(args) {
228
227
  return `${enabled} ${id}${name}${desc}`;
229
228
  });
230
229
  return {
231
- content: [
232
- { type: 'text', text: `${rules.length} alarm rule(s):\n${lines.join('\n')}` },
233
- ],
230
+ content: [{ type: 'text', text: `${rules.length} alarm rule(s):\n${lines.join('\n')}` }],
234
231
  isError: false,
235
232
  };
236
233
  }
@@ -9,7 +9,7 @@ async function toolBucketRead(args) {
9
9
  );
10
10
  const data = await getBucketData({
11
11
  bucket: args.bucket,
12
- device: args.device,
12
+ device: args.device_id,
13
13
  user: args.user,
14
14
  items: args.items || 10,
15
15
  sort: args.sort || 'desc',
@@ -22,9 +22,7 @@ async function toolBucketRead(args) {
22
22
  group_by: args.group_by,
23
23
  });
24
24
  const text =
25
- Array.isArray(data) && data.length === 0
26
- ? 'No data'
27
- : JSON.stringify(data, null, 2);
25
+ Array.isArray(data) && data.length === 0 ? 'No data' : JSON.stringify(data, null, 2);
28
26
  return {
29
27
  content: [{ type: 'text', text }],
30
28
  isError: false,
@@ -49,10 +47,9 @@ Custom buckets may not share the \`monitoring\` schema — don't assume a \`devi
49
47
  description:
50
48
  'Bucket ID as declared in the product profile (e.g. "monitoring", or a custom one like "sensors", "events").',
51
49
  },
52
- device: {
50
+ device_id: {
53
51
  type: 'string',
54
- description:
55
- 'Device ID. Omit for a one-row-per-device fleet view.',
52
+ description: 'Device ID. Omit for a one-row-per-device fleet view.',
56
53
  },
57
54
  items: {
58
55
  type: 'number',
@@ -61,8 +58,7 @@ Custom buckets may not share the \`monitoring\` schema — don't assume a \`devi
61
58
  },
62
59
  minutes: {
63
60
  type: 'number',
64
- description:
65
- 'Get data from the last N minutes. Alternative to min_ts/max_ts.',
61
+ description: 'Get data from the last N minutes. Alternative to min_ts/max_ts.',
66
62
  },
67
63
  min_ts: { type: 'number', description: 'Minimum timestamp in milliseconds' },
68
64
  max_ts: {
@@ -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) {
@@ -39,7 +50,7 @@ async function toolDevices(args) {
39
50
  }
40
51
 
41
52
  async function toolDeviceInfo(args) {
42
- const { api: deviceApi } = getAPI(args.device, args.user);
53
+ const { api: deviceApi } = getAPI(args.device_id, args.user);
43
54
  const parts = [];
44
55
 
45
56
  // Server-side record first — surfaces the assigned product,
@@ -47,7 +58,7 @@ async function toolDeviceInfo(args) {
47
58
  // can't see. Best effort: don't fail the whole tool if the
48
59
  // record can't be read.
49
60
  try {
50
- const record = await getDevice(args.device, args.user);
61
+ const record = await getDevice(args.device_id, args.user);
51
62
  const meta = [];
52
63
  if (record.device) meta.push(`device: ${record.device}`);
53
64
  if (record.name) meta.push(`name: ${record.name}`);
@@ -120,7 +131,7 @@ async function toolDeviceInfo(args) {
120
131
  }
121
132
 
122
133
  async function toolDeviceSetProduct(args) {
123
- const device = args.device;
134
+ const device = args.device_id;
124
135
  if (!device) throw inputError('device is required');
125
136
  if (args.product === undefined || args.product === null)
126
137
  throw inputError('product is required (pass "" to unassign)');
@@ -133,7 +144,7 @@ async function toolDeviceSetProduct(args) {
133
144
  }
134
145
 
135
146
  async function toolDeviceDelete(args) {
136
- const device = args.device;
147
+ const device = args.device_id;
137
148
  if (!device) throw inputError('device is required');
138
149
  const removed = await deleteDevice(device, args.user);
139
150
  return {
@@ -182,10 +193,10 @@ export const tools = [
182
193
  inputSchema: {
183
194
  type: 'object',
184
195
  properties: {
185
- device: { type: 'string', description: 'Device ID' },
196
+ device_id: { type: 'string', description: 'Device ID' },
186
197
  user: { type: 'string', description: 'API user' },
187
198
  },
188
- required: ['device'],
199
+ required: ['device_id'],
189
200
  },
190
201
  handler: toolDeviceInfo,
191
202
  },
@@ -196,14 +207,14 @@ export const tools = [
196
207
  inputSchema: {
197
208
  type: 'object',
198
209
  properties: {
199
- device: { type: 'string', description: 'Device ID' },
210
+ device_id: { type: 'string', description: 'Device ID' },
200
211
  product: {
201
212
  type: 'string',
202
213
  description: 'Product ID to assign, or empty string "" to unassign.',
203
214
  },
204
215
  user: { type: 'string', description: 'API user' },
205
216
  },
206
- required: ['device', 'product'],
217
+ required: ['device_id', 'product'],
207
218
  },
208
219
  handler: toolDeviceSetProduct,
209
220
  },
@@ -214,10 +225,10 @@ export const tools = [
214
225
  inputSchema: {
215
226
  type: 'object',
216
227
  properties: {
217
- device: { type: 'string', description: 'Device ID to delete.' },
228
+ device_id: { type: 'string', description: 'Device ID to delete.' },
218
229
  user: { type: 'string', description: 'API user' },
219
230
  },
220
- required: ['device'],
231
+ required: ['device_id'],
221
232
  },
222
233
  handler: toolDeviceDelete,
223
234
  },
@@ -5,7 +5,7 @@ import { BYTES, TIMEOUTS } from '../constants.js';
5
5
  import { getAPI } from './helpers.js';
6
6
 
7
7
  async function toolExec(args) {
8
- const { api } = getAPI(args.device, args.user);
8
+ const { api } = getAPI(args.device_id, args.user);
9
9
  // Default guards against hung commands locking the session; callers
10
10
  // override for known-long operations. Knob lives in lib/constants.js.
11
11
  const timeout =
@@ -33,7 +33,7 @@ async function toolExec(args) {
33
33
  }
34
34
 
35
35
  async function toolUpdate(args) {
36
- const { api: deviceApi } = getAPI(args.device, args.user);
36
+ const { api: deviceApi } = getAPI(args.device_id, args.user);
37
37
  const payload = {
38
38
  action: args.action,
39
39
  channel: args.channel || 'latest',
@@ -46,7 +46,7 @@ async function toolUpdate(args) {
46
46
  }
47
47
 
48
48
  async function toolMonitoring(args) {
49
- const device = args.device;
49
+ const device = args.device_id;
50
50
  const user = args.user;
51
51
 
52
52
  // Real-time: read the device's `monitoring` resource directly.
@@ -143,11 +143,11 @@ export const tools = [
143
143
  type: 'object',
144
144
  properties: {
145
145
  command: { type: 'string', description: 'Shell command to execute' },
146
- device: { type: 'string', description: 'Device ID (from thinr_devices)' },
146
+ device_id: { type: 'string', description: 'Device ID (from thinr_devices)' },
147
147
  timeout: { type: 'number', description: 'Timeout in seconds (default: 30)' },
148
148
  user: { type: 'string', description: 'API user (for admin impersonation)' },
149
149
  },
150
- required: ['command', 'device'],
150
+ required: ['command', 'device_id'],
151
151
  },
152
152
  handler: toolExec,
153
153
  },
@@ -157,7 +157,7 @@ export const tools = [
157
157
  inputSchema: {
158
158
  type: 'object',
159
159
  properties: {
160
- device: { type: 'string', description: 'Device ID' },
160
+ device_id: { type: 'string', description: 'Device ID' },
161
161
  action: {
162
162
  type: 'string',
163
163
  description: '"check" to check for updates, "apply" to install the update',
@@ -165,7 +165,7 @@ export const tools = [
165
165
  channel: { type: 'string', description: 'Update channel (default: "latest")' },
166
166
  user: { type: 'string', description: 'API user' },
167
167
  },
168
- required: ['action', 'device'],
168
+ required: ['action', 'device_id'],
169
169
  },
170
170
  handler: toolUpdate,
171
171
  },
@@ -185,14 +185,15 @@ Modes:
185
185
  description:
186
186
  'If true, get live data directly from the device. If false/omitted, query historical data from the monitoring bucket.',
187
187
  },
188
- device: {
188
+ device_id: {
189
189
  type: 'string',
190
190
  description:
191
191
  'Device ID. Required for realtime. Omit for a one-row-per-device fleet view.',
192
192
  },
193
193
  items: {
194
194
  type: 'number',
195
- description: 'Number of data points to return (default: 10). In multi-device mode set ≥ number of devices (e.g. 200) since each device contributes one row.',
195
+ description:
196
+ 'Number of data points to return (default: 10). In multi-device mode set ≥ number of devices (e.g. 200) since each device contributes one row.',
196
197
  },
197
198
  minutes: {
198
199
  type: 'number',
@@ -205,12 +206,18 @@ Modes:
205
206
  },
206
207
  sort: {
207
208
  type: 'string',
208
- description: 'Sort order by timestamp: "asc" or "desc" (default: "desc"). Does NOT sort by metric — do that client-side on the result.',
209
+ description:
210
+ 'Sort order by timestamp: "asc" or "desc" (default: "desc"). Does NOT sort by metric — do that client-side on the result.',
211
+ },
212
+ agg: {
213
+ type: 'string',
214
+ description:
215
+ 'Time-window size for windowed aggregation: "5m", "10m", "1h", "6h". Only meaningful with a specific device. For a per-device single value across the whole range, omit `agg` and pass `agg_type` alone.',
209
216
  },
210
- agg: { type: 'string', description: 'Time-window size for windowed aggregation: "5m", "10m", "1h", "6h". Only meaningful with a specific device. For a per-device single value across the whole range, omit `agg` and pass `agg_type` alone.' },
211
217
  agg_type: {
212
218
  type: 'string',
213
- description: 'Reducer applied to each group/window. Supported: "mean", "max", "min", "sum", "count", "first", "last", "median", "spread" (max-min), "stddev". Most common for monitoring: max, mean, min.',
219
+ description:
220
+ 'Reducer applied to each group/window. Supported: "mean", "max", "min", "sum", "count", "first", "last", "median", "spread" (max-min), "stddev". Most common for monitoring: max, mean, min.',
214
221
  },
215
222
  fields: {
216
223
  type: 'string',
@@ -5,7 +5,7 @@ import { getAPI } from './helpers.js';
5
5
  import { inputError } from '../errors.js';
6
6
 
7
7
  async function toolRead(args) {
8
- const { api } = getAPI(args.device, args.user);
8
+ const { api } = getAPI(args.device_id, args.user);
9
9
  const content = await api.readFile(args.path);
10
10
  return {
11
11
  content: [{ type: 'text', text: content.toString('utf8') }],
@@ -14,7 +14,7 @@ async function toolRead(args) {
14
14
  }
15
15
 
16
16
  async function toolWrite(args) {
17
- const { api } = getAPI(args.device, args.user);
17
+ const { api } = getAPI(args.device_id, args.user);
18
18
  await api.writeFile(args.path, Buffer.from(args.content, 'utf8'));
19
19
  return {
20
20
  content: [{ type: 'text', text: `Written ${args.content.length} bytes to ${args.path}` }],
@@ -23,8 +23,26 @@ async function toolWrite(args) {
23
23
  }
24
24
 
25
25
  async function toolLs(args) {
26
- const { api } = getAPI(args.device, args.user);
27
- const entries = await api.listDir(args.path || '/', args.include_hidden || false);
26
+ const { api } = getAPI(args.device_id, args.user);
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,13 +50,13 @@ 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
  }
39
57
 
40
58
  async function toolMkdir(args) {
41
- const { api } = getAPI(args.device, args.user);
59
+ const { api } = getAPI(args.device_id, args.user);
42
60
  await api.mkdir(args.path);
43
61
  return {
44
62
  content: [{ type: 'text', text: `Created directory: ${args.path}` }],
@@ -47,7 +65,7 @@ async function toolMkdir(args) {
47
65
  }
48
66
 
49
67
  async function toolRm(args) {
50
- const { api } = getAPI(args.device, args.user);
68
+ const { api } = getAPI(args.device_id, args.user);
51
69
  await api.delete(args.path, args.recursive !== false);
52
70
  return {
53
71
  content: [{ type: 'text', text: `Deleted: ${args.path}` }],
@@ -56,7 +74,7 @@ async function toolRm(args) {
56
74
  }
57
75
 
58
76
  async function toolMv(args) {
59
- const { api } = getAPI(args.device, args.user);
77
+ const { api } = getAPI(args.device_id, args.user);
60
78
  await api.move(args.source, args.destination, args.overwrite || false);
61
79
  return {
62
80
  content: [{ type: 'text', text: `Moved: ${args.source} → ${args.destination}` }],
@@ -75,7 +93,7 @@ async function toolPush(args) {
75
93
  const remotePath = args.destination.endsWith('/')
76
94
  ? args.destination + basename(localPath)
77
95
  : args.destination;
78
- const { api } = getAPI(args.device, args.user);
96
+ const { api } = getAPI(args.device_id, args.user);
79
97
  const content = await readLocalFile(localPath);
80
98
  await api.writeFile(remotePath, content);
81
99
  return {
@@ -99,7 +117,7 @@ async function toolPull(args) {
99
117
  const localPath = args.destination.endsWith('/')
100
118
  ? resolvePath(args.destination, basename(args.source))
101
119
  : resolvePath(args.destination);
102
- const { api } = getAPI(args.device, args.user);
120
+ const { api } = getAPI(args.device_id, args.user);
103
121
  const buf = await api.readFile(args.source);
104
122
  await writeLocalFile(localPath, buf);
105
123
  return {
@@ -121,10 +139,10 @@ export const tools = [
121
139
  type: 'object',
122
140
  properties: {
123
141
  path: { type: 'string', description: 'Absolute file path on the device' },
124
- device: { type: 'string', description: 'Device ID' },
142
+ device_id: { type: 'string', description: 'Device ID' },
125
143
  user: { type: 'string', description: 'API user' },
126
144
  },
127
- required: ['path', 'device'],
145
+ required: ['path', 'device_id'],
128
146
  },
129
147
  handler: toolRead,
130
148
  },
@@ -136,28 +154,32 @@ export const tools = [
136
154
  properties: {
137
155
  path: { type: 'string', description: 'Absolute file path on the device' },
138
156
  content: { type: 'string', description: 'File content to write' },
139
- device: { type: 'string', description: 'Device ID' },
157
+ device_id: { type: 'string', description: 'Device ID' },
140
158
  user: { type: 'string', description: 'API user' },
141
159
  },
142
- required: ['path', 'content', 'device'],
160
+ required: ['path', 'content', 'device_id'],
143
161
  },
144
162
  handler: toolWrite,
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., "/", "/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.`,
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: { type: 'string', description: 'Directory path (default: /)' },
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)',
156
178
  },
157
- device: { type: 'string', description: 'Device ID' },
179
+ device_id: { type: 'string', description: 'Device ID' },
158
180
  user: { type: 'string', description: 'API user' },
159
181
  },
160
- required: ['device'],
182
+ required: ['device_id'],
161
183
  },
162
184
  handler: toolLs,
163
185
  },
@@ -168,10 +190,10 @@ export const tools = [
168
190
  type: 'object',
169
191
  properties: {
170
192
  path: { type: 'string', description: 'Directory path to create' },
171
- device: { type: 'string', description: 'Device ID' },
193
+ device_id: { type: 'string', description: 'Device ID' },
172
194
  user: { type: 'string', description: 'API user' },
173
195
  },
174
- required: ['path', 'device'],
196
+ required: ['path', 'device_id'],
175
197
  },
176
198
  handler: toolMkdir,
177
199
  },
@@ -186,10 +208,10 @@ export const tools = [
186
208
  type: 'boolean',
187
209
  description: 'Delete directory contents recursively (default: true)',
188
210
  },
189
- device: { type: 'string', description: 'Device ID' },
211
+ device_id: { type: 'string', description: 'Device ID' },
190
212
  user: { type: 'string', description: 'API user' },
191
213
  },
192
- required: ['path', 'device'],
214
+ required: ['path', 'device_id'],
193
215
  },
194
216
  handler: toolRm,
195
217
  },
@@ -205,10 +227,10 @@ export const tools = [
205
227
  type: 'boolean',
206
228
  description: 'Overwrite destination if exists (default: false)',
207
229
  },
208
- device: { type: 'string', description: 'Device ID' },
230
+ device_id: { type: 'string', description: 'Device ID' },
209
231
  user: { type: 'string', description: 'API user' },
210
232
  },
211
- required: ['source', 'destination', 'device'],
233
+ required: ['source', 'destination', 'device_id'],
212
234
  },
213
235
  handler: toolMv,
214
236
  },
@@ -227,10 +249,10 @@ export const tools = [
227
249
  description:
228
250
  'Absolute path on the device. Trailing "/" keeps the source basename.',
229
251
  },
230
- device: { type: 'string', description: 'Device ID' },
252
+ device_id: { type: 'string', description: 'Device ID' },
231
253
  user: { type: 'string', description: 'API user' },
232
254
  },
233
- required: ['source', 'destination', 'device'],
255
+ required: ['source', 'destination', 'device_id'],
234
256
  },
235
257
  handler: toolPush,
236
258
  },
@@ -249,10 +271,10 @@ export const tools = [
249
271
  description:
250
272
  'Local file path (absolute or relative to the MCP server CWD). Trailing "/" keeps the source basename.',
251
273
  },
252
- device: { type: 'string', description: 'Device ID' },
274
+ device_id: { type: 'string', description: 'Device ID' },
253
275
  user: { type: 'string', description: 'API user' },
254
276
  },
255
- required: ['source', 'destination', 'device'],
277
+ required: ['source', 'destination', 'device_id'],
256
278
  },
257
279
  handler: toolPull,
258
280
  },
@@ -41,7 +41,7 @@ async function toolAgentInstallCommand(args) {
41
41
  const url = `${scheme}://get.thinremote.io/${script}`;
42
42
 
43
43
  const agentArgs = ['install', '--token', shQuote(config.token)];
44
- if (args.device) agentArgs.push('--device', shQuote(args.device));
44
+ if (args.device_id) agentArgs.push('--device', shQuote(args.device_id));
45
45
  if (args.product) agentArgs.push('--product', shQuote(args.product));
46
46
  if (args.overwrite) agentArgs.push('--overwrite');
47
47
  if (args.no_start) agentArgs.push('--no-start');
@@ -83,7 +83,7 @@ async function toolAgentInstallCommand(args) {
83
83
  command,
84
84
  '',
85
85
  'Notes:',
86
- '- The command embeds the active profile\'s API token — treat it as a credential.',
86
+ "- The command embeds the active profile's API token — treat it as a credential.",
87
87
  '- Installing the system service needs root. Pass `sudo: true` if the target host is not already root.',
88
88
  '- The agent reads the target server from the JWT, so `--host` is not needed.',
89
89
  ];
@@ -106,10 +106,10 @@ export const tools = [
106
106
  inputSchema: {
107
107
  type: 'object',
108
108
  properties: {
109
- device: {
109
+ device_id: {
110
110
  type: 'string',
111
111
  description:
112
- 'Custom device identifier. Omit to let the agent default to the target host\'s hostname.',
112
+ "Custom device identifier. Omit to let the agent default to the target host's hostname.",
113
113
  },
114
114
  product: {
115
115
  type: 'string',
@@ -127,8 +127,7 @@ export const tools = [
127
127
  },
128
128
  no_verify_ssl: {
129
129
  type: 'boolean',
130
- description:
131
- 'Disable TLS certificate verification (for self-signed servers).',
130
+ description: 'Disable TLS certificate verification (for self-signed servers).',
132
131
  },
133
132
  channel: {
134
133
  type: 'string',
@@ -170,15 +170,15 @@ Response carries a consolidated report: devices visited, ok / failed counts, and
170
170
  },
171
171
  target_product: {
172
172
  type: 'string',
173
- description: 'Override the playbook\'s target.product.',
173
+ description: "Override the playbook's target.product.",
174
174
  },
175
175
  target_group: {
176
176
  type: 'string',
177
- description: 'Override the playbook\'s target.group.',
177
+ description: "Override the playbook's target.group.",
178
178
  },
179
179
  target_devices: {
180
180
  type: 'array',
181
- description: 'Override the playbook\'s target.devices with an explicit list.',
181
+ description: "Override the playbook's target.devices with an explicit list.",
182
182
  },
183
183
  concurrency: {
184
184
  type: 'number',
@@ -187,7 +187,7 @@ Response carries a consolidated report: devices visited, ok / failed counts, and
187
187
  fail_fast: {
188
188
  type: 'boolean',
189
189
  description:
190
- 'Stop dequeueing new devices on the first failure (default: playbook\'s own value).',
190
+ "Stop dequeueing new devices on the first failure (default: playbook's own value).",
191
191
  },
192
192
  continue_on_error: {
193
193
  type: 'boolean',
@@ -196,8 +196,7 @@ Response carries a consolidated report: devices visited, ok / failed counts, and
196
196
  },
197
197
  vars: {
198
198
  type: 'object',
199
- description:
200
- 'Merge into the playbook\'s `vars` block before substitution.',
199
+ description: "Merge into the playbook's `vars` block before substitution.",
201
200
  },
202
201
  user: {
203
202
  type: 'string',
@@ -141,20 +141,14 @@ async function toolProductUpdate(args) {
141
141
 
142
142
  const fields = Object.keys(patch);
143
143
  if (fields.length === 0) {
144
- throw inputError(
145
- 'at least one of name, description, enabled, icon must be provided',
146
- );
144
+ throw inputError('at least one of name, description, enabled, icon must be provided');
147
145
  }
148
146
 
149
147
  await updateProduct(args.product, args.user, patch);
150
148
 
151
- const summary = fields
152
- .map((f) => (f === 'config' ? 'icon' : f))
153
- .join(', ');
149
+ const summary = fields.map((f) => (f === 'config' ? 'icon' : f)).join(', ');
154
150
  return {
155
- content: [
156
- { type: 'text', text: `Updated product "${args.product}" (${summary}).` },
157
- ],
151
+ content: [{ type: 'text', text: `Updated product "${args.product}" (${summary}).` }],
158
152
  isError: false,
159
153
  };
160
154
  }
@@ -430,7 +424,7 @@ async function toolProductPlaybookWrite(args) {
430
424
  async function toolProductPlaybookRun(args) {
431
425
  if (!args.product) throw inputError('product is required');
432
426
  if (!args.name) throw inputError('name is required');
433
- if (!args.device) throw inputError('device is required (single-device only)');
427
+ if (!args.device_id) throw inputError('device is required (single-device only)');
434
428
 
435
429
  const entry = await findProductPlaybook(args.product, args.name, args.user);
436
430
  if (!entry) {
@@ -453,7 +447,7 @@ async function toolProductPlaybookRun(args) {
453
447
  throw inputError(err.message);
454
448
  }
455
449
 
456
- pb.target.devices = [args.device];
450
+ pb.target.devices = [args.device_id];
457
451
  pb.target.product = args.product;
458
452
  pb.target.group = null;
459
453
 
@@ -463,7 +457,7 @@ async function toolProductPlaybookRun(args) {
463
457
  dry_run: true,
464
458
  product: args.product,
465
459
  name: args.name,
466
- device: args.device,
460
+ device: args.device_id,
467
461
  variables: listVariables(pb),
468
462
  vars: resolvedScope,
469
463
  steps: plan,
@@ -475,11 +469,10 @@ async function toolProductPlaybookRun(args) {
475
469
  }
476
470
 
477
471
  const all = await getDevices({ product: args.product }, args.user);
478
- const device =
479
- all.find((d) => d.device === args.device) || {
480
- device: args.device,
481
- connection: { active: false },
482
- };
472
+ const device = all.find((d) => d.device === args.device_id) || {
473
+ device: args.device_id,
474
+ connection: { active: false },
475
+ };
483
476
 
484
477
  const results = await runPlaybook(pb, [device], {
485
478
  user: args.user,
@@ -492,7 +485,7 @@ async function toolProductPlaybookRun(args) {
492
485
  const payload = {
493
486
  product: args.product,
494
487
  name: args.name,
495
- device: args.device,
488
+ device: args.device_id,
496
489
  mode: args.check ? 'check' : 'apply',
497
490
  ok: !!result?.ok,
498
491
  result,
@@ -508,9 +501,7 @@ async function toolProductPlaybookRollout(args) {
508
501
  if (!args.name) throw inputError('name is required');
509
502
 
510
503
  const batchSize =
511
- Number.isFinite(args.batch_size) && args.batch_size > 0
512
- ? Math.floor(args.batch_size)
513
- : 5;
504
+ Number.isFinite(args.batch_size) && args.batch_size > 0 ? Math.floor(args.batch_size) : 5;
514
505
  const failureThreshold =
515
506
  args.failure_threshold === undefined || args.failure_threshold === null
516
507
  ? DEFAULT_FAILURE_THRESHOLD
@@ -1455,9 +1446,10 @@ Pass \`dry_run: true\` to get the resolved plan without contacting the device. P
1455
1446
  type: 'string',
1456
1447
  description: 'Playbook name as registered in the product index.',
1457
1448
  },
1458
- device: {
1449
+ device_id: {
1459
1450
  type: 'string',
1460
- description: 'Target device ID (single-device only). Pick any device of the product.',
1451
+ description:
1452
+ 'Target device ID (single-device only). Pick any device of the product.',
1461
1453
  },
1462
1454
  vars: {
1463
1455
  type: 'object',
@@ -1477,7 +1469,7 @@ Pass \`dry_run: true\` to get the resolved plan without contacting the device. P
1477
1469
  },
1478
1470
  user: { type: 'string', description: 'API user (admin impersonation).' },
1479
1471
  },
1480
- required: ['product', 'name', 'device'],
1472
+ required: ['product', 'name', 'device_id'],
1481
1473
  },
1482
1474
  handler: toolProductPlaybookRun,
1483
1475
  },
@@ -401,7 +401,8 @@ Gotchas:
401
401
  },
402
402
  enabled: {
403
403
  type: 'boolean',
404
- description: 'Default: true. Set to false to stage a definition without activating it.',
404
+ description:
405
+ 'Default: true. Set to false to stage a definition without activating it.',
405
406
  },
406
407
  user: { type: 'string', description: 'API user.' },
407
408
  },
@@ -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: {},
@@ -3,7 +3,7 @@ import { getDeviceProperties, getDeviceProperty, setDeviceProperty } from '../pr
3
3
  import { inputError } from '../errors.js';
4
4
 
5
5
  async function toolPropertyGet(args) {
6
- const device = args.device;
6
+ const device = args.device_id;
7
7
  if (!device)
8
8
  throw inputError('No device specified. Use thinr_devices to list available devices.');
9
9
  if (args.property) {
@@ -22,7 +22,7 @@ async function toolPropertyGet(args) {
22
22
  }
23
23
 
24
24
  async function toolPropertySet(args) {
25
- const device = args.device;
25
+ const device = args.device_id;
26
26
  if (!device)
27
27
  throw inputError('No device specified. Use thinr_devices to list available devices.');
28
28
  if (!args.property) throw inputError('property is required');
@@ -51,10 +51,10 @@ export const tools = [
51
51
  description:
52
52
  'Property name. Omit to list the names of all properties on the device.',
53
53
  },
54
- device: { type: 'string', description: 'Device ID' },
54
+ device_id: { type: 'string', description: 'Device ID' },
55
55
  user: { type: 'string', description: 'API user' },
56
56
  },
57
- required: ['device'],
57
+ required: ['device_id'],
58
58
  },
59
59
  handler: toolPropertyGet,
60
60
  },
@@ -69,10 +69,10 @@ export const tools = [
69
69
  description:
70
70
  'Any JSON-serialisable value (string, number, boolean, object, array).',
71
71
  },
72
- device: { type: 'string', description: 'Device ID' },
72
+ device_id: { type: 'string', description: 'Device ID' },
73
73
  user: { type: 'string', description: 'API user' },
74
74
  },
75
- required: ['property', 'value', 'device'],
75
+ required: ['property', 'value', 'device_id'],
76
76
  },
77
77
  handler: toolPropertySet,
78
78
  },
@@ -3,7 +3,7 @@ import { callDeviceResource, listDeviceResourcesWithSchemas } from '../resource.
3
3
  import { inputError } from '../errors.js';
4
4
 
5
5
  async function toolResourceList(args) {
6
- const device = args.device;
6
+ const device = args.device_id;
7
7
  if (!device)
8
8
  throw inputError('No device specified. Use thinr_devices to list available devices.');
9
9
  const entries = await listDeviceResourcesWithSchemas(device, { user: args.user });
@@ -22,7 +22,7 @@ async function toolResourceList(args) {
22
22
  }
23
23
 
24
24
  async function toolResourceCall(args) {
25
- const device = args.device;
25
+ const device = args.device_id;
26
26
  if (!device)
27
27
  throw inputError('No device specified. Use thinr_devices to list available devices.');
28
28
  if (!args.resource) throw inputError('resource is required');
@@ -47,10 +47,10 @@ export const tools = [
47
47
  inputSchema: {
48
48
  type: 'object',
49
49
  properties: {
50
- device: { type: 'string', description: 'Device ID' },
50
+ device_id: { type: 'string', description: 'Device ID' },
51
51
  user: { type: 'string', description: 'API user' },
52
52
  },
53
- required: ['device'],
53
+ required: ['device_id'],
54
54
  },
55
55
  handler: toolResourceList,
56
56
  },
@@ -68,10 +68,10 @@ export const tools = [
68
68
  description:
69
69
  'JSON object with the input parameters declared by the resource. Omit or pass {} for input-less resources.',
70
70
  },
71
- device: { type: 'string', description: 'Device ID' },
71
+ device_id: { type: 'string', description: 'Device ID' },
72
72
  user: { type: 'string', description: 'API user' },
73
73
  },
74
- required: ['resource', 'device'],
74
+ required: ['resource', 'device_id'],
75
75
  },
76
76
  handler: toolResourceCall,
77
77
  },
@@ -1,10 +1,10 @@
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
 
6
6
  async function toolScriptList(args) {
7
- const device = args.device;
7
+ const device = args.device_id;
8
8
  if (!device)
9
9
  throw inputError('No device specified. Use thinr_devices to list available devices.');
10
10
  const info = await readDeviceResource(device, '$scripts/info', { user: args.user });
@@ -25,7 +25,7 @@ async function toolScriptList(args) {
25
25
  }
26
26
 
27
27
  async function toolScriptWrite(args) {
28
- const device = args.device;
28
+ const device = args.device_id;
29
29
  if (!device)
30
30
  throw inputError('No device specified. Use thinr_devices to list available devices.');
31
31
  if (!args.name) throw inputError('name is required');
@@ -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;
@@ -76,7 +94,7 @@ async function toolScriptWrite(args) {
76
94
  }
77
95
 
78
96
  async function toolScriptDelete(args) {
79
- const device = args.device;
97
+ const device = args.device_id;
80
98
  if (!device)
81
99
  throw inputError('No device specified. Use thinr_devices to list available devices.');
82
100
  if (!args.name) throw inputError('name is required');
@@ -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,
@@ -113,10 +145,10 @@ export const tools = [
113
145
  inputSchema: {
114
146
  type: 'object',
115
147
  properties: {
116
- device: { type: 'string', description: 'Device ID' },
148
+ device_id: { type: 'string', description: 'Device ID' },
117
149
  user: { type: 'string', description: 'API user' },
118
150
  },
119
- required: ['device'],
151
+ required: ['device_id'],
120
152
  },
121
153
  handler: toolScriptList,
122
154
  },
@@ -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,14 +175,19 @@ 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.',
146
186
  },
147
- device: { type: 'string', description: 'Device ID' },
187
+ device_id: { type: 'string', description: 'Device ID' },
148
188
  user: { type: 'string', description: 'API user' },
149
189
  },
150
- required: ['name', 'content', 'device'],
190
+ required: ['name', 'content', 'device_id'],
151
191
  },
152
192
  handler: toolScriptWrite,
153
193
  },
@@ -162,10 +202,10 @@ This tool writes the script, makes it executable, and calls the agent's \`$scrip
162
202
  description:
163
203
  'Name of the script to remove (must match the file basename, without extension).',
164
204
  },
165
- device: { type: 'string', description: 'Device ID' },
205
+ device_id: { type: 'string', description: 'Device ID' },
166
206
  user: { type: 'string', description: 'API user' },
167
207
  },
168
- required: ['name', 'device'],
208
+ required: ['name', 'device_id'],
169
209
  },
170
210
  handler: toolScriptDelete,
171
211
  },
@@ -115,13 +115,11 @@ async function toolTokenDelete(args) {
115
115
  // ─── Device-level tokens ────────────────────────────────────────────
116
116
 
117
117
  async function toolDeviceTokenList(args) {
118
- if (!args.device) throw inputError('device is required');
119
- const tokens = await listDeviceTokens(args.device, args.user);
118
+ if (!args.device_id) throw inputError('device is required');
119
+ const tokens = await listDeviceTokens(args.device_id, args.user);
120
120
  if (tokens.length === 0) {
121
121
  return {
122
- content: [
123
- { type: 'text', text: `No device tokens on device "${args.device}".` },
124
- ],
122
+ content: [{ type: 'text', text: `No device tokens on device "${args.device_id}".` }],
125
123
  isError: false,
126
124
  };
127
125
  }
@@ -129,7 +127,7 @@ async function toolDeviceTokenList(args) {
129
127
  content: [
130
128
  {
131
129
  type: 'text',
132
- text: `${tokens.length} device token(s) on "${args.device}":\n${JSON.stringify(tokens, null, 2)}`,
130
+ text: `${tokens.length} device token(s) on "${args.device_id}":\n${JSON.stringify(tokens, null, 2)}`,
133
131
  },
134
132
  ],
135
133
  isError: false,
@@ -137,11 +135,11 @@ async function toolDeviceTokenList(args) {
137
135
  }
138
136
 
139
137
  async function toolDeviceTokenCreate(args) {
140
- if (!args.device) throw inputError('device is required');
138
+ if (!args.device_id) throw inputError('device is required');
141
139
  if (!args.token_name) throw inputError('token_name is required');
142
140
 
143
141
  const doc = await createDeviceToken(
144
- args.device,
142
+ args.device_id,
145
143
  {
146
144
  token_name: args.token_name,
147
145
  token_resources: args.token_resources,
@@ -153,7 +151,7 @@ async function toolDeviceTokenCreate(args) {
153
151
  content: [
154
152
  {
155
153
  type: 'text',
156
- text: `Created device token "${args.token_name}" on "${args.device}".\n${JSON.stringify(doc, null, 2)}`,
154
+ text: `Created device token "${args.token_name}" on "${args.device_id}".\n${JSON.stringify(doc, null, 2)}`,
157
155
  },
158
156
  ],
159
157
  isError: false,
@@ -161,16 +159,16 @@ async function toolDeviceTokenCreate(args) {
161
159
  }
162
160
 
163
161
  async function toolDeviceTokenDelete(args) {
164
- if (!args.device) throw inputError('device is required');
162
+ if (!args.device_id) throw inputError('device is required');
165
163
  if (!args.token_id) throw inputError('token_id is required');
166
- const removed = await deleteDeviceToken(args.device, args.token_id, args.user);
164
+ const removed = await deleteDeviceToken(args.device_id, args.token_id, args.user);
167
165
  return {
168
166
  content: [
169
167
  {
170
168
  type: 'text',
171
169
  text: removed
172
- ? `Deleted device token "${args.token_id}" from "${args.device}".`
173
- : `Device token "${args.token_id}" was not configured on "${args.device}".`,
170
+ ? `Deleted device token "${args.token_id}" from "${args.device_id}".`
171
+ : `Device token "${args.token_id}" was not configured on "${args.device_id}".`,
174
172
  },
175
173
  ],
176
174
  isError: false,
@@ -318,10 +316,10 @@ ${EXPIRY_NOTE}`,
318
316
  inputSchema: {
319
317
  type: 'object',
320
318
  properties: {
321
- device: { type: 'string', description: 'Device ID.' },
319
+ device_id: { type: 'string', description: 'Device ID.' },
322
320
  user: { type: 'string', description: 'API user.' },
323
321
  },
324
- required: ['device'],
322
+ required: ['device_id'],
325
323
  },
326
324
  handler: toolDeviceTokenList,
327
325
  },
@@ -336,7 +334,7 @@ Example: a token that lets a remote script call only the \`monitoring\` and \`re
336
334
  inputSchema: {
337
335
  type: 'object',
338
336
  properties: {
339
- device: { type: 'string', description: 'Device ID.' },
337
+ device_id: { type: 'string', description: 'Device ID.' },
340
338
  token_name: { type: 'string', description: 'Display name for the new token.' },
341
339
  token_resources: {
342
340
  type: 'array',
@@ -350,7 +348,7 @@ Example: a token that lets a remote script call only the \`monitoring\` and \`re
350
348
  },
351
349
  user: { type: 'string', description: 'API user.' },
352
350
  },
353
- required: ['device', 'token_name'],
351
+ required: ['device_id', 'token_name'],
354
352
  },
355
353
  handler: toolDeviceTokenCreate,
356
354
  },
@@ -360,11 +358,11 @@ Example: a token that lets a remote script call only the \`monitoring\` and \`re
360
358
  inputSchema: {
361
359
  type: 'object',
362
360
  properties: {
363
- device: { type: 'string', description: 'Device ID.' },
361
+ device_id: { type: 'string', description: 'Device ID.' },
364
362
  token_id: { type: 'string', description: 'Token id to remove.' },
365
363
  user: { type: 'string', description: 'API user.' },
366
364
  },
367
- required: ['device', 'token_id'],
365
+ required: ['device_id', 'token_id'],
368
366
  },
369
367
  handler: toolDeviceTokenDelete,
370
368
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinremote/thinr-cli",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
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": {