@thinremote/thinr-cli 1.3.0 → 1.3.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 +11 -0
- package/lib/mcp/tools-resources.js +4 -2
- package/lib/mcp/tools-scripts.js +5 -5
- package/lib/playbook/actions.js +33 -15
- package/lib/resource.js +34 -24
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.1] - 2026-09-24
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Device resources now honour the `user` argument, so an admin acting on
|
|
8
|
+
another account reaches that account's devices. `thinr_resource_list`,
|
|
9
|
+
`thinr_resource_call` and the `thinr_script_*` tools accepted the
|
|
10
|
+
argument but addressed the active profile's own account, which answered
|
|
11
|
+
"Device not found"; playbook `resource` and script actions had the same
|
|
12
|
+
gap. Not-found errors now name the account they looked in.
|
|
13
|
+
|
|
3
14
|
## [1.3.0] - 2026-09-13
|
|
4
15
|
|
|
5
16
|
### Added
|
|
@@ -6,7 +6,7 @@ async function toolResourceList(args) {
|
|
|
6
6
|
const device = args.device;
|
|
7
7
|
if (!device)
|
|
8
8
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
9
|
-
const entries = await listDeviceResourcesWithSchemas(device);
|
|
9
|
+
const entries = await listDeviceResourcesWithSchemas(device, { user: args.user });
|
|
10
10
|
const fnLabel = (f) =>
|
|
11
11
|
({ 1: 'no params', 2: 'input', 3: 'output', 4: 'input/output' })[f] || 'unknown';
|
|
12
12
|
const lines = entries.map((e) => {
|
|
@@ -26,7 +26,9 @@ async function toolResourceCall(args) {
|
|
|
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');
|
|
29
|
-
const result = await callDeviceResource(device, args.resource, args.inputs
|
|
29
|
+
const result = await callDeviceResource(device, args.resource, args.inputs, {
|
|
30
|
+
user: args.user,
|
|
31
|
+
});
|
|
30
32
|
return {
|
|
31
33
|
content: [
|
|
32
34
|
{
|
package/lib/mcp/tools-scripts.js
CHANGED
|
@@ -7,7 +7,7 @@ async function toolScriptList(args) {
|
|
|
7
7
|
const device = args.device;
|
|
8
8
|
if (!device)
|
|
9
9
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
10
|
-
const info = await readDeviceResource(device, '$scripts/info');
|
|
10
|
+
const info = await readDeviceResource(device, '$scripts/info', { user: args.user });
|
|
11
11
|
const scripts = Array.isArray(info?.scripts) ? info.scripts : [];
|
|
12
12
|
const header = `Scripts directory: ${info?.path || '(unknown)'}\n${scripts.length} script(s) registered:`;
|
|
13
13
|
const entries = scripts.map((s) => {
|
|
@@ -33,7 +33,7 @@ async function toolScriptWrite(args) {
|
|
|
33
33
|
|
|
34
34
|
const { api: deviceApi } = getAPI(device, args.user);
|
|
35
35
|
|
|
36
|
-
const info = await readDeviceResource(device, '$scripts/info');
|
|
36
|
+
const info = await readDeviceResource(device, '$scripts/info', { user: args.user });
|
|
37
37
|
const baseDir = info?.path;
|
|
38
38
|
if (!baseDir)
|
|
39
39
|
throw new Error('Agent did not return a scripts directory (is $scripts/info supported?)');
|
|
@@ -52,7 +52,7 @@ async function toolScriptWrite(args) {
|
|
|
52
52
|
throw new Error(`chmod +x failed: ${chmod.stderr || `exit ${chmod.retcode}`}`);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
const reloaded = await callDeviceResource(device, '$scripts/reload', {});
|
|
55
|
+
const reloaded = await callDeviceResource(device, '$scripts/reload', {}, { user: args.user });
|
|
56
56
|
// The agent registers resources by the file's stem, so "battery.sh"
|
|
57
57
|
// becomes scripts/battery. Match the same way to echo back the schema.
|
|
58
58
|
const registeredName = args.name.replace(/\.[^./]+$/, '');
|
|
@@ -82,7 +82,7 @@ async function toolScriptDelete(args) {
|
|
|
82
82
|
if (!args.name) throw inputError('name is required');
|
|
83
83
|
|
|
84
84
|
const { api: deviceApi } = getAPI(device, args.user);
|
|
85
|
-
const info = await readDeviceResource(device, '$scripts/info');
|
|
85
|
+
const info = await readDeviceResource(device, '$scripts/info', { user: args.user });
|
|
86
86
|
const baseDir = info?.path;
|
|
87
87
|
if (!baseDir)
|
|
88
88
|
throw new Error('Agent did not return a scripts directory (is $scripts/info supported?)');
|
|
@@ -93,7 +93,7 @@ async function toolScriptDelete(args) {
|
|
|
93
93
|
const scriptPath = `${baseDir}/${args.name}`;
|
|
94
94
|
|
|
95
95
|
await deviceApi.delete(scriptPath, false);
|
|
96
|
-
const reloaded = await callDeviceResource(device, '$scripts/reload', {});
|
|
96
|
+
const reloaded = await callDeviceResource(device, '$scripts/reload', {}, { user: args.user });
|
|
97
97
|
const remaining = Array.isArray(reloaded?.scripts) ? reloaded.scripts.length : 0;
|
|
98
98
|
return {
|
|
99
99
|
content: [
|
package/lib/playbook/actions.js
CHANGED
|
@@ -30,7 +30,10 @@ const MAX_CAPTURED_STREAM_BYTES = 32 * 1024;
|
|
|
30
30
|
|
|
31
31
|
function capStream(s) {
|
|
32
32
|
if (typeof s !== 'string' || s.length <= MAX_CAPTURED_STREAM_BYTES) return s;
|
|
33
|
-
return
|
|
33
|
+
return (
|
|
34
|
+
s.slice(0, MAX_CAPTURED_STREAM_BYTES) +
|
|
35
|
+
`\n…[truncated, ${s.length - MAX_CAPTURED_STREAM_BYTES} more bytes]`
|
|
36
|
+
);
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
/** @type {Record<string, (ctx: any, params: any) => Promise<string | { summary: string, stdout?: string, stderr?: string, exitCode?: number }>>} */
|
|
@@ -128,11 +131,16 @@ export const HANDLERS = {
|
|
|
128
131
|
},
|
|
129
132
|
|
|
130
133
|
async resource(ctx, params) {
|
|
131
|
-
const result = await callDeviceResource(
|
|
134
|
+
const result = await callDeviceResource(
|
|
135
|
+
ctx.deviceId,
|
|
136
|
+
params.resource,
|
|
137
|
+
params.inputs || null,
|
|
138
|
+
{
|
|
139
|
+
user: ctx.user,
|
|
140
|
+
},
|
|
141
|
+
);
|
|
132
142
|
const summary =
|
|
133
|
-
typeof result === 'string'
|
|
134
|
-
? result.slice(0, 40)
|
|
135
|
-
: JSON.stringify(result).slice(0, 40);
|
|
143
|
+
typeof result === 'string' ? result.slice(0, 40) : JSON.stringify(result).slice(0, 40);
|
|
136
144
|
return `resource ${params.resource} → ${summary}`;
|
|
137
145
|
},
|
|
138
146
|
|
|
@@ -158,7 +166,7 @@ export const HANDLERS = {
|
|
|
158
166
|
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
159
167
|
throw new Error(`invalid script name "${params.name}"`);
|
|
160
168
|
}
|
|
161
|
-
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
169
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info', { user: ctx.user });
|
|
162
170
|
const baseDir = info?.path;
|
|
163
171
|
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
164
172
|
const path = `${baseDir}/${params.name}`;
|
|
@@ -168,7 +176,7 @@ export const HANDLERS = {
|
|
|
168
176
|
TIMEOUTS.SCRIPT_CHMOD_SECONDS,
|
|
169
177
|
);
|
|
170
178
|
if (chmod.retcode !== 0) throw new Error(`chmod failed: ${chmod.stderr || chmod.retcode}`);
|
|
171
|
-
await callDeviceResource(ctx.deviceId, '$scripts/reload', {});
|
|
179
|
+
await callDeviceResource(ctx.deviceId, '$scripts/reload', {}, { user: ctx.user });
|
|
172
180
|
return `installed ${params.name}`;
|
|
173
181
|
},
|
|
174
182
|
|
|
@@ -176,11 +184,11 @@ export const HANDLERS = {
|
|
|
176
184
|
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
177
185
|
throw new Error(`invalid script name "${params.name}"`);
|
|
178
186
|
}
|
|
179
|
-
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
187
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info', { user: ctx.user });
|
|
180
188
|
const baseDir = info?.path;
|
|
181
189
|
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
182
190
|
await ctx.api.delete(`${baseDir}/${params.name}`, false);
|
|
183
|
-
await callDeviceResource(ctx.deviceId, '$scripts/reload', {});
|
|
191
|
+
await callDeviceResource(ctx.deviceId, '$scripts/reload', {}, { user: ctx.user });
|
|
184
192
|
return `removed ${params.name}`;
|
|
185
193
|
},
|
|
186
194
|
};
|
|
@@ -221,10 +229,16 @@ export const CHECKERS = {
|
|
|
221
229
|
try {
|
|
222
230
|
const info = await ctx.api.info(params.source);
|
|
223
231
|
const size = info?.size ?? '?';
|
|
224
|
-
return {
|
|
232
|
+
return {
|
|
233
|
+
status: 'changed',
|
|
234
|
+
summary: `would pull ${params.source} (${size} bytes) → ${params.destination}`,
|
|
235
|
+
};
|
|
225
236
|
} catch (err) {
|
|
226
237
|
if (err?.response?.status === 404) {
|
|
227
|
-
return {
|
|
238
|
+
return {
|
|
239
|
+
status: 'changed',
|
|
240
|
+
summary: `source ${params.source} not found on device`,
|
|
241
|
+
};
|
|
228
242
|
}
|
|
229
243
|
throw err;
|
|
230
244
|
}
|
|
@@ -236,7 +250,7 @@ export const CHECKERS = {
|
|
|
236
250
|
}
|
|
237
251
|
const content = resolveWriteContent(params, ctx.baseDir);
|
|
238
252
|
const localHash = sha256Hex(content);
|
|
239
|
-
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
253
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info', { user: ctx.user });
|
|
240
254
|
const baseDir = info?.path;
|
|
241
255
|
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
242
256
|
const remotePath = `${baseDir}/${params.name}`;
|
|
@@ -342,7 +356,8 @@ export const CHECKERS = {
|
|
|
342
356
|
const upToDate = result?.status === 'up_to_date';
|
|
343
357
|
return {
|
|
344
358
|
status: upToDate ? 'unchanged' : 'changed',
|
|
345
|
-
summary:
|
|
359
|
+
summary:
|
|
360
|
+
`update ${params.op}: ${result?.status || 'unknown'}` +
|
|
346
361
|
(result?.latest ? ` (latest=${result.latest})` : ''),
|
|
347
362
|
};
|
|
348
363
|
},
|
|
@@ -351,7 +366,7 @@ export const CHECKERS = {
|
|
|
351
366
|
if (!/^[A-Za-z0-9._-]+$/.test(params.name)) {
|
|
352
367
|
throw new Error(`invalid script name "${params.name}"`);
|
|
353
368
|
}
|
|
354
|
-
const info = await readDeviceResource(ctx.deviceId, '$scripts/info');
|
|
369
|
+
const info = await readDeviceResource(ctx.deviceId, '$scripts/info', { user: ctx.user });
|
|
355
370
|
const baseDir = info?.path;
|
|
356
371
|
if (!baseDir) throw new Error('agent did not return a scripts directory');
|
|
357
372
|
try {
|
|
@@ -396,7 +411,10 @@ async function hashCheck(api, path, contentBuffer) {
|
|
|
396
411
|
return { status: 'changed', summary: `create ${path} (${contentBuffer.length} bytes)` };
|
|
397
412
|
}
|
|
398
413
|
if (err?.response?.status === 400) {
|
|
399
|
-
return {
|
|
414
|
+
return {
|
|
415
|
+
status: 'changed',
|
|
416
|
+
summary: `${path} is not a regular file — would overwrite`,
|
|
417
|
+
};
|
|
400
418
|
}
|
|
401
419
|
if (err?.response?.status === 405 || err?.response?.status === 501) {
|
|
402
420
|
return { status: 'unknown', summary: 'agent lacks $fs/hash — cannot predict' };
|
package/lib/resource.js
CHANGED
|
@@ -3,23 +3,33 @@ import api from './api.js';
|
|
|
3
3
|
import { apiError } from './errors.js';
|
|
4
4
|
import { info, warning, hint } from './format.js';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Account the request is addressed to: `options.user` for admin access to
|
|
8
|
+
* another account's devices, the active profile's own user otherwise.
|
|
9
|
+
* Matches `createDeviceAPI`, so both halves of a device interaction (files
|
|
10
|
+
* and resources) resolve the same way.
|
|
11
|
+
*/
|
|
12
|
+
function apiUser(options = {}) {
|
|
13
|
+
return options.user || requireConfig().username;
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
// Drops internal resources whose keys start with `$`; those belong to
|
|
7
17
|
// the agent's own framework (cmd, fs, scripts…) and shouldn't surface
|
|
8
18
|
// through the user-facing resource list.
|
|
9
|
-
export async function getDeviceResources(deviceId) {
|
|
10
|
-
const
|
|
19
|
+
export async function getDeviceResources(deviceId, options = {}) {
|
|
20
|
+
const user = apiUser(options);
|
|
11
21
|
|
|
12
22
|
try {
|
|
13
23
|
// `/resources/api` merges firmware resources with API resources
|
|
14
24
|
// inherited from the product profile, which `/resources` alone omits.
|
|
15
|
-
const response = await api.get(
|
|
16
|
-
`/v3/users/${config.username}/devices/${deviceId}/resources/api`,
|
|
17
|
-
);
|
|
25
|
+
const response = await api.get(`/v3/users/${user}/devices/${deviceId}/resources/api`);
|
|
18
26
|
return Object.fromEntries(
|
|
19
27
|
Object.entries(response.data).filter(([key]) => !key.startsWith('$')),
|
|
20
28
|
);
|
|
21
29
|
} catch (error) {
|
|
22
|
-
throw apiError(error, {
|
|
30
|
+
throw apiError(error, {
|
|
31
|
+
notFound: `Device not found: ${deviceId} (account ${user})`,
|
|
32
|
+
});
|
|
23
33
|
}
|
|
24
34
|
}
|
|
25
35
|
|
|
@@ -28,16 +38,16 @@ export async function getDeviceResources(deviceId) {
|
|
|
28
38
|
* output-only (fn=3) or fn=4 resources when you only want the latest
|
|
29
39
|
* sample, not to trigger the input side.
|
|
30
40
|
*/
|
|
31
|
-
export async function readDeviceResource(deviceId, resourceId) {
|
|
32
|
-
const
|
|
41
|
+
export async function readDeviceResource(deviceId, resourceId, options = {}) {
|
|
42
|
+
const user = apiUser(options);
|
|
33
43
|
try {
|
|
34
44
|
const response = await api.get(
|
|
35
|
-
`/v3/users/${
|
|
45
|
+
`/v3/users/${user}/devices/${deviceId}/resources/${resourceId}`,
|
|
36
46
|
);
|
|
37
47
|
return response.data;
|
|
38
48
|
} catch (error) {
|
|
39
49
|
throw apiError(error, {
|
|
40
|
-
notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
|
|
50
|
+
notFound: `Resource not found: ${resourceId} on device ${deviceId} (account ${user})`,
|
|
41
51
|
});
|
|
42
52
|
}
|
|
43
53
|
}
|
|
@@ -51,32 +61,32 @@ export async function readDeviceResource(deviceId, resourceId) {
|
|
|
51
61
|
* return `in`; fn=1 may return an empty object. Not every agent/firmware
|
|
52
62
|
* exposes /api — callers should tolerate 404/501.
|
|
53
63
|
*/
|
|
54
|
-
export async function getDeviceResourceApi(deviceId, resourceId) {
|
|
55
|
-
const
|
|
64
|
+
export async function getDeviceResourceApi(deviceId, resourceId, options = {}) {
|
|
65
|
+
const user = apiUser(options);
|
|
56
66
|
try {
|
|
57
67
|
const response = await api.get(
|
|
58
|
-
`/v3/users/${
|
|
68
|
+
`/v3/users/${user}/devices/${deviceId}/resources/${resourceId}/api`,
|
|
59
69
|
);
|
|
60
70
|
return response.data;
|
|
61
71
|
} catch (error) {
|
|
62
72
|
throw apiError(error, {
|
|
63
|
-
notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
|
|
73
|
+
notFound: `Resource not found: ${resourceId} on device ${deviceId} (account ${user})`,
|
|
64
74
|
});
|
|
65
75
|
}
|
|
66
76
|
}
|
|
67
77
|
|
|
68
|
-
export async function executeDeviceResource(deviceId, resourceId, inputs) {
|
|
69
|
-
const
|
|
78
|
+
export async function executeDeviceResource(deviceId, resourceId, inputs, options = {}) {
|
|
79
|
+
const user = apiUser(options);
|
|
70
80
|
|
|
71
81
|
try {
|
|
72
82
|
const response = await api.post(
|
|
73
|
-
`/v3/users/${
|
|
83
|
+
`/v3/users/${user}/devices/${deviceId}/resources/${resourceId}`,
|
|
74
84
|
inputs,
|
|
75
85
|
);
|
|
76
86
|
return response.data;
|
|
77
87
|
} catch (error) {
|
|
78
88
|
throw apiError(error, {
|
|
79
|
-
notFound: `Resource not found: ${resourceId} on device ${deviceId}`,
|
|
89
|
+
notFound: `Resource not found: ${resourceId} on device ${deviceId} (account ${user})`,
|
|
80
90
|
});
|
|
81
91
|
}
|
|
82
92
|
}
|
|
@@ -87,14 +97,14 @@ export async function executeDeviceResource(deviceId, resourceId, inputs) {
|
|
|
87
97
|
* the output side of fn=3/4 resources without triggering the input
|
|
88
98
|
* handler. Otherwise POST with the payload.
|
|
89
99
|
*/
|
|
90
|
-
export async function callDeviceResource(deviceId, resourceId, inputs) {
|
|
100
|
+
export async function callDeviceResource(deviceId, resourceId, inputs, options = {}) {
|
|
91
101
|
const hasInputs =
|
|
92
102
|
inputs !== undefined &&
|
|
93
103
|
inputs !== null &&
|
|
94
104
|
!(typeof inputs === 'object' && !Array.isArray(inputs) && Object.keys(inputs).length === 0);
|
|
95
105
|
return hasInputs
|
|
96
|
-
? executeDeviceResource(deviceId, resourceId, inputs)
|
|
97
|
-
: readDeviceResource(deviceId, resourceId);
|
|
106
|
+
? executeDeviceResource(deviceId, resourceId, inputs, options)
|
|
107
|
+
: readDeviceResource(deviceId, resourceId, options);
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
/**
|
|
@@ -102,14 +112,14 @@ export async function callDeviceResource(deviceId, resourceId, inputs) {
|
|
|
102
112
|
* `/api` schema. Returns an array with `{name, fn, in?, out?}` so both
|
|
103
113
|
* the CLI and the MCP can render the same information.
|
|
104
114
|
*/
|
|
105
|
-
export async function listDeviceResourcesWithSchemas(deviceId) {
|
|
106
|
-
const resources = await getDeviceResources(deviceId);
|
|
115
|
+
export async function listDeviceResourcesWithSchemas(deviceId, options = {}) {
|
|
116
|
+
const resources = await getDeviceResources(deviceId, options);
|
|
107
117
|
const names = Object.keys(resources);
|
|
108
118
|
const entries = await Promise.all(
|
|
109
119
|
names.map(async (name) => {
|
|
110
120
|
const fn = resources[name]?.fn;
|
|
111
121
|
try {
|
|
112
|
-
const schema = await getDeviceResourceApi(deviceId, name);
|
|
122
|
+
const schema = await getDeviceResourceApi(deviceId, name, options);
|
|
113
123
|
return { name, fn, ...schema };
|
|
114
124
|
} catch {
|
|
115
125
|
return { name, fn };
|