@thinremote/thinr-cli 1.2.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 CHANGED
@@ -1,5 +1,34 @@
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
+
14
+ ## [1.3.0] - 2026-09-13
15
+
16
+ ### Added
17
+
18
+ - `thinr login` — re-authenticate the current profile in place, without
19
+ deleting and recreating it. Renews an expired session in one step and
20
+ defaults the server prompt to the profile's existing server.
21
+
22
+ ### Changed
23
+
24
+ - Session-expiry errors now point to the working command: "Session
25
+ expired. Run `thinr login` to sign in again." (previously suggested
26
+ `thinr`, which opens the dashboard instead of re-authenticating).
27
+ - More robust token refresh for WebSocket sessions: the interactive
28
+ console and streaming `exec` now refresh an expired access token
29
+ before the handshake and retry once on a 401, matching the
30
+ refresh-on-401 behaviour of HTTP calls.
31
+
3
32
  ## [1.2.0] - 2026-06-10
4
33
 
5
34
  ### Added
package/README.md CHANGED
@@ -12,8 +12,8 @@ Two operating modes:
12
12
 
13
13
  - **CLI** — interactive or scripted device management from a shell.
14
14
  - **MCP server** — a stdio [Model Context Protocol][mcp] server that
15
- exposes the same operations as typed tools for AI clients like
16
- Claude Code.
15
+ exposes the same operations as 80+ typed tools for AI clients like
16
+ Claude Code, Claude Desktop and Cursor.
17
17
 
18
18
  Both modes share the same auth, profiles, and device API layer, so
19
19
  you configure the tool once and use it in whichever mode fits the
@@ -39,6 +39,7 @@ task at hand.
39
39
  - [Authoring playbooks from natural language](#authoring-playbooks-from-natural-language)
40
40
  - [Per-call controls](#per-call-controls)
41
41
  - [Integrating with Claude Code](#integrating-with-claude-code)
42
+ - [Integrating with Claude Desktop, Cursor and other MCP hosts](#integrating-with-claude-desktop-cursor-and-other-mcp-hosts)
42
43
  - [Profiles and multi-account use](#profiles-and-multi-account-use)
43
44
  - [Development](#development)
44
45
  - [License](#license)
@@ -428,44 +429,53 @@ Other profiles are left untouched.
428
429
 
429
430
  ## Part 2 — MCP server
430
431
 
431
- `thinr` ships an [MCP][mcp] server that exposes the CLI's device
432
- operations as a set of typed tools. AI clients (Claude Code, Claude
433
- Desktop, and any other MCP-compatible host) can then list devices,
434
- run shell commands, read/write files, call resources, and manage
435
- products through the same API the CLI uses — with stable schemas and
436
- a consistent error contract.
432
+ `thinr` ships an [MCP][mcp] server that exposes the CLI's device and
433
+ fleet operations as a set of 80+ typed tools. AI clients (Claude Code,
434
+ Claude Desktop, Cursor, and any other MCP-compatible host) can list
435
+ devices, run shell commands, read/write files, call resources, manage
436
+ products, author and roll out playbooks, triage alarms and rotate
437
+ access tokens through the same API the CLI uses — with stable schemas
438
+ and a consistent error contract.
437
439
 
438
440
  ### Starting the server
439
441
 
440
442
  ```bash
441
- thinr mcp [-d, --device <deviceId>]
443
+ thinr mcp
442
444
  ```
443
445
 
444
- The server speaks [MCP over stdio][mcp-stdio]. `-d/--device` sets a
445
- default device that tools can omit in each call; `--user` can be
446
- passed through (via the global flag) to impersonate another account.
446
+ The server speaks [MCP over stdio][mcp-stdio]. Every tool call carries
447
+ its own `device`, `user` and `profile` arguments, so a single running
448
+ server handles any device of any configured environment without a
449
+ restart.
447
450
 
448
451
  [mcp-stdio]: https://modelcontextprotocol.io/specification/server/transport#stdio
449
452
 
450
453
  ### Tool catalog
451
454
 
452
- Grouped by capability. Every tool accepts optional `device`, `user`,
453
- and `profile` arguments, so a single session can target any
454
- device/account/environment without restart.
455
-
456
- | Area | Tools |
457
- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
458
- | Discovery | `thinr_devices` (with optional `query` for regex filtering), `thinr_device_info`, `thinr_profiles` |
459
- | Shell | `thinr_exec` (buffered), with streaming stdout/stderr |
460
- | Filesystem | `thinr_read`, `thinr_write`, `thinr_push`, `thinr_pull`, `thinr_ls`, `thinr_mkdir`, `thinr_rm`, `thinr_mv` |
461
- | Resources | `thinr_resource_list` (with `in`/`out` schemas), `thinr_resource_call` |
462
- | Properties | `thinr_property_get`, `thinr_property_set` |
463
- | Scripts (device) | `thinr_script_list`, `thinr_script_write`, `thinr_script_delete` |
464
- | Monitoring and update | `thinr_monitoring`, `thinr_bucket_read`, `thinr_update` |
465
- | Products | `thinr_products`, `thinr_product_delete`, `thinr_device_set_product`, `thinr_product_exec`, `thinr_product_write` |
466
- | Product scripts | `thinr_product_script_list`, `thinr_product_script_read`, `thinr_product_script_write`, `thinr_product_script_delete` |
467
- | Playbooks (authoring) | `thinr_playbook_schema`, `thinr_playbook_validate`, `thinr_playbook_run` (ad-hoc) |
468
- | Product playbooks | `thinr_product_playbook_list`, `thinr_product_playbook_read`, `thinr_product_playbook_write`, `thinr_product_playbook_run` (single device), `thinr_product_playbook_rollout` (fleet), `thinr_product_playbook_delete` |
455
+ Grouped by capability. Every tool accepts optional `device` (when
456
+ relevant), `user` and `profile` arguments, so a single session can
457
+ target any device, account or environment without restart.
458
+
459
+ | Area | Tools |
460
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
461
+ | Discovery | `thinr_devices` (with optional `query` for regex filtering), `thinr_device_info`, `thinr_profiles` |
462
+ | Provisioning and lifecycle | `thinr_agent_install_command`, `thinr_device_set_product`, `thinr_device_delete` |
463
+ | Shell | `thinr_exec` (buffered), with streaming stdout/stderr |
464
+ | Filesystem | `thinr_read`, `thinr_write`, `thinr_push`, `thinr_pull`, `thinr_ls`, `thinr_mkdir`, `thinr_rm`, `thinr_mv` |
465
+ | Resources | `thinr_resource_list` (with `in`/`out` schemas), `thinr_resource_call` |
466
+ | Properties (device) | `thinr_property_get`, `thinr_property_set` |
467
+ | Scripts (device) | `thinr_script_list`, `thinr_script_write`, `thinr_script_delete` |
468
+ | Monitoring and agent updates | `thinr_monitoring`, `thinr_bucket_read`, `thinr_update` |
469
+ | Alarms | `thinr_alarm_instances`, `thinr_alarm_instance_stats`, `thinr_alarm_instance_get`, `thinr_alarm_instance_update`, `thinr_alarm_instance_delete`, `thinr_alarm_rules`, `thinr_alarm_rule_read/write/delete` |
470
+ | Access tokens | User: `thinr_token_list`, `thinr_token_get`, `thinr_token_create`, `thinr_token_update`, `thinr_token_delete`. Device: `thinr_device_token_list`, `thinr_device_token_create`, `thinr_device_token_delete` |
471
+ | Products | `thinr_products`, `thinr_product_create`, `thinr_product_update`, `thinr_product_delete`, `thinr_product_exec`, `thinr_product_write` |
472
+ | Product scripts | `thinr_product_script_list`, `thinr_product_script_read`, `thinr_product_script_write`, `thinr_product_script_delete` |
473
+ | Product properties | `thinr_product_property_get`, `thinr_product_property_set`, `thinr_product_property_delete` |
474
+ | Product profile schema | `thinr_product_profile_api_{list,get,set,delete}`, `thinr_product_profile_bucket_{list,get,set,delete}`, `thinr_product_profile_property_{list,get,set,delete}` |
475
+ | Product log sources | `thinr_product_logs_list`, `thinr_product_logs_add`, `thinr_product_logs_remove`, `thinr_product_logs_presets`, `thinr_product_logs_set_default` |
476
+ | Product metrics | `thinr_product_metric_list`, `thinr_product_metric_set`, `thinr_product_metric_delete` |
477
+ | Playbooks (authoring) | `thinr_playbook_schema`, `thinr_playbook_validate`, `thinr_playbook_run` (ad-hoc) |
478
+ | Product playbooks | `thinr_product_playbook_list`, `thinr_product_playbook_read`, `thinr_product_playbook_write`, `thinr_product_playbook_run` (single device), `thinr_product_playbook_rollout` (fleet), `thinr_product_playbook_delete` |
469
479
 
470
480
  Full input/output schemas are published via standard MCP
471
481
  `list_tools`; the client will show them when you connect.
@@ -557,8 +567,27 @@ That's it — a single MCP server entry handles every device of the
557
567
  active profile. Tool calls pass `device` (and optionally `user` /
558
568
  `profile`) per call.
559
569
 
560
- Other MCP hosts follow the same pattern — they just need the command
561
- `thinr mcp` (optionally with `-d <deviceId>`) over stdio.
570
+ ### Integrating with Claude Desktop, Cursor and other MCP hosts
571
+
572
+ Other MCP hosts use a JSON config file (`claude_desktop_config.json`,
573
+ `.cursor/mcp.json`, etc.). The entry shape is the same on every host:
574
+
575
+ ```json
576
+ {
577
+ "mcpServers": {
578
+ "thinr": {
579
+ "command": "thinr",
580
+ "args": ["mcp"]
581
+ }
582
+ }
583
+ }
584
+ ```
585
+
586
+ The `thinr` binary must be on `PATH` for the host to spawn it —
587
+ `npm install -g @thinremote/thinr-cli` puts it there. If the host
588
+ runs under a shell that doesn't load your global npm bin
589
+ (common on macOS GUI apps), point `command` at the absolute path
590
+ (`which thinr`) instead.
562
591
 
563
592
  ---
564
593
 
package/bin/thinr.js CHANGED
@@ -6,7 +6,7 @@ import { dirname, resolve } from 'path';
6
6
  import { Command } from 'commander';
7
7
  import figlet from 'figlet';
8
8
  import { accent, info } from '../lib/format.js';
9
- import { configExists, readConfig, setActiveProfile } from '../lib/config.js';
9
+ import { configExists, getActiveProfile, readConfig, setActiveProfile } from '../lib/config.js';
10
10
  import { detectJsonModeFromArgv } from '../lib/output.js';
11
11
 
12
12
  // Single source of truth for the version: package.json. Avoids the drift
@@ -64,6 +64,25 @@ playbookCommand(program);
64
64
  profileCommand(program);
65
65
  logoutCommand(program);
66
66
 
67
+ program
68
+ .command('login')
69
+ .helpGroup('Profile:')
70
+ .description('Re-authenticate the current profile (renew an expired session)')
71
+ .action(async () => {
72
+ try {
73
+ // Pin the target to the active/default profile by name so the
74
+ // refreshed credentials overwrite it in place, even when the
75
+ // profile name differs from the server hostname.
76
+ const name = getActiveProfile();
77
+ if (name) setActiveProfile(name);
78
+ await authenticate();
79
+ console.log(info('Signed in.'));
80
+ } catch (error) {
81
+ console.error(`Error: ${error.message}`);
82
+ process.exit(1);
83
+ }
84
+ });
85
+
67
86
  program
68
87
  .command('mcp')
69
88
  .helpGroup('Integration:')
package/lib/auth.js CHANGED
@@ -16,6 +16,10 @@ const OAUTH_DEVICE_CODE_TIMEOUT_MS = TIMEOUTS.OAUTH_DEVICE_CODE_MS;
16
16
  * @returns {Promise<void>}
17
17
  */
18
18
  export async function authenticate() {
19
+ // When re-authenticating an existing profile, default the server prompt
20
+ // to the one already stored so `thinr login` is a couple of keystrokes.
21
+ const existing = readConfig();
22
+
19
23
  // Ask for authentication method
20
24
  const method = await select({
21
25
  message: 'How would you like to authenticate?',
@@ -29,7 +33,7 @@ export async function authenticate() {
29
33
  // Ask for server URL
30
34
  const server = await input({
31
35
  message: 'Enter server URL:',
32
- default: 'console.thinr.io',
36
+ default: existing?.server || 'console.thinr.io',
33
37
  validate: (input) => {
34
38
  return input ? true : 'Server URL is required';
35
39
  },
@@ -152,7 +156,7 @@ export async function refreshToken() {
152
156
  async function doRefresh() {
153
157
  const config = readConfig();
154
158
  if (!config.refresh_token) {
155
- const err = new Error('No refresh token available; re-authenticate with `thinr`.');
159
+ const err = new Error('Session expired. Run `thinr login` to sign in again.');
156
160
  err.code = 'unauthorized';
157
161
  throw err;
158
162
  }
@@ -172,7 +176,7 @@ async function doRefresh() {
172
176
  _isRefresh: true,
173
177
  });
174
178
  } catch (_error) {
175
- const err = new Error('Token refresh failed; re-authenticate with `thinr`.');
179
+ const err = new Error('Session expired. Run `thinr login` to sign in again.');
176
180
  err.code = 'unauthorized';
177
181
  throw err;
178
182
  }
@@ -184,6 +188,37 @@ async function doRefresh() {
184
188
  return response.data.access_token;
185
189
  }
186
190
 
191
+ /**
192
+ * Milliseconds-since-epoch expiry of a JWT access token, or null when
193
+ * the token is not a decodable JWT (permanent API tokens have no exp).
194
+ */
195
+ function jwtExpiryMs(token) {
196
+ try {
197
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
198
+ return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Return a currently-valid access token, refreshing first when the
206
+ * stored one is an expired (or about-to-expire) JWT. HTTP requests
207
+ * recover from expiry through the api.js response interceptor, but
208
+ * WebSocket handshakes have no retry layer: callers opening a WS use
209
+ * this to start from a valid token. Tokens without a decodable `exp`
210
+ * and profiles without a refresh_token pass through untouched.
211
+ */
212
+ export async function ensureFreshToken() {
213
+ const config = readConfig();
214
+ if (!config?.token || !config.refresh_token) return config?.token ?? null;
215
+ const expiresAt = jwtExpiryMs(config.token);
216
+ if (expiresAt === null || expiresAt - Date.now() > TIMEOUTS.TOKEN_EXPIRY_SLACK_MS) {
217
+ return config.token;
218
+ }
219
+ return refreshToken();
220
+ }
221
+
187
222
  /**
188
223
  * Delete user token from server
189
224
  * @returns {Promise<void>}
@@ -69,8 +69,7 @@ export class CommandStream {
69
69
  this.cancelled = true;
70
70
  if (
71
71
  this.ws &&
72
- (this.ws.readyState === WebSocket.OPEN ||
73
- this.ws.readyState === WebSocket.CONNECTING)
72
+ (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
74
73
  ) {
75
74
  this.ws.close();
76
75
  }
@@ -100,11 +99,14 @@ export class CommandStream {
100
99
  _armSafety() {
101
100
  const { timeout } = this.opts;
102
101
  if (!timeout || timeout <= 0) return;
103
- this.safetyTimer = setTimeout(() => {
104
- if (this.resolved) return;
105
- this.timedOut = true;
106
- this._finish();
107
- }, timeout * 1000 + TIMEOUTS.EXEC_SAFETY_GRACE_MS);
102
+ this.safetyTimer = setTimeout(
103
+ () => {
104
+ if (this.resolved) return;
105
+ this.timedOut = true;
106
+ this._finish();
107
+ },
108
+ timeout * 1000 + TIMEOUTS.EXEC_SAFETY_GRACE_MS,
109
+ );
108
110
  }
109
111
 
110
112
  _disarmSafety() {
@@ -116,6 +118,19 @@ export class CommandStream {
116
118
 
117
119
  _wireEvents(ws) {
118
120
  ws.on('open', () => this._forwardStdin(ws));
121
+ ws.on('unexpected-response', (req, res) => {
122
+ // Handshake rejected before the upgrade (401 expired token,
123
+ // 404 unknown device, ...). Tag the HTTP status so callers
124
+ // can react to it; execStream() refreshes and retries on 401.
125
+ req.destroy();
126
+ if (this.cancelled || this.resolved) return;
127
+ this._disarmSafety();
128
+ const err = /** @type {Error & { statusCode: number }} */ (
129
+ new Error(`Unexpected server response: ${res.statusCode}`)
130
+ );
131
+ err.statusCode = res.statusCode;
132
+ this._reject?.(err);
133
+ });
119
134
  ws.on('error', (err) => {
120
135
  if (this.cancelled || this.resolved) return;
121
136
  this._disarmSafety();
@@ -176,8 +191,7 @@ export class CommandStream {
176
191
  });
177
192
  if (
178
193
  this.ws &&
179
- (this.ws.readyState === WebSocket.OPEN ||
180
- this.ws.readyState === WebSocket.CONNECTING)
194
+ (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
181
195
  ) {
182
196
  this.ws.terminate();
183
197
  }
package/lib/console.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import WebSocket from 'ws';
2
2
  import { requireConfig } from './config.js';
3
+ import { ensureFreshToken } from './auth.js';
3
4
  import api from './api.js';
4
5
  import { warning, error as errorStyle } from './format.js';
5
6
 
@@ -18,10 +19,15 @@ import { warning, error as errorStyle } from './format.js';
18
19
  export async function connectToDeviceConsole(deviceId) {
19
20
  const config = requireConfig();
20
21
 
22
+ // WS handshakes lack the refresh-on-401 retry that api.js gives
23
+ // HTTP requests: start from a valid token (no-op for permanent
24
+ // tokens, which carry no expiry).
25
+ const token = (await ensureFreshToken()) || config.token;
26
+
21
27
  const sessionId = createUUID();
22
28
  const wsUrl = `wss://${config.server}/v3/users/${config.username}/devices/${deviceId}/resources/$terminal/${sessionId}?raw=1`;
23
29
  const ws = new WebSocket(wsUrl, {
24
- headers: { Authorization: `Bearer ${config.token}` },
30
+ headers: { Authorization: `Bearer ${token}` },
25
31
  });
26
32
 
27
33
  let terminalRestored = false;
package/lib/constants.js CHANGED
@@ -12,6 +12,13 @@ export const TIMEOUTS = {
12
12
  /** Default axios request timeout for control-plane HTTP calls. */
13
13
  API_DEFAULT_MS: 10_000,
14
14
 
15
+ /**
16
+ * Refresh a JWT access token this close to (or past) its `exp`
17
+ * before opening a WebSocket. WS handshakes lack the
18
+ * refresh-on-401 retry that api.js gives HTTP requests.
19
+ */
20
+ TOKEN_EXPIRY_SLACK_MS: 30_000,
21
+
15
22
  /** POSTs to device resources (cmd, update, etc.) — mid-weight calls. */
16
23
  DEVICE_RESOURCE_CALL_MS: 30_000,
17
24
 
package/lib/device-api.js CHANGED
@@ -2,6 +2,7 @@ import api from './api.js';
2
2
  import { requireConfig } from './config.js';
3
3
  import { BYTES, TIMEOUTS } from './constants.js';
4
4
  import { CommandStream } from './command-stream.js';
5
+ import { ensureFreshToken, refreshToken } from './auth.js';
5
6
  import { Readable } from 'node:stream';
6
7
 
7
8
  // Directory endpoints on the agent expect a trailing slash so it can
@@ -166,7 +167,11 @@ export function createDeviceAPI(deviceId, options = {}) {
166
167
  },
167
168
 
168
169
  // POST /v3/users/{user}/devices/{device}/resources/{resource}
169
- async callResource(resource, payload = null, { timeout = TIMEOUTS.DEVICE_RESOURCE_CALL_MS } = {}) {
170
+ async callResource(
171
+ resource,
172
+ payload = null,
173
+ { timeout = TIMEOUTS.DEVICE_RESOURCE_CALL_MS } = {},
174
+ ) {
170
175
  const url = `${resourcePath}/${resource}`;
171
176
  const response = await api.post(url, payload, { timeout });
172
177
  return response.data;
@@ -197,23 +202,39 @@ export function createDeviceAPI(deviceId, options = {}) {
197
202
  * Preferred path for the CLI: the server detects WS close and stops
198
203
  * the device-side stream automatically. Framing, timeout and
199
204
  * cancellation live in `CommandStream` (lib/command-stream.js).
205
+ *
206
+ * Auth: the WS handshake has no refresh-on-401 interceptor like
207
+ * HTTP calls do, so the token is refreshed up front when its JWT
208
+ * is already expired, and a handshake 401 gets one explicit
209
+ * refresh-and-retry.
200
210
  */
201
- execStream(cmd, { timeout = 0, onStdout, onStderr, onCancel, stdin } = {}) {
211
+ async execStream(cmd, { timeout = 0, onStdout, onStderr, onCancel, stdin } = {}) {
202
212
  const isLocal = config.server === 'localhost' || config.server === '127.0.0.1';
203
213
  const insecure = isLocal || process.env.THINR_INSECURE === '1';
204
- return new CommandStream({
205
- server: config.server,
206
- token: config.token,
207
- apiUser,
208
- deviceId,
209
- insecure,
210
- cmd,
211
- timeout,
212
- onStdout,
213
- onStderr,
214
- onCancel,
215
- stdin,
216
- }).run();
214
+ const run = (token) =>
215
+ new CommandStream({
216
+ server: config.server,
217
+ token,
218
+ apiUser,
219
+ deviceId,
220
+ insecure,
221
+ cmd,
222
+ timeout,
223
+ onStdout,
224
+ onStderr,
225
+ onCancel,
226
+ stdin,
227
+ }).run();
228
+ try {
229
+ return await run((await ensureFreshToken()) || config.token);
230
+ } catch (error) {
231
+ // A token that decodes as still-valid can be rejected
232
+ // anyway (revoked, clock skew): mirror the api.js
233
+ // interceptor and retry the handshake once after an
234
+ // explicit refresh.
235
+ if (error?.statusCode !== 401) throw error;
236
+ return run(await refreshToken());
237
+ }
217
238
  },
218
239
  };
219
240
  }
package/lib/errors.js CHANGED
@@ -7,6 +7,18 @@
7
7
  * that names the missing entity (e.g. "Device not found: abc").
8
8
  */
9
9
  export function apiError(error, messages = {}) {
10
+ // Errors normalized upstream (the tagged 'unauthorized' from a failed
11
+ // token refresh, inputError, ...) carry a stable string code and no
12
+ // axios envelope: pass them through so the code survives.
13
+ if (
14
+ error &&
15
+ typeof error.code === 'string' &&
16
+ !error.isAxiosError &&
17
+ !error.response &&
18
+ !error.request
19
+ ) {
20
+ return error;
21
+ }
10
22
  if (error && error.response) {
11
23
  const status = error.response.status;
12
24
  if (status === 404) {
@@ -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
  {
@@ -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: [
@@ -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 s.slice(0, MAX_CAPTURED_STREAM_BYTES) + `\n…[truncated, ${s.length - MAX_CAPTURED_STREAM_BYTES} more bytes]`;
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(ctx.deviceId, params.resource, params.inputs || null);
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 { status: 'changed', summary: `would pull ${params.source} (${size} bytes) → ${params.destination}` };
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 { status: 'changed', summary: `source ${params.source} not found on device` };
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: `update ${params.op}: ${result?.status || 'unknown'}` +
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 { status: 'changed', summary: `${path} is not a regular file — would overwrite` };
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 config = requireConfig();
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, { notFound: `Device not found: ${deviceId}` });
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 config = requireConfig();
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/${config.username}/devices/${deviceId}/resources/${resourceId}`,
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 config = requireConfig();
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/${config.username}/devices/${deviceId}/resources/${resourceId}/api`,
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 config = requireConfig();
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/${config.username}/devices/${deviceId}/resources/${resourceId}`,
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinremote/thinr-cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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": {