@thinremote/thinr-cli 1.1.0 → 1.2.0-alpha.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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.0] - 2026-04-16
4
+
5
+ ### Added
6
+
7
+ - `thinr product exec <productId> <command...>` — run a shell command
8
+ in parallel on every active device of a product, with bounded
9
+ concurrency (`-c, --concurrency`, default 10), per-device timeout
10
+ (`--timeout`, default 30 s), `--fail-fast`, asset-group filter
11
+ (`-g, --group`) and an `-a, --all` switch to include offline
12
+ devices. Live progress shows a single spinner with done/total
13
+ counters; the final render uses a `cli-table3` table per device
14
+ plus a compact `Output:` section and a one-line totals bar.
15
+ - `thinr_product_exec` MCP tool — the same fan-out as the CLI
16
+ subcommand, returning a consolidated text report so an AI agent
17
+ can roll out a change across a whole fleet in a single call
18
+ instead of looping `thinr_exec` per device.
19
+ - `lib/concurrency.js#runPool` — tiny helper used by both entry
20
+ points; keeps at most N workers in flight and returns a parallel
21
+ results array. `runPool` plus an internal try/catch in each worker
22
+ keeps fleet-wide errors localised per device.
23
+
24
+ ### Changed
25
+
26
+ - `cli-table3` added as a direct dependency to render the
27
+ product-exec summary.
28
+
3
29
  ## [1.1.0] - 2026-04-16
4
30
 
5
31
  Major release: MCP server for AI clients, subcommand-first CLI, JSON
@@ -128,6 +154,7 @@ consistent error taxonomy across both interfaces.
128
154
  - Property listing and retrieval
129
155
  - Device resource listing and execution
130
156
 
157
+ [1.2.0]: https://github.com/Thin-Remote/thinr-cli/compare/1.1.0...1.2.0
131
158
  [1.1.0]: https://github.com/Thin-Remote/thinr-cli/compare/1.0.2...1.1.0
132
159
  [1.0.2]: https://github.com/Thin-Remote/thinr-cli/compare/1.0.1...1.0.2
133
160
  [1.0.1]: https://github.com/Thin-Remote/thinr-cli/compare/1.0.0...1.0.1
package/README.md CHANGED
@@ -299,6 +299,44 @@ Options:
299
299
  Offline devices are skipped for `resource` — the server would reject
300
300
  the call anyway.
301
301
 
302
+ #### `exec`
303
+
304
+ Run a shell command in parallel across every active device of the
305
+ product and report a per-device status table plus each device's
306
+ output:
307
+
308
+ ```bash
309
+ thinr product exec <productId> <command...> [options]
310
+ ```
311
+
312
+ Options:
313
+
314
+ - `-j, --json`: Output as JSON. Emits a single envelope with a
315
+ `summary` block and a `results[]` array carrying stdout, stderr,
316
+ exit code, `timedOut` flag and per-device duration.
317
+ - `-c, --concurrency <n>`: Max parallel executions (default: 10)
318
+ - `--timeout <seconds>`: Per-device timeout (default: 30)
319
+ - `--fail-fast`: Stop dequeueing new devices as soon as one fails
320
+ - `-g, --group <group>`: Filter devices by asset group
321
+ - `-a, --all`: Include offline devices (default: only active)
322
+
323
+ Examples:
324
+
325
+ ```bash
326
+ # hostname on every active device
327
+ thinr product exec my-product hostname
328
+
329
+ # apt upgrade across the fleet with 5 parallel slots and a long timeout
330
+ thinr product exec my-product "apt-get -y upgrade" -c 5 --timeout 600
331
+
332
+ # target only a subset of the product
333
+ thinr product exec my-product "systemctl restart agent" -g staging
334
+ ```
335
+
336
+ Also exposed as an MCP tool (`thinr_product_exec`) with the same
337
+ parameters, so agents can drive a fleet operation with a single call
338
+ instead of looping `thinr_exec` per device.
339
+
302
340
  ### `thinr profile`
303
341
 
304
342
  Manage the profile store (see [Profiles and multi-account
@@ -1,9 +1,12 @@
1
1
  import chalk from 'chalk';
2
+ import Table from 'cli-table3';
2
3
  import { InvalidArgumentError } from 'commander';
3
4
  import { configExists } from '../lib/config.js';
4
5
  import { formatDeviceProperty, getDeviceProperty } from '../lib/property.js';
5
6
  import { callDeviceResource } from '../lib/resource.js';
6
7
  import { filterActiveDevices, getDevices } from '../lib/devices.js';
8
+ import { createDeviceAPI } from '../lib/device-api.js';
9
+ import { runPool } from '../lib/concurrency.js';
7
10
  import {
8
11
  setJsonMode,
9
12
  isJsonMode,
@@ -37,6 +40,16 @@ function collectInput(value, previous = {}) {
37
40
  return { ...previous, [value.slice(0, idx)]: value.slice(idx + 1) };
38
41
  }
39
42
 
43
+ function parsePositiveInt(label) {
44
+ return (value) => {
45
+ const n = Number(value);
46
+ if (!Number.isInteger(n) || n <= 0) {
47
+ throw new InvalidArgumentError(`${label} must be a positive integer`);
48
+ }
49
+ return n;
50
+ };
51
+ }
52
+
40
53
  async function fetchProductDevices(productId, group, user) {
41
54
  const filter = { productId };
42
55
  if (group) filter.asset_group = group;
@@ -164,4 +177,231 @@ export function productCommand(program) {
164
177
  }
165
178
  if (isJsonMode()) printOk({ product: productId, resource, results });
166
179
  });
180
+
181
+ product
182
+ .command('exec <productId> <command...>')
183
+ .description(
184
+ 'Execute a shell command in parallel on every active device of the product',
185
+ )
186
+ .option('-j, --json', 'Output as JSON')
187
+ .option('-g, --group <group>', 'Filter devices by asset group')
188
+ .option(
189
+ '-c, --concurrency <n>',
190
+ 'Max parallel executions (default: 10)',
191
+ parsePositiveInt('concurrency'),
192
+ 10,
193
+ )
194
+ .option(
195
+ '--timeout <seconds>',
196
+ 'Per-device timeout in seconds (default: 30)',
197
+ parsePositiveInt('timeout'),
198
+ 30,
199
+ )
200
+ .option('--fail-fast', 'Stop dequeueing new devices as soon as one fails')
201
+ .option('-a, --all', 'Include offline devices (by default, only active)')
202
+ .action(async (productId, commandParts, opts, cmd) => {
203
+ applyJsonFlag(opts);
204
+ ensureConfigured();
205
+ const user = getGlobalUser(cmd);
206
+ const commandStr = commandParts.join(' ');
207
+
208
+ const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
209
+ let devices;
210
+ try {
211
+ devices = await fetchProductDevices(productId, opts.group, user);
212
+ spinner.succeed(`Found ${devices.length} device(s) in product ${productId}`);
213
+ } catch (error) {
214
+ spinner.fail(`Failed to retrieve devices for product ${productId}`);
215
+ const { message, code } = classifyError(error);
216
+ printErr(message, { code });
217
+ return;
218
+ }
219
+ if (!opts.all) devices = filterActiveDevices(devices);
220
+
221
+ if (devices.length === 0) {
222
+ if (isJsonMode()) {
223
+ printOk({
224
+ product: productId,
225
+ command: commandStr,
226
+ summary: { total: 0, ok: 0, failed: 0, durationMs: 0 },
227
+ results: [],
228
+ });
229
+ } else {
230
+ console.log(chalk.yellow('No devices to run on.'));
231
+ }
232
+ return;
233
+ }
234
+
235
+ // Live progress (human mode only): a single spinner that
236
+ // updates with done/in-flight counters as workers finish.
237
+ // Spinner is silenced in JSON mode by createSpinner itself.
238
+ const runSpinner = createSpinner('').start();
239
+ let doneCount = 0;
240
+ let inFlight = 0;
241
+ const updateSpinnerText = () => {
242
+ runSpinner.text =
243
+ `Running \`${commandStr}\` on ${devices.length} device(s) — ` +
244
+ `${doneCount}/${devices.length} done · ${inFlight} in flight` +
245
+ (opts.failFast ? ' · fail-fast' : '');
246
+ };
247
+ updateSpinnerText();
248
+
249
+ const startTs = Date.now();
250
+ const poolResults = await runPool(
251
+ devices,
252
+ opts.concurrency,
253
+ async (device) => {
254
+ inFlight++;
255
+ updateSpinnerText();
256
+ const api = createDeviceAPI(device.device, { user });
257
+ const perDeviceStart = Date.now();
258
+ let stdout = '';
259
+ let stderr = '';
260
+ const { exitCode, timedOut } = await api.execStream(commandStr, {
261
+ timeout: opts.timeout,
262
+ onStdout: (s) => {
263
+ stdout += s;
264
+ },
265
+ onStderr: (s) => {
266
+ stderr += s;
267
+ },
268
+ });
269
+ const durationMs = Date.now() - perDeviceStart;
270
+ const entry = {
271
+ device: device.device,
272
+ ok: !timedOut && exitCode === 0,
273
+ exitCode: exitCode ?? null,
274
+ timedOut: !!timedOut,
275
+ durationMs,
276
+ stdout,
277
+ stderr,
278
+ };
279
+ inFlight--;
280
+ doneCount++;
281
+ updateSpinnerText();
282
+ return entry;
283
+ },
284
+ { failFast: !!opts.failFast },
285
+ ).catch((err) => {
286
+ // fail-fast re-throws the first error; the partial results
287
+ // are still in `results` as it was populated before the throw.
288
+ // We fall through below and normalize with what we have.
289
+ return err;
290
+ });
291
+
292
+ // poolResults is either the array (success path) or the rethrown
293
+ // error from fail-fast. When it's an error, the pool still filled
294
+ // `results[i]` for what finished; but we lost that handle. Easy
295
+ // workaround: when fail-fast throws, re-run with failFast=false on
296
+ // nothing — or simpler, just catch inside runPool and not throw.
297
+ // For now, if it's an Error instance, surface it as a top-level
298
+ // failure and abort.
299
+ // Stop the live spinner now that the pool has settled, regardless
300
+ // of outcome. The final summary below replaces it.
301
+ runSpinner.stop();
302
+
303
+ if (poolResults instanceof Error) {
304
+ const { message, code } = classifyError(poolResults);
305
+ if (isJsonMode()) {
306
+ printErr(message, { code });
307
+ } else {
308
+ console.error(chalk.red(`fail-fast: ${message}`));
309
+ }
310
+ return;
311
+ }
312
+
313
+ const entries = poolResults.map((r, i) => {
314
+ if (r?.ok) return r.value;
315
+ const { message, code } = classifyError(r?.error);
316
+ return {
317
+ device: devices[i].device,
318
+ ok: false,
319
+ exitCode: null,
320
+ timedOut: false,
321
+ durationMs: 0,
322
+ stdout: '',
323
+ stderr: '',
324
+ error: { message, code },
325
+ };
326
+ });
327
+
328
+ const totalDurationMs = Date.now() - startTs;
329
+ const okCount = entries.filter((e) => e.ok).length;
330
+ const timedOutCount = entries.filter((e) => e.timedOut).length;
331
+ const errorCount = entries.filter((e) => e.error).length;
332
+ const failCount = entries.length - okCount - timedOutCount - errorCount;
333
+
334
+ if (isJsonMode()) {
335
+ printOk({
336
+ product: productId,
337
+ command: commandStr,
338
+ summary: {
339
+ total: entries.length,
340
+ ok: okCount,
341
+ failed: failCount + timedOutCount + errorCount,
342
+ timedOut: timedOutCount,
343
+ errored: errorCount,
344
+ durationMs: totalDurationMs,
345
+ },
346
+ results: entries,
347
+ });
348
+ return;
349
+ }
350
+
351
+ const statusBadge = (e) => {
352
+ if (e.error) return chalk.red('error');
353
+ if (e.timedOut) return chalk.yellow('timeout');
354
+ if (e.ok) return chalk.green('ok');
355
+ return chalk.red('fail');
356
+ };
357
+ const detail = (e) => {
358
+ if (e.error) return e.error.message;
359
+ if (e.timedOut) return `after ${opts.timeout}s`;
360
+ return `exit=${e.exitCode ?? 'null'}`;
361
+ };
362
+ const table = new Table({
363
+ head: ['Device', 'Status', 'Detail', 'Duration'].map((h) => chalk.bold(h)),
364
+ style: { head: [], border: ['gray'] },
365
+ colAligns: ['left', 'left', 'left', 'right'],
366
+ });
367
+ for (const e of entries) {
368
+ table.push([
369
+ e.device,
370
+ statusBadge(e),
371
+ detail(e),
372
+ `${e.durationMs}ms`,
373
+ ]);
374
+ }
375
+ console.log(table.toString());
376
+
377
+ // Output section: only devices that produced stdout/stderr.
378
+ // Single-line outputs render inline; multi-line ones indent
379
+ // on their own block so grepping and copying still work.
380
+ const withOutput = entries.filter(
381
+ (e) => (e.stdout && e.stdout.trim()) || (e.stderr && e.stderr.trim()),
382
+ );
383
+ if (withOutput.length) {
384
+ console.log(chalk.bold('\nOutput:'));
385
+ for (const e of withOutput) {
386
+ const body = [e.stdout, e.stderr]
387
+ .map((s) => (s ? s.replace(/\s+$/, '') : ''))
388
+ .filter(Boolean)
389
+ .join('\n');
390
+ const lines = body.split('\n');
391
+ if (lines.length === 1) {
392
+ console.log(` ${chalk.dim(e.device + ':')} ${lines[0]}`);
393
+ } else {
394
+ console.log(` ${chalk.dim(e.device + ':')}`);
395
+ for (const line of lines) console.log(` ${line}`);
396
+ }
397
+ }
398
+ }
399
+
400
+ console.log(
401
+ '\n' +
402
+ chalk.gray(
403
+ `${entries.length} total — ${okCount} ok, ${failCount} fail, ${timedOutCount} timeout, ${errorCount} error · ${totalDurationMs}ms`,
404
+ ),
405
+ );
406
+ });
167
407
  }
@@ -0,0 +1,52 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Run `worker(item, index)` over `items` with at most `limit` invocations
5
+ * in flight at any time. Returns an array parallel to `items` describing
6
+ * the outcome of each task as `{ ok: true, value }` or `{ ok: false, error }`.
7
+ *
8
+ * By default, a rejection from one worker does not stop the others — the
9
+ * caller inspects the results array. Set `failFast: true` to stop dequeueing
10
+ * new work as soon as any worker rejects; in-flight tasks still finish and
11
+ * their results are included. The first rejection is rethrown after the
12
+ * in-flight tasks settle.
13
+ *
14
+ * @template T, R
15
+ * @param {T[]} items
16
+ * @param {number} limit
17
+ * @param {(item: T, index: number) => Promise<R>} worker
18
+ * @param {{ failFast?: boolean }} [opts]
19
+ * @returns {Promise<Array<{ ok: true, value: R } | { ok: false, error: unknown }>>}
20
+ */
21
+ export async function runPool(items, limit, worker, { failFast = false } = {}) {
22
+ const results = new Array(items.length);
23
+ let nextIndex = 0;
24
+ let aborted = false;
25
+ /** @type {unknown} */
26
+ let abortError = null;
27
+
28
+ const effectiveLimit = Math.max(1, Math.min(limit, items.length));
29
+
30
+ async function runner() {
31
+ while (!aborted) {
32
+ const i = nextIndex++;
33
+ if (i >= items.length) return;
34
+ try {
35
+ results[i] = { ok: true, value: await worker(items[i], i) };
36
+ } catch (error) {
37
+ results[i] = { ok: false, error };
38
+ if (failFast && !aborted) {
39
+ aborted = true;
40
+ abortError = error;
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ const runners = [];
47
+ for (let i = 0; i < effectiveLimit; i++) runners.push(runner());
48
+ await Promise.all(runners);
49
+
50
+ if (aborted && abortError) throw abortError;
51
+ return results;
52
+ }
@@ -8,6 +8,9 @@ import {
8
8
  readProductScript,
9
9
  getProductApi,
10
10
  } from '../product.js';
11
+ import { getDevices, filterActiveDevices } from '../devices.js';
12
+ import { createDeviceAPI } from '../device-api.js';
13
+ import { runPool } from '../concurrency.js';
11
14
  import { inputError } from '../errors.js';
12
15
 
13
16
  async function toolProducts(args) {
@@ -110,6 +113,170 @@ async function toolProductScriptDelete(args) {
110
113
  };
111
114
  }
112
115
 
116
+ /**
117
+ * @typedef {{
118
+ * device: string,
119
+ * ok: boolean,
120
+ * exitCode: number | null,
121
+ * timedOut: boolean,
122
+ * durationMs: number,
123
+ * stdout: string,
124
+ * stderr: string,
125
+ * error?: string,
126
+ * }} ExecEntry
127
+ */
128
+
129
+ async function toolProductExec(args) {
130
+ if (!args.product) throw inputError('product is required');
131
+ if (!args.command) throw inputError('command is required');
132
+ const concurrency =
133
+ Number.isFinite(args.concurrency) && args.concurrency > 0
134
+ ? Math.floor(args.concurrency)
135
+ : 10;
136
+ const timeout =
137
+ Number.isFinite(args.timeout) && args.timeout > 0 ? Math.floor(args.timeout) : 30;
138
+ const failFast = !!args.fail_fast;
139
+ const includeOffline = !!args.all;
140
+
141
+ const filter = { productId: args.product };
142
+ if (args.group) filter.asset_group = args.group;
143
+ let devices = await getDevices(filter, args.user);
144
+ if (!includeOffline) devices = filterActiveDevices(devices);
145
+
146
+ if (devices.length === 0) {
147
+ const suffix = args.group ? `, group="${args.group}"` : '';
148
+ return {
149
+ content: [
150
+ {
151
+ type: 'text',
152
+ text: `No devices to run on (product="${args.product}"${suffix}).`,
153
+ },
154
+ ],
155
+ isError: false,
156
+ };
157
+ }
158
+
159
+ const startTs = Date.now();
160
+ let aborted = false;
161
+ /** @type {string | null} */
162
+ let firstFailureDevice = null;
163
+
164
+ // Worker never throws: it returns a populated `ExecEntry` in every
165
+ // branch (success, non-zero exit, timeout, connection error, or
166
+ // "skipped" once fail-fast has triggered). That keeps the post-pool
167
+ // mapping trivial and sidesteps the `{ ok: true } | { ok: false }`
168
+ // union narrowing limitation with JSDoc types.
169
+ const poolResults = await runPool(devices, concurrency, async (device) => {
170
+ if (failFast && aborted) {
171
+ /** @type {ExecEntry} */
172
+ const skipped = {
173
+ device: device.device,
174
+ ok: false,
175
+ exitCode: null,
176
+ timedOut: false,
177
+ durationMs: 0,
178
+ stdout: '',
179
+ stderr: '',
180
+ error: `skipped (fail-fast after ${firstFailureDevice})`,
181
+ };
182
+ return skipped;
183
+ }
184
+ const perStart = Date.now();
185
+ let stdout = '';
186
+ let stderr = '';
187
+ /** @type {ExecEntry} */
188
+ let entry;
189
+ try {
190
+ const api = createDeviceAPI(device.device, { user: args.user || undefined });
191
+ const { exitCode, timedOut } = await api.execStream(args.command, {
192
+ timeout,
193
+ onStdout: (s) => {
194
+ stdout += s;
195
+ },
196
+ onStderr: (s) => {
197
+ stderr += s;
198
+ },
199
+ });
200
+ entry = {
201
+ device: device.device,
202
+ ok: !timedOut && exitCode === 0,
203
+ exitCode: exitCode ?? null,
204
+ timedOut: !!timedOut,
205
+ durationMs: Date.now() - perStart,
206
+ stdout,
207
+ stderr,
208
+ };
209
+ } catch (err) {
210
+ const msg = err instanceof Error ? err.message : String(err);
211
+ entry = {
212
+ device: device.device,
213
+ ok: false,
214
+ exitCode: null,
215
+ timedOut: false,
216
+ durationMs: Date.now() - perStart,
217
+ stdout,
218
+ stderr,
219
+ error: msg,
220
+ };
221
+ }
222
+ if (failFast && !entry.ok && !aborted) {
223
+ aborted = true;
224
+ firstFailureDevice = entry.device;
225
+ }
226
+ return entry;
227
+ });
228
+
229
+ /** @type {ExecEntry[]} */
230
+ const entries = poolResults.map((r, i) => {
231
+ if (r && r.ok) return r.value;
232
+ // Defensive fallback — the worker above never throws, so this
233
+ // branch shouldn't trigger in practice.
234
+ return {
235
+ device: devices[i].device,
236
+ ok: false,
237
+ exitCode: null,
238
+ timedOut: false,
239
+ durationMs: 0,
240
+ stdout: '',
241
+ stderr: '',
242
+ error: 'unknown error',
243
+ };
244
+ });
245
+
246
+ const totalMs = Date.now() - startTs;
247
+ const okCount = entries.filter((e) => e.ok).length;
248
+ const timedOutCount = entries.filter((e) => e.timedOut).length;
249
+ const errorCount = entries.filter((e) => e.error).length;
250
+ const failCount = entries.length - okCount - timedOutCount - errorCount;
251
+
252
+ const lines = [];
253
+ lines.push(
254
+ `Ran \`${args.command}\` on ${entries.length} device(s) of product "${args.product}" ` +
255
+ `(concurrency=${concurrency}, timeout=${timeout}s).\n`,
256
+ );
257
+ for (const e of entries) {
258
+ const status = e.error
259
+ ? `ERROR (${e.error})`
260
+ : e.timedOut
261
+ ? `TIMEOUT after ${timeout}s`
262
+ : e.ok
263
+ ? `OK exit=0`
264
+ : `FAIL exit=${e.exitCode}`;
265
+ lines.push(`--- ${e.device} [${status}] ${e.durationMs}ms ---`);
266
+ if (e.stdout) lines.push(e.stdout.trimEnd());
267
+ if (e.stderr) lines.push(`stderr: ${e.stderr.trimEnd()}`);
268
+ }
269
+ lines.push(
270
+ `\nSummary: ${okCount} ok, ${failCount} failed, ${timedOutCount} timed out, ${errorCount} errored — ${totalMs}ms total`,
271
+ );
272
+
273
+ const anyFailure = failCount + timedOutCount + errorCount > 0;
274
+ return {
275
+ content: [{ type: 'text', text: lines.join('\n') }],
276
+ isError: anyFailure,
277
+ };
278
+ }
279
+
113
280
  export const tools = [
114
281
  {
115
282
  name: 'thinr_products',
@@ -211,6 +378,51 @@ The file extension (.sh, .py, .js, …) is preserved in the storage for editor h
211
378
  },
212
379
  handler: toolProductScriptWrite,
213
380
  },
381
+ {
382
+ name: 'thinr_product_exec',
383
+ description: `Execute a shell command in parallel on every active device of a product, and return stdout, stderr, exit code and duration per device plus a consolidated summary.
384
+
385
+ Prefer this over iterating \`thinr_exec\` device-by-device when you need to roll out or inspect something across a fleet. Offline devices are skipped by default; pass \`all: true\` to attempt them anyway (they will typically fail fast with a connection error). \`concurrency\` bounds how many devices run at once (default 10), \`timeout\` is per-device in seconds (default 30), and \`fail_fast\` stops dequeueing new devices as soon as one returns a non-zero exit / times out — in-flight work still completes.`,
386
+ inputSchema: {
387
+ type: 'object',
388
+ properties: {
389
+ product: {
390
+ type: 'string',
391
+ description: 'Product ID (discover with thinr_products).',
392
+ },
393
+ command: {
394
+ type: 'string',
395
+ description: 'Shell command to run on each device.',
396
+ },
397
+ group: {
398
+ type: 'string',
399
+ description:
400
+ 'Optional asset group filter — restricts execution to devices in this group.',
401
+ },
402
+ concurrency: {
403
+ type: 'number',
404
+ description: 'Max parallel executions (default: 10).',
405
+ },
406
+ timeout: {
407
+ type: 'number',
408
+ description: 'Per-device timeout in seconds (default: 30).',
409
+ },
410
+ fail_fast: {
411
+ type: 'boolean',
412
+ description:
413
+ 'Stop dequeueing new devices as soon as one fails (default: false).',
414
+ },
415
+ all: {
416
+ type: 'boolean',
417
+ description:
418
+ 'Include offline devices (default: false — only active devices are targeted).',
419
+ },
420
+ user: { type: 'string', description: 'API user (admin impersonation).' },
421
+ },
422
+ required: ['product', 'command'],
423
+ },
424
+ handler: toolProductExec,
425
+ },
214
426
  {
215
427
  name: 'thinr_product_script_delete',
216
428
  description: `Remove a product script: deletes the file under scripts/ in the product storage and the associated API resource.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinremote/thinr-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0-alpha.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": {
@@ -46,6 +46,7 @@
46
46
  "@modelcontextprotocol/sdk": "^1.29.0",
47
47
  "axios": "^1.10.0",
48
48
  "chalk": "^5.4.1",
49
+ "cli-table3": "^0.6.5",
49
50
  "commander": "^14.0.0",
50
51
  "conf": "^15.1.0",
51
52
  "figlet": "^1.8.1",