@thinremote/thinr-cli 1.3.4 → 1.4.0

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +117 -123
  3. package/bin/thinr.js +43 -105
  4. package/commands/_shared.js +10 -90
  5. package/commands/device/_shared.js +2 -2
  6. package/commands/device/exec.js +2 -2
  7. package/commands/device/fs.js +2 -2
  8. package/commands/device/index.js +5 -19
  9. package/commands/device/logs.js +4 -4
  10. package/commands/device/status.js +1 -1
  11. package/commands/device/transfer.js +2 -2
  12. package/commands/device/update.js +8 -2
  13. package/commands/fleet/upgrade.js +3 -3
  14. package/commands/playbook/run.js +2 -2
  15. package/commands/playbook/validate.js +2 -2
  16. package/commands/product/_shared.js +1 -1
  17. package/commands/product/exec.js +2 -2
  18. package/commands/product/fs.js +2 -2
  19. package/commands/product/index.js +4 -11
  20. package/commands/product/logs.js +2 -2
  21. package/commands/product/metric.js +2 -2
  22. package/commands/product/playbook.js +5 -5
  23. package/commands/product/property.js +4 -4
  24. package/commands/product/push.js +2 -2
  25. package/commands/product/resource.js +4 -4
  26. package/commands/product/status.js +3 -3
  27. package/dist/dashboard.js +12 -12
  28. package/docs/guides/thinremote-metrics-logs.md +59 -0
  29. package/docs/guides/thinremote-playbooks.md +133 -0
  30. package/docs/guides/thinremote-scripts.md +115 -0
  31. package/docs/guides/thinremote.md +116 -0
  32. package/lib/command-stream.js +3 -2
  33. package/lib/console.js +6 -6
  34. package/lib/constants.js +1 -19
  35. package/lib/dashboard/event-stream.js +5 -5
  36. package/lib/device-api.js +4 -4
  37. package/lib/guides.js +41 -0
  38. package/lib/identity.js +75 -0
  39. package/lib/local-files.js +54 -0
  40. package/lib/mcp/{tools-install.js → agent-install.js} +12 -61
  41. package/lib/mcp/helpers.js +5 -25
  42. package/lib/mcp/playbook-ops.js +393 -0
  43. package/lib/mcp/registry.js +9 -43
  44. package/lib/mcp/resource-hooks.js +53 -0
  45. package/lib/mcp/resource-types.js +857 -0
  46. package/lib/mcp/tools-exec.js +217 -177
  47. package/lib/monitoring.js +3 -3
  48. package/lib/playbook/actions.js +30 -18
  49. package/lib/playbook/expression.js +1 -4
  50. package/lib/playbook/fleet.js +4 -1
  51. package/lib/playbook/loader.js +16 -8
  52. package/lib/playbook/runner.js +40 -8
  53. package/lib/playbook/schema.js +108 -34
  54. package/lib/product/log-presets.js +1 -1
  55. package/lib/product/logs.js +2 -2
  56. package/lib/product-orchestrator.js +14 -9
  57. package/lib/product.js +29 -227
  58. package/lib/proxy.js +4 -4
  59. package/lib/status.js +13 -5
  60. package/package.json +4 -2
  61. package/commands/device/delete.js +0 -53
  62. package/commands/device/list.js +0 -55
  63. package/commands/device/property.js +0 -52
  64. package/commands/device/resource.js +0 -52
  65. package/commands/logout.js +0 -47
  66. package/commands/product/list.js +0 -86
  67. package/commands/product/props.js +0 -125
  68. package/commands/profile.js +0 -173
  69. package/lib/alarms.js +0 -223
  70. package/lib/api.js +0 -89
  71. package/lib/auth.js +0 -428
  72. package/lib/bucket.js +0 -82
  73. package/lib/concurrency.js +0 -52
  74. package/lib/config.js +0 -230
  75. package/lib/devices.js +0 -60
  76. package/lib/errors.js +0 -65
  77. package/lib/format.js +0 -114
  78. package/lib/mcp/server.js +0 -67
  79. package/lib/mcp/tools-alarms.js +0 -507
  80. package/lib/mcp/tools-buckets.js +0 -99
  81. package/lib/mcp/tools-devices.js +0 -235
  82. package/lib/mcp/tools-filesystem.js +0 -281
  83. package/lib/mcp/tools-logs.js +0 -225
  84. package/lib/mcp/tools-playbook.js +0 -210
  85. package/lib/mcp/tools-products.js +0 -1557
  86. package/lib/mcp/tools-profile.js +0 -635
  87. package/lib/mcp/tools-profiles.js +0 -30
  88. package/lib/mcp/tools-properties.js +0 -79
  89. package/lib/mcp/tools-resources.js +0 -78
  90. package/lib/mcp/tools-scripts.js +0 -212
  91. package/lib/mcp/tools-tokens.js +0 -369
  92. package/lib/output.js +0 -114
  93. package/lib/paths.js +0 -32
  94. package/lib/profile.js +0 -657
  95. package/lib/property.js +0 -75
  96. package/lib/resource.js +0 -151
  97. package/lib/storage.js +0 -117
  98. package/lib/tokens.js +0 -366
@@ -1,1557 +0,0 @@
1
- // @ts-check
2
- import { Buffer } from 'node:buffer';
3
- import {
4
- getProducts,
5
- getProduct,
6
- createProduct,
7
- productExists,
8
- updateProduct,
9
- deleteProductWithStorage,
10
- installProductScript,
11
- removeProductScript,
12
- listProductScripts,
13
- readProductScript,
14
- getProductApi,
15
- getProductProperties,
16
- getProductProperty,
17
- setProductProperty,
18
- deleteProductProperty,
19
- listDashboardMetrics,
20
- upsertDashboardMetric,
21
- removeDashboardMetric,
22
- DASHBOARD_METRICS_PROPERTY,
23
- findProductPlaybook,
24
- listProductPlaybooks,
25
- readProductPlaybook,
26
- uploadProductPlaybook,
27
- deleteProductPlaybook,
28
- uploadFleetRunReport,
29
- } from '../product.js';
30
- import { createDeviceAPI } from '../device-api.js';
31
- import { TIMEOUTS } from '../constants.js';
32
- import { runProductFanOut } from '../product-orchestrator.js';
33
- import { requireConfig } from '../config.js';
34
- import { filterActiveDevices, getDevices } from '../devices.js';
35
- import { parsePlaybook } from '../playbook/loader.js';
36
- import { buildDryRunPlan, runPlaybook } from '../playbook/runner.js';
37
- import { DEFAULT_FAILURE_THRESHOLD, runFleetPlaybook } from '../playbook/fleet.js';
38
- import { listVariables, resolveVarScope } from '../playbook/vars.js';
39
- import { inputError } from '../errors.js';
40
-
41
- async function toolProducts(args) {
42
- const user = args.user;
43
- const products = await getProducts(user);
44
- if (products.length === 0) {
45
- return { content: [{ type: 'text', text: 'No products configured' }], isError: false };
46
- }
47
- const lines = await Promise.all(
48
- products.map(async (p) => {
49
- const id = p.product;
50
- const label = p.name && p.name !== id ? ` (${p.name})` : '';
51
- const enabled = p.enabled ? '✓' : '✗';
52
- const apis = await getProductApi(id, user);
53
- const scripts = Object.keys(apis).length;
54
- const scriptsLabel = scripts > 0 ? ` — ${scripts} api-resource(s)` : '';
55
- return `${enabled} ${id}${label}${scriptsLabel}`;
56
- }),
57
- );
58
- return {
59
- content: [{ type: 'text', text: `${products.length} product(s):\n${lines.join('\n')}` }],
60
- isError: false,
61
- };
62
- }
63
-
64
- async function toolProductCreate(args) {
65
- if (!args.product) throw inputError('product is required');
66
- if (!/^[A-Za-z0-9._-]+$/.test(args.product)) {
67
- throw inputError(
68
- `Invalid product id "${args.product}" (allowed: letters, digits, dot, dash, underscore)`,
69
- );
70
- }
71
- if (await productExists(args.product, args.user)) {
72
- const err = new Error(`Product "${args.product}" already exists`);
73
- err.code = 'already_exists';
74
- throw err;
75
- }
76
-
77
- let iconSpec;
78
- if (args.icon) {
79
- if (!args.icon.source) throw inputError('icon.source is required when icon is set');
80
- const type = args.icon.type || 'fa';
81
- if (type !== 'fa' && type !== 'svg') {
82
- throw inputError(`icon.type must be "fa" or "svg" (got "${type}")`);
83
- }
84
- iconSpec = {
85
- type,
86
- source: args.icon.source,
87
- color: args.icon.color || '#000000',
88
- background: args.icon.background || '#ffffff',
89
- };
90
- }
91
-
92
- await createProduct(args.product, args.user, {
93
- name: args.name,
94
- description: args.description,
95
- ...(iconSpec ? { icon: iconSpec } : {}),
96
- });
97
-
98
- return {
99
- content: [
100
- {
101
- type: 'text',
102
- text: `Created product "${args.product}"${args.name ? ` (${args.name})` : ''}.`,
103
- },
104
- ],
105
- isError: false,
106
- };
107
- }
108
-
109
- async function toolProductUpdate(args) {
110
- if (!args.product) throw inputError('product is required');
111
-
112
- const patch = {};
113
- if (args.name !== undefined) patch.name = args.name;
114
- if (args.description !== undefined) patch.description = args.description;
115
- if (args.enabled !== undefined) patch.enabled = !!args.enabled;
116
-
117
- if (args.icon) {
118
- if (!args.icon.source) throw inputError('icon.source is required when icon is set');
119
- const type = args.icon.type || 'fa';
120
- if (type !== 'fa' && type !== 'svg') {
121
- throw inputError(`icon.type must be "fa" or "svg" (got "${type}")`);
122
- }
123
- // Fetch current config so the icon update doesn't clobber unrelated
124
- // fields (`type`, custom dashboards, profile-side bits the API
125
- // groups under config).
126
- const current = await getProduct(args.product, args.user);
127
- const config = { ...(current?.config || {}) };
128
- config.icons = [
129
- {
130
- conditions: [],
131
- icon: {
132
- type,
133
- source: args.icon.source,
134
- color: args.icon.color || '#000000',
135
- background: args.icon.background || '#ffffff',
136
- },
137
- },
138
- ];
139
- patch.config = config;
140
- }
141
-
142
- const fields = Object.keys(patch);
143
- if (fields.length === 0) {
144
- throw inputError('at least one of name, description, enabled, icon must be provided');
145
- }
146
-
147
- await updateProduct(args.product, args.user, patch);
148
-
149
- const summary = fields.map((f) => (f === 'config' ? 'icon' : f)).join(', ');
150
- return {
151
- content: [{ type: 'text', text: `Updated product "${args.product}" (${summary}).` }],
152
- isError: false,
153
- };
154
- }
155
-
156
- async function toolProductDelete(args) {
157
- if (!args.product) throw inputError('product is required');
158
- const { steps } = await deleteProductWithStorage(args.product, args.user, {
159
- keepStorage: !!args.keep_storage,
160
- });
161
- return {
162
- content: [
163
- { type: 'text', text: `Removed "${args.product}":\n - ${steps.join('\n - ')}` },
164
- ],
165
- isError: false,
166
- };
167
- }
168
-
169
- async function toolProductScriptList(args) {
170
- if (!args.product) throw inputError('product is required');
171
- const entries = await listProductScripts(args.product, args.user);
172
- const header =
173
- entries.length === 0
174
- ? `No scripts in product "${args.product}"`
175
- : `${entries.length} script(s) in product "${args.product}":`;
176
- const lines = entries.map((e) => {
177
- const marker = e.registered ? '[api]' : '[orphan]';
178
- return ` ${marker} ${e.name} (${e.size} bytes) → resource: ${e.stem}`;
179
- });
180
- return {
181
- content: [{ type: 'text', text: [header, ...lines].join('\n') }],
182
- isError: false,
183
- };
184
- }
185
-
186
- async function toolProductScriptRead(args) {
187
- if (!args.product) throw inputError('product is required');
188
- if (!args.name) throw inputError('name is required');
189
- const body = await readProductScript(args.product, args.name, args.user);
190
- return {
191
- content: [{ type: 'text', text: body }],
192
- isError: false,
193
- };
194
- }
195
-
196
- async function toolProductScriptWrite(args) {
197
- const { steps, stem } = await installProductScript({
198
- product: args.product,
199
- name: args.name,
200
- content: args.content,
201
- user: args.user,
202
- input: args.input,
203
- output: args.output,
204
- });
205
- const lines = [
206
- `Installed product script "${args.name}" on "${args.product}".`,
207
- `Steps:`,
208
- ...steps.map((s) => ` - ${s}`),
209
- `Invoke on any device of this product with thinr_resource_call(device=<id>, resource="${stem}", inputs={input: {...}}).`,
210
- ];
211
- return {
212
- content: [{ type: 'text', text: lines.join('\n') }],
213
- isError: false,
214
- };
215
- }
216
-
217
- async function toolProductPropertyGet(args) {
218
- if (!args.product) throw inputError('product is required');
219
- if (args.property) {
220
- const value = await getProductProperty(args.product, args.property, args.user);
221
- return {
222
- content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
223
- isError: false,
224
- };
225
- }
226
- const list = await getProductProperties(args.product, args.user);
227
- const names = Array.isArray(list) ? list.map((p) => p.property).filter(Boolean) : [];
228
- return {
229
- content: [{ type: 'text', text: names.length ? names.join('\n') : '(no properties)' }],
230
- isError: false,
231
- };
232
- }
233
-
234
- async function toolProductPropertySet(args) {
235
- if (!args.product) throw inputError('product is required');
236
- if (!args.property) throw inputError('property is required');
237
- if (args.value === undefined) throw inputError('value is required');
238
- const saved = await setProductProperty(args.product, args.property, args.value, args.user);
239
- return {
240
- content: [
241
- {
242
- type: 'text',
243
- text: `Saved property "${saved.property}" on product "${saved.product || args.product}" (value: ${JSON.stringify(saved.value)})`,
244
- },
245
- ],
246
- isError: false,
247
- };
248
- }
249
-
250
- async function toolProductPropertyDelete(args) {
251
- if (!args.product) throw inputError('product is required');
252
- if (!args.property) throw inputError('property is required');
253
- const removed = await deleteProductProperty(args.product, args.property, args.user);
254
- return {
255
- content: [
256
- {
257
- type: 'text',
258
- text: removed
259
- ? `Deleted property "${args.property}" from product "${args.product}".`
260
- : `Property "${args.property}" was not set on product "${args.product}".`,
261
- },
262
- ],
263
- isError: false,
264
- };
265
- }
266
-
267
- async function toolProductMetricList(args) {
268
- if (!args.product) throw inputError('product is required');
269
- const metrics = await listDashboardMetrics(args.product, args.user);
270
- if (metrics.length === 0) {
271
- return {
272
- content: [
273
- {
274
- type: 'text',
275
- text: `No dashboard metrics configured on product "${args.product}" (property "${DASHBOARD_METRICS_PROPERTY}" is empty).`,
276
- },
277
- ],
278
- isError: false,
279
- };
280
- }
281
- const lines = metrics.map((m) => {
282
- const parts = [
283
- m.name,
284
- `resource=${m.resource}`,
285
- m.field ? `field=${m.field}` : null,
286
- m.aggregation ? `agg=${m.aggregation}` : null,
287
- m.visualization ? `viz=${m.visualization}` : null,
288
- m.interval ? `interval=${m.interval}s` : null,
289
- ].filter(Boolean);
290
- return ` - ${parts.join(' ')}${m.label ? ` · ${m.label}` : ''}`;
291
- });
292
- return {
293
- content: [
294
- {
295
- type: 'text',
296
- text: `${metrics.length} metric(s) on "${args.product}":\n${lines.join('\n')}`,
297
- },
298
- ],
299
- isError: false,
300
- };
301
- }
302
-
303
- async function toolProductMetricSet(args) {
304
- if (!args.product) throw inputError('product is required');
305
- const metric = {
306
- name: args.name,
307
- label: args.label,
308
- resource: args.resource,
309
- field: args.field,
310
- aggregation: args.aggregation,
311
- visualization: args.visualization,
312
- interval: args.interval,
313
- unit: args.unit,
314
- };
315
- for (const k of Object.keys(metric)) if (metric[k] === undefined) delete metric[k];
316
- const { action, count } = await upsertDashboardMetric(args.product, metric, args.user);
317
- return {
318
- content: [
319
- {
320
- type: 'text',
321
- text: `Metric "${metric.name}" ${action} on product "${args.product}" (total: ${count}).`,
322
- },
323
- ],
324
- isError: false,
325
- };
326
- }
327
-
328
- async function toolProductMetricDelete(args) {
329
- if (!args.product) throw inputError('product is required');
330
- if (!args.name) throw inputError('name is required');
331
- const { action, count } = await removeDashboardMetric(args.product, args.name, args.user);
332
- return {
333
- content: [
334
- {
335
- type: 'text',
336
- text:
337
- action === 'removed'
338
- ? `Removed metric "${args.name}" from product "${args.product}" (remaining: ${count}).`
339
- : `Metric "${args.name}" was not configured on product "${args.product}".`,
340
- },
341
- ],
342
- isError: false,
343
- };
344
- }
345
-
346
- async function toolProductScriptDelete(args) {
347
- const { steps } = await removeProductScript({
348
- product: args.product,
349
- name: args.name,
350
- user: args.user,
351
- });
352
- return {
353
- content: [
354
- {
355
- type: 'text',
356
- text: `Removed product script "${args.name}" from "${args.product}":\n - ${steps.join('\n - ')}`,
357
- },
358
- ],
359
- isError: false,
360
- };
361
- }
362
-
363
- async function toolProductPlaybookList(args) {
364
- if (!args.product) throw inputError('product is required');
365
- const entries = await listProductPlaybooks(args.product, args.user);
366
- if (entries.length === 0) {
367
- return {
368
- content: [
369
- {
370
- type: 'text',
371
- text: `No playbooks registered on product "${args.product}".`,
372
- },
373
- ],
374
- isError: false,
375
- };
376
- }
377
- const lines = entries.map((e) => {
378
- const desc = e.description ? ` — ${e.description}` : '';
379
- return ` - ${e.name}${desc} (${e.path})`;
380
- });
381
- return {
382
- content: [
383
- {
384
- type: 'text',
385
- text: `${entries.length} playbook(s) on "${args.product}":\n${lines.join('\n')}`,
386
- },
387
- ],
388
- isError: false,
389
- };
390
- }
391
-
392
- async function toolProductPlaybookRead(args) {
393
- if (!args.product) throw inputError('product is required');
394
- if (!args.name) throw inputError('name is required');
395
- const content = await readProductPlaybook(args.product, args.name, args.user);
396
- return {
397
- content: [{ type: 'text', text: content }],
398
- isError: false,
399
- };
400
- }
401
-
402
- async function toolProductPlaybookWrite(args) {
403
- if (!args.product) throw inputError('product is required');
404
- if (!args.name) throw inputError('name is required');
405
- if (typeof args.content !== 'string') throw inputError('content (YAML string) is required');
406
- const result = await uploadProductPlaybook({
407
- product: args.product,
408
- name: args.name,
409
- content: args.content,
410
- description: args.description,
411
- user: args.user,
412
- skipValidation: !!args.skip_validation,
413
- });
414
- const header = `Playbook "${result.entry.name}" ${result.action} on product "${args.product}"${
415
- result.replaced ? ' (replaced existing entry)' : ''
416
- }.`;
417
- const steps = result.steps.map((s) => ` - ${s}`);
418
- return {
419
- content: [{ type: 'text', text: [header, ...steps].join('\n') }],
420
- isError: false,
421
- };
422
- }
423
-
424
- async function toolProductPlaybookRun(args) {
425
- if (!args.product) throw inputError('product is required');
426
- if (!args.name) throw inputError('name is required');
427
- if (!args.device_id) throw inputError('device is required (single-device only)');
428
-
429
- const entry = await findProductPlaybook(args.product, args.name, args.user);
430
- if (!entry) {
431
- const available = await listProductPlaybooks(args.product, args.user);
432
- throw inputError(
433
- `Playbook "${args.name}" is not registered on product "${args.product}". Available: ${available.map((e) => e.name).join(', ') || '(none)'}`,
434
- );
435
- }
436
-
437
- const content = await readProductPlaybook(args.product, args.name, args.user);
438
- const pb = parsePlaybook(content, { sourcePath: `${args.name}.yaml` });
439
-
440
- const rawVars =
441
- args.vars && typeof args.vars === 'object' && !Array.isArray(args.vars) ? args.vars : {};
442
-
443
- let resolvedScope;
444
- try {
445
- resolvedScope = resolveVarScope(pb, rawVars);
446
- } catch (err) {
447
- throw inputError(err.message);
448
- }
449
-
450
- pb.target.devices = [args.device_id];
451
- pb.target.product = args.product;
452
- pb.target.group = null;
453
-
454
- if (args.dry_run) {
455
- const plan = buildDryRunPlan(pb, { overrides: rawVars });
456
- const payload = {
457
- dry_run: true,
458
- product: args.product,
459
- name: args.name,
460
- device: args.device_id,
461
- variables: listVariables(pb),
462
- vars: resolvedScope,
463
- steps: plan,
464
- };
465
- return {
466
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
467
- isError: false,
468
- };
469
- }
470
-
471
- const all = await getDevices({ product: args.product }, args.user);
472
- const device = all.find((d) => d.device === args.device_id) || {
473
- device: args.device_id,
474
- connection: { active: false },
475
- };
476
-
477
- const results = await runPlaybook(pb, [device], {
478
- user: args.user,
479
- concurrency: 1,
480
- failFast: true,
481
- checkMode: !!args.check,
482
- overrides: rawVars,
483
- });
484
- const result = results[0];
485
- const payload = {
486
- product: args.product,
487
- name: args.name,
488
- device: args.device_id,
489
- mode: args.check ? 'check' : 'apply',
490
- ok: !!result?.ok,
491
- result,
492
- };
493
- return {
494
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
495
- isError: !result?.ok,
496
- };
497
- }
498
-
499
- async function toolProductPlaybookRollout(args) {
500
- if (!args.product) throw inputError('product is required');
501
- if (!args.name) throw inputError('name is required');
502
-
503
- const batchSize =
504
- Number.isFinite(args.batch_size) && args.batch_size > 0 ? Math.floor(args.batch_size) : 5;
505
- const failureThreshold =
506
- args.failure_threshold === undefined || args.failure_threshold === null
507
- ? DEFAULT_FAILURE_THRESHOLD
508
- : Number(args.failure_threshold);
509
- if (!Number.isFinite(failureThreshold) || failureThreshold < 0 || failureThreshold > 100) {
510
- throw inputError('failure_threshold must be a number between 0 and 100');
511
- }
512
-
513
- const entry = await findProductPlaybook(args.product, args.name, args.user);
514
- if (!entry) {
515
- const available = await listProductPlaybooks(args.product, args.user);
516
- throw inputError(
517
- `Playbook "${args.name}" is not registered on product "${args.product}". Available: ${available.map((e) => e.name).join(', ') || '(none)'}`,
518
- );
519
- }
520
-
521
- const content = await readProductPlaybook(args.product, args.name, args.user);
522
- const pb = parsePlaybook(content, { sourcePath: `${args.name}.yaml` });
523
-
524
- const rawVars =
525
- args.vars && typeof args.vars === 'object' && !Array.isArray(args.vars) ? args.vars : {};
526
-
527
- let resolvedScope;
528
- try {
529
- resolvedScope = resolveVarScope(pb, rawVars);
530
- } catch (err) {
531
- throw inputError(err.message);
532
- }
533
-
534
- const filters = {};
535
- if (args.filter && typeof args.filter === 'object' && !Array.isArray(args.filter)) {
536
- for (const [k, v] of Object.entries(args.filter)) {
537
- if (!/^[A-Za-z0-9_]+$/.test(k)) {
538
- throw inputError(`Invalid filter key "${k}"`);
539
- }
540
- filters[k] = v;
541
- }
542
- }
543
-
544
- const baseFilter = { product: args.product };
545
- if (args.group) baseFilter.asset_group = args.group;
546
- Object.assign(baseFilter, filters);
547
- let devices = await getDevices(baseFilter, args.user);
548
- if (!args.include_offline) devices = filterActiveDevices(devices);
549
-
550
- pb.target.devices = null;
551
- pb.target.product = args.product;
552
- pb.target.group = args.group || null;
553
-
554
- if (args.dry_run) {
555
- const plan = buildDryRunPlan(pb, { overrides: rawVars });
556
- const payload = {
557
- dry_run: true,
558
- product: args.product,
559
- name: args.name,
560
- fleet: true,
561
- device_count: devices.length,
562
- devices: devices.map((d) => d.device),
563
- batch_size: batchSize,
564
- failure_threshold: failureThreshold,
565
- variables: listVariables(pb),
566
- vars: resolvedScope,
567
- steps: plan,
568
- };
569
- return {
570
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
571
- isError: false,
572
- };
573
- }
574
-
575
- if (devices.length === 0) {
576
- const payload = {
577
- product: args.product,
578
- name: args.name,
579
- fleet: true,
580
- device_count: 0,
581
- aborted: false,
582
- summary: { attempted: 0, succeeded: 0, failed: 0, failureRate: 0 },
583
- message: 'no matching devices',
584
- };
585
- return {
586
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
587
- isError: false,
588
- };
589
- }
590
-
591
- const startedAt = new Date().toISOString();
592
- const outcome = await runFleetPlaybook(pb, devices, {
593
- user: args.user,
594
- batchSize,
595
- failureThreshold,
596
- overrides: rawVars,
597
- checkMode: !!args.check,
598
- });
599
- const finishedAt = new Date().toISOString();
600
-
601
- const summary = {
602
- attempted: outcome.attempted,
603
- succeeded: outcome.succeeded,
604
- failed: outcome.failed,
605
- failureRate: outcome.failureRate,
606
- total: devices.length,
607
- };
608
-
609
- const report = {
610
- product: args.product,
611
- name: args.name,
612
- user: args.user || requireConfig().username,
613
- startedAt,
614
- finishedAt,
615
- durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
616
- mode: args.check ? 'check' : 'apply',
617
- playbook: {
618
- name: pb.name || null,
619
- description: pb.description || null,
620
- steps: pb.steps.length,
621
- },
622
- rollout: {
623
- batchSize,
624
- failureThreshold,
625
- filters: Object.keys(filters).length > 0 ? filters : null,
626
- group: args.group || null,
627
- includeOffline: !!args.include_offline,
628
- },
629
- vars: resolvedScope,
630
- summary,
631
- aborted: outcome.aborted,
632
- reason: outcome.reason,
633
- batches: outcome.batches,
634
- devices: outcome.results.map((r) => ({
635
- device: r?.device,
636
- ok: !!r?.ok,
637
- skipped: !!r?.skipped,
638
- error: r?.error || null,
639
- })),
640
- };
641
-
642
- const upload = await uploadFleetRunReport({ product: args.product, report, user: args.user });
643
-
644
- const payload = {
645
- product: args.product,
646
- name: args.name,
647
- fleet: true,
648
- mode: args.check ? 'check' : 'apply',
649
- startedAt,
650
- finishedAt,
651
- batch_size: batchSize,
652
- failure_threshold: failureThreshold,
653
- device_count: devices.length,
654
- batches: outcome.batches,
655
- summary,
656
- aborted: outcome.aborted,
657
- reason: outcome.reason,
658
- results: outcome.results,
659
- report_uploaded: upload.ok,
660
- report_path: upload.ok ? upload.path : null,
661
- report_error: upload.ok ? null : upload.reason,
662
- };
663
- return {
664
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
665
- isError: outcome.aborted || summary.failed > 0,
666
- };
667
- }
668
-
669
- async function toolProductPlaybookDelete(args) {
670
- if (!args.product) throw inputError('product is required');
671
- if (!args.name) throw inputError('name is required');
672
- const result = await deleteProductPlaybook({
673
- product: args.product,
674
- name: args.name,
675
- user: args.user,
676
- });
677
- const header = result.removed
678
- ? `Deleted playbook "${args.name}" from product "${args.product}".`
679
- : `Playbook "${args.name}" was not registered on product "${args.product}".`;
680
- const steps = result.steps.map((s) => ` - ${s}`);
681
- return {
682
- content: [{ type: 'text', text: [header, ...steps].join('\n') }],
683
- isError: false,
684
- };
685
- }
686
-
687
- /**
688
- * @typedef {{
689
- * device: string,
690
- * ok: boolean,
691
- * exitCode: number | null,
692
- * timedOut: boolean,
693
- * durationMs: number,
694
- * stdout: string,
695
- * stderr: string,
696
- * error?: string,
697
- * }} ExecEntry
698
- */
699
-
700
- async function toolProductWrite(args) {
701
- if (!args.product) throw inputError('product is required');
702
- if (typeof args.path !== 'string' || !args.path) throw inputError('path is required');
703
- if (typeof args.content !== 'string') throw inputError('content (string) is required');
704
- const payload = Buffer.from(args.content, 'utf8');
705
-
706
- const { devices, entries } = await runProductFanOut({
707
- product: args.product,
708
- group: args.group,
709
- includeOffline: !!args.all,
710
- user: args.user,
711
- concurrency:
712
- Number.isFinite(args.concurrency) && args.concurrency > 0
713
- ? Math.floor(args.concurrency)
714
- : 10,
715
- failFast: !!args.fail_fast,
716
- worker: async (device) => {
717
- const t0 = Date.now();
718
- try {
719
- const api = createDeviceAPI(device.device, { user: args.user || undefined });
720
- await api.writeFile(args.path, payload);
721
- return {
722
- device: device.device,
723
- ok: true,
724
- bytes: payload.byteLength,
725
- durationMs: Date.now() - t0,
726
- };
727
- } catch (err) {
728
- return {
729
- device: device.device,
730
- ok: false,
731
- bytes: 0,
732
- durationMs: Date.now() - t0,
733
- error: err instanceof Error ? err.message : String(err),
734
- };
735
- }
736
- },
737
- skipped: (device, firstFailure) => ({
738
- device: device.device,
739
- ok: false,
740
- bytes: 0,
741
- durationMs: 0,
742
- error: `skipped (fail-fast after ${firstFailure})`,
743
- }),
744
- isFailure: (entry) => !entry.ok,
745
- });
746
-
747
- if (devices.length === 0) {
748
- const suffix = args.group ? `, group="${args.group}"` : '';
749
- return {
750
- content: [
751
- {
752
- type: 'text',
753
- text: `No devices to upload to (product="${args.product}"${suffix}).`,
754
- },
755
- ],
756
- isError: false,
757
- };
758
- }
759
-
760
- const okCount = entries.filter((e) => e.ok).length;
761
- const lines = [];
762
- lines.push(
763
- `Uploaded to ${okCount}/${entries.length} device(s) of product "${args.product}" (path: ${args.path}, ${payload.byteLength} bytes each).\n`,
764
- );
765
- for (const e of entries) {
766
- lines.push(
767
- `${e.device} ${e.ok ? 'OK' : 'FAIL'} ${e.durationMs}ms${e.ok ? '' : ` · ${e.error}`}`,
768
- );
769
- }
770
- return {
771
- content: [{ type: 'text', text: lines.join('\n') }],
772
- isError: okCount < entries.length,
773
- };
774
- }
775
-
776
- async function toolProductExec(args) {
777
- if (!args.product) throw inputError('product is required');
778
- if (!args.command) throw inputError('command is required');
779
- const concurrency =
780
- Number.isFinite(args.concurrency) && args.concurrency > 0
781
- ? Math.floor(args.concurrency)
782
- : 10;
783
- const timeout =
784
- Number.isFinite(args.timeout) && args.timeout > 0
785
- ? Math.floor(args.timeout)
786
- : TIMEOUTS.DEFAULT_EXEC_SECONDS;
787
-
788
- const { devices, entries, durationMs } = await runProductFanOut({
789
- product: args.product,
790
- group: args.group,
791
- includeOffline: !!args.all,
792
- user: args.user,
793
- concurrency,
794
- failFast: !!args.fail_fast,
795
- worker: async (device) => {
796
- const perStart = Date.now();
797
- let stdout = '';
798
- let stderr = '';
799
- try {
800
- const api = createDeviceAPI(device.device, { user: args.user || undefined });
801
- const { exitCode, timedOut } = await api.execStream(args.command, {
802
- timeout,
803
- onStdout: (s) => {
804
- stdout += s;
805
- },
806
- onStderr: (s) => {
807
- stderr += s;
808
- },
809
- });
810
- return {
811
- device: device.device,
812
- ok: !timedOut && exitCode === 0,
813
- exitCode: exitCode ?? null,
814
- timedOut: !!timedOut,
815
- durationMs: Date.now() - perStart,
816
- stdout,
817
- stderr,
818
- };
819
- } catch (err) {
820
- return {
821
- device: device.device,
822
- ok: false,
823
- exitCode: null,
824
- timedOut: false,
825
- durationMs: Date.now() - perStart,
826
- stdout,
827
- stderr,
828
- error: err instanceof Error ? err.message : String(err),
829
- };
830
- }
831
- },
832
- skipped: (device, firstFailure) => ({
833
- device: device.device,
834
- ok: false,
835
- exitCode: null,
836
- timedOut: false,
837
- durationMs: 0,
838
- stdout: '',
839
- stderr: '',
840
- error: `skipped (fail-fast after ${firstFailure})`,
841
- }),
842
- isFailure: (entry) => !entry.ok,
843
- });
844
-
845
- if (devices.length === 0) {
846
- const suffix = args.group ? `, group="${args.group}"` : '';
847
- return {
848
- content: [
849
- {
850
- type: 'text',
851
- text: `No devices to run on (product="${args.product}"${suffix}).`,
852
- },
853
- ],
854
- isError: false,
855
- };
856
- }
857
-
858
- const okCount = entries.filter((e) => e.ok).length;
859
- const timedOutCount = entries.filter((e) => e.timedOut).length;
860
- const errorCount = entries.filter((e) => e.error).length;
861
- const failCount = entries.length - okCount - timedOutCount - errorCount;
862
-
863
- const lines = [];
864
- lines.push(
865
- `Ran \`${args.command}\` on ${entries.length} device(s) of product "${args.product}" ` +
866
- `(concurrency=${concurrency}, timeout=${timeout}s).\n`,
867
- );
868
- for (const e of entries) {
869
- const status = e.error
870
- ? `ERROR (${e.error})`
871
- : e.timedOut
872
- ? `TIMEOUT after ${timeout}s`
873
- : e.ok
874
- ? `OK exit=0`
875
- : `FAIL exit=${e.exitCode}`;
876
- lines.push(`--- ${e.device} [${status}] ${e.durationMs}ms ---`);
877
- if (e.stdout) lines.push(e.stdout.trimEnd());
878
- if (e.stderr) lines.push(`stderr: ${e.stderr.trimEnd()}`);
879
- }
880
- lines.push(
881
- `\nSummary: ${okCount} ok, ${failCount} failed, ${timedOutCount} timed out, ${errorCount} errored — ${durationMs}ms total`,
882
- );
883
-
884
- const anyFailure = failCount + timedOutCount + errorCount > 0;
885
- return {
886
- content: [{ type: 'text', text: lines.join('\n') }],
887
- isError: anyFailure,
888
- };
889
- }
890
-
891
- export const tools = [
892
- {
893
- name: 'thinr_products',
894
- description:
895
- 'List all products configured on the server, with their device count and whether they have product-level scripts configured. Use this before assigning a device to a product with thinr_device_set_product, or before managing product scripts with thinr_product_script_*.',
896
- inputSchema: {
897
- type: 'object',
898
- properties: {
899
- user: { type: 'string', description: 'API user' },
900
- },
901
- required: [],
902
- },
903
- handler: toolProducts,
904
- },
905
- {
906
- name: 'thinr_product_create',
907
- description: `Create a new product. The product ID is immutable — pick it carefully (letters, digits, dot, dash, underscore). Optionally set the display name, description and icon at creation time; otherwise sensible defaults apply (name = id, generic description, Font Awesome Linux icon).
908
-
909
- Failure mode: refuses to overwrite an existing product. Use thinr_product_update to change metadata on a product that already exists.
910
-
911
- Typical flow: thinr_products to discover what is already there, thinr_product_create to register the new one, thinr_product_script_write to attach scripts, thinr_product_metric_set to register dashboard metrics, thinr_device_set_product to assign devices.`,
912
- inputSchema: {
913
- type: 'object',
914
- properties: {
915
- product: {
916
- type: 'string',
917
- description:
918
- 'Stable product ID (letters, digits, dot, dash, underscore). Cannot be changed later.',
919
- },
920
- name: {
921
- type: 'string',
922
- description:
923
- 'Display name shown across the platform UI. Defaults to the product ID.',
924
- },
925
- description: {
926
- type: 'string',
927
- description: 'Free-form description.',
928
- },
929
- icon: {
930
- type: 'object',
931
- description:
932
- "Optional icon. Pick `type: 'fa'` for a Font Awesome class or `type: 'svg'` for inline SVG markup. Defaults to a generic Linux icon.",
933
- properties: {
934
- type: {
935
- type: 'string',
936
- enum: ['fa', 'svg'],
937
- description:
938
- "Icon kind: 'fa' (default) for Font Awesome class, 'svg' for inline SVG markup.",
939
- },
940
- source: {
941
- type: 'string',
942
- description:
943
- 'Font Awesome class (e.g. "fab fa-linux", "fas fa-server") or full SVG markup including the `<svg>` wrapper.',
944
- },
945
- color: {
946
- type: 'string',
947
- description: 'Hex foreground color. Default: #000000.',
948
- },
949
- background: {
950
- type: 'string',
951
- description: 'Hex background color. Default: #ffffff.',
952
- },
953
- },
954
- required: ['source'],
955
- },
956
- user: { type: 'string', description: 'API user' },
957
- },
958
- required: ['product'],
959
- },
960
- handler: toolProductCreate,
961
- },
962
- {
963
- name: 'thinr_product_update',
964
- description: `Update product metadata in place. Pass any subset of fields (\`name\`, \`description\`, \`enabled\`, \`icon\`) — only the supplied ones are modified, the rest are left untouched. Useful to set a friendlier display name shown across the platform UI, change the description, toggle the product on or off, or replace its icon.
965
-
966
- Icons accept Font Awesome classes (\`{ type: "fa", source: "fab fa-linux" }\`) or inline SVG markup (\`{ type: "svg", source: "<svg ...>...</svg>" }\`). When an icon is supplied, the current product config is fetched first so unrelated fields (type, profile, dashboards) are preserved.
967
-
968
- Does not change the product ID. To rename the ID itself, the product has to be recreated and devices reassigned.`,
969
- inputSchema: {
970
- type: 'object',
971
- properties: {
972
- product: { type: 'string', description: 'Product ID to update.' },
973
- name: {
974
- type: 'string',
975
- description:
976
- 'Display name shown across the platform UI (dashboards, lists, etc.).',
977
- },
978
- description: {
979
- type: 'string',
980
- description: 'Free-form description.',
981
- },
982
- enabled: {
983
- type: 'boolean',
984
- description:
985
- 'Enable or disable the product. Disabled products do not deliver scripts or buckets to devices.',
986
- },
987
- icon: {
988
- type: 'object',
989
- description:
990
- "Replace the product's icon. Pick `type: 'fa'` for a Font Awesome class or `type: 'svg'` for inline SVG markup.",
991
- properties: {
992
- type: {
993
- type: 'string',
994
- enum: ['fa', 'svg'],
995
- description:
996
- "Icon kind: 'fa' (default) for Font Awesome class, 'svg' for inline SVG markup.",
997
- },
998
- source: {
999
- type: 'string',
1000
- description:
1001
- 'Font Awesome class (e.g. "fab fa-linux", "fas fa-server") or full SVG markup including the `<svg>` wrapper.',
1002
- },
1003
- color: {
1004
- type: 'string',
1005
- description: 'Hex foreground color. Default: #000000.',
1006
- },
1007
- background: {
1008
- type: 'string',
1009
- description: 'Hex background color. Default: #ffffff.',
1010
- },
1011
- },
1012
- required: ['source'],
1013
- },
1014
- user: { type: 'string', description: 'API user' },
1015
- },
1016
- required: ['product'],
1017
- },
1018
- handler: toolProductUpdate,
1019
- },
1020
- {
1021
- name: 'thinr_product_delete',
1022
- description:
1023
- 'Delete a product and, by default, its file storage with all scripts. Destructive: confirm with the user before calling. Any device currently assigned to the product is left as-is (unassigned from it by the server) — this tool does not touch the user-level monitoring bucket, which is shared across products.',
1024
- inputSchema: {
1025
- type: 'object',
1026
- properties: {
1027
- product: { type: 'string', description: 'Product ID to delete.' },
1028
- keep_storage: {
1029
- type: 'boolean',
1030
- description:
1031
- "When true, preserve the product's file storage (scripts + index.js). Default: false (also delete the storage).",
1032
- },
1033
- user: { type: 'string', description: 'API user' },
1034
- },
1035
- required: ['product'],
1036
- },
1037
- handler: toolProductDelete,
1038
- },
1039
- {
1040
- name: 'thinr_product_script_list',
1041
- description: `List product-level scripts stored under the product's file storage, cross-referenced with the API resources they expose.
1042
-
1043
- Product scripts are executable files kept in a per-product storage ("<product>") under scripts/. Each script file is exposed as a product API resource (same name without the extension) that can be invoked on any device of the product via thinr_resource_call. Compared to device-level scripts (thinr_script_*), product scripts scale to large fleets because the source of truth lives server-side and is downloaded inline on each invocation — no per-device deployment, no drift.`,
1044
- inputSchema: {
1045
- type: 'object',
1046
- properties: {
1047
- product: { type: 'string', description: 'Product ID.' },
1048
- user: { type: 'string', description: 'API user' },
1049
- },
1050
- required: ['product'],
1051
- },
1052
- handler: toolProductScriptList,
1053
- },
1054
- {
1055
- name: 'thinr_product_script_read',
1056
- description: `Read the current content of a product script from the product storage. Useful to inspect or edit an existing script before rewriting it with thinr_product_script_write.`,
1057
- inputSchema: {
1058
- type: 'object',
1059
- properties: {
1060
- product: { type: 'string', description: 'Product ID.' },
1061
- name: {
1062
- type: 'string',
1063
- description:
1064
- 'Script filename as stored under scripts/ (with extension if any, e.g. "backup.sh").',
1065
- },
1066
- user: { type: 'string', description: 'API user' },
1067
- },
1068
- required: ['product', 'name'],
1069
- },
1070
- handler: toolProductScriptRead,
1071
- },
1072
- {
1073
- name: 'thinr_product_script_write',
1074
- description: `Upsert a product-level script: installs a new script if the name is unused, replaces the existing one in place otherwise. The product must exist already — create it first with thinr_product_create. This tool only handles the script side: creates the product's storage if missing, writes the generic index.js wrapper on first use, wires profile.code.storage, enables the product, uploads the script under scripts/<name>, and creates/updates the API resource so the script is immediately callable on any device of the product.
1075
-
1076
- Naming convention: \`*_write\` denotes upsert, shared with every per-product leaf. The product container itself uses split thinr_product_create / thinr_product_update tools to make accidental overwrites of product metadata impossible.
1077
-
1078
- Script contract:
1079
- - Any executable source. If the body starts with a shebang (#!/usr/bin/env python3, #!/bin/bash, …) the agent materializes it to a temp file and runs it with the declared interpreter. Without a shebang, the body runs through the shell (pipes, redirects, && all work).
1080
- - Any object the caller sends when invoking the resource is forwarded to the script on stdin as JSON. Parse with jq / json.load / JSON.parse.
1081
- - When \`output\` is true (default), stdout must be valid JSON; it is parsed server-side (parse_stdout) so the resource returns the decoded object, not the raw shell output.
1082
-
1083
- Pick \`input\` and \`output\` according to the resource shape:
1084
- - output-only (e.g. stats): omit \`input\`, leave \`output\` at its default. Resource classifies as fn=3 (output); frontend renders only the typed output.
1085
- - input+output (e.g. echo/transform): pass an \`input\` schema, leave \`output\` at its default. Resource classifies as fn=4 (input_output); frontend renders typed input fields AND the output.
1086
- - input-only (e.g. notify, log): pass an \`input\` schema and set \`output: false\`. Resource classifies as fn=2 (input); frontend renders only the input form and the call replies empty.
1087
- - run (e.g. ping, restart): omit \`input\` and set \`output: false\`. Resource classifies as fn=1 (run); just a button that fires the script.
1088
-
1089
- \`input\` schema shape: a plain object mapping field names to default values (e.g. { "threshold": 80, "label": "warm" }). Declaring it registers a template_payload on the request side so the frontend renders typed input fields with those defaults and callers can override each field when invoking the resource. Field names must match /^[A-Za-z_][A-Za-z0-9_]*$/; values may be string, number or boolean. Omit \`input\` for a free-form payload where any object passed by the caller flows straight to stdin without a declared schema.
1090
-
1091
- The file extension (.sh, .py, .js, …) is preserved in the storage for editor highlighting, but the API resource name is the stem without extension. So scripts/backup.sh is callable as the resource "backup".`,
1092
- inputSchema: {
1093
- type: 'object',
1094
- properties: {
1095
- product: { type: 'string', description: 'Product ID.' },
1096
- name: {
1097
- type: 'string',
1098
- description:
1099
- 'Script filename (e.g. "backup.sh"). The stem ("backup") becomes the API resource name.',
1100
- },
1101
- content: {
1102
- type: 'string',
1103
- description:
1104
- 'Full script source code. Include a shebang to use a non-shell interpreter.',
1105
- },
1106
- input: {
1107
- type: 'object',
1108
- description:
1109
- 'Optional input schema: plain object mapping field names to default values. Registers a typed template so the frontend renders input fields. Field names must match /^[A-Za-z_][A-Za-z0-9_]*$/. Values may be string, number or boolean. Omit for a free-form payload.',
1110
- additionalProperties: true,
1111
- },
1112
- output: {
1113
- type: 'boolean',
1114
- description:
1115
- 'Whether the resource returns data. Default true: stdout is parsed as JSON and returned (and exposed as the resource output in the frontend). Set to false for fire-and-forget scripts — the resource replies empty and, combined with no input, classifies as "run".',
1116
- },
1117
- user: { type: 'string', description: 'API user' },
1118
- },
1119
- required: ['product', 'name', 'content'],
1120
- },
1121
- handler: toolProductScriptWrite,
1122
- },
1123
- {
1124
- name: 'thinr_product_write',
1125
- description: `Upload a file to every active device of a product in parallel. Content is passed inline as a UTF-8 string — the same contract as thinr_write, just fanned out.
1126
-
1127
- Typical uses: rolling out a shared configuration file, pushing a small static asset, or seeding a script that devices load at boot. Offline devices are skipped by default; set \`all: true\` to attempt them too (they will typically fail fast with a connection error). \`concurrency\` bounds parallel uploads (default 10) and \`fail_fast\` stops dequeueing new devices after the first failure.`,
1128
- inputSchema: {
1129
- type: 'object',
1130
- properties: {
1131
- product: {
1132
- type: 'string',
1133
- description: 'Product ID (discover with thinr_products).',
1134
- },
1135
- path: {
1136
- type: 'string',
1137
- description: 'Absolute path on each device where the file will be written.',
1138
- },
1139
- content: {
1140
- type: 'string',
1141
- description: 'UTF-8 file content to write on every device.',
1142
- },
1143
- group: {
1144
- type: 'string',
1145
- description: 'Optional asset group filter.',
1146
- },
1147
- concurrency: {
1148
- type: 'number',
1149
- description: 'Max parallel uploads (default: 10).',
1150
- },
1151
- fail_fast: {
1152
- type: 'boolean',
1153
- description:
1154
- 'Stop dequeueing new devices as soon as one fails (default: false).',
1155
- },
1156
- all: {
1157
- type: 'boolean',
1158
- description:
1159
- 'Include offline devices (default: false — only active are targeted).',
1160
- },
1161
- user: { type: 'string', description: 'API user (admin impersonation).' },
1162
- },
1163
- required: ['product', 'path', 'content'],
1164
- },
1165
- handler: toolProductWrite,
1166
- },
1167
- {
1168
- name: 'thinr_product_exec',
1169
- 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.
1170
-
1171
- 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.`,
1172
- inputSchema: {
1173
- type: 'object',
1174
- properties: {
1175
- product: {
1176
- type: 'string',
1177
- description: 'Product ID (discover with thinr_products).',
1178
- },
1179
- command: {
1180
- type: 'string',
1181
- description: 'Shell command to run on each device.',
1182
- },
1183
- group: {
1184
- type: 'string',
1185
- description:
1186
- 'Optional asset group filter — restricts execution to devices in this group.',
1187
- },
1188
- concurrency: {
1189
- type: 'number',
1190
- description: 'Max parallel executions (default: 10).',
1191
- },
1192
- timeout: {
1193
- type: 'number',
1194
- description: 'Per-device timeout in seconds (default: 30).',
1195
- },
1196
- fail_fast: {
1197
- type: 'boolean',
1198
- description:
1199
- 'Stop dequeueing new devices as soon as one fails (default: false).',
1200
- },
1201
- all: {
1202
- type: 'boolean',
1203
- description:
1204
- 'Include offline devices (default: false — only active devices are targeted).',
1205
- },
1206
- user: { type: 'string', description: 'API user (admin impersonation).' },
1207
- },
1208
- required: ['product', 'command'],
1209
- },
1210
- handler: toolProductExec,
1211
- },
1212
- {
1213
- name: 'thinr_product_script_delete',
1214
- description: `Remove a product script: deletes the file under scripts/ in the product storage and the associated API resource.`,
1215
- inputSchema: {
1216
- type: 'object',
1217
- properties: {
1218
- product: { type: 'string', description: 'Product ID.' },
1219
- name: {
1220
- type: 'string',
1221
- description:
1222
- 'Script filename as stored under scripts/ (with extension if any).',
1223
- },
1224
- user: { type: 'string', description: 'API user' },
1225
- },
1226
- required: ['product', 'name'],
1227
- },
1228
- handler: toolProductScriptDelete,
1229
- },
1230
- {
1231
- name: 'thinr_product_property_get',
1232
- description: `Read a product-level property (not a device property). Product properties are structured JSON values attached to the product itself — typical uses include dashboard configuration, shared feature flags, or the list of dashboard metrics managed by thinr_product_metric_*. Call without \`property\` to list the names of every property configured on the product.`,
1233
- inputSchema: {
1234
- type: 'object',
1235
- properties: {
1236
- product: { type: 'string', description: 'Product ID.' },
1237
- property: {
1238
- type: 'string',
1239
- description:
1240
- 'Property name. Omit to list the names of all properties on the product.',
1241
- },
1242
- user: { type: 'string', description: 'API user' },
1243
- },
1244
- required: ['product'],
1245
- },
1246
- handler: toolProductPropertyGet,
1247
- },
1248
- {
1249
- name: 'thinr_product_property_set',
1250
- description: `Upsert a product-level property: creates the property if missing, overwrites it wholesale if it already exists. The value can be any JSON-serialisable shape (string, number, object, array). Prefer the higher-level thinr_product_metric_* tools when managing dashboard metrics — they encapsulate the property shape so you don't have to reconstruct it by hand.
1251
-
1252
- Naming convention: this tool follows the upsert pattern shared by every per-product leaf (\`*_set\`, \`*_write\`). Container-level operations (the product itself) split create and update into separate tools so a fresh registration cannot accidentally overwrite an existing product's metadata.`,
1253
- inputSchema: {
1254
- type: 'object',
1255
- properties: {
1256
- product: { type: 'string', description: 'Product ID.' },
1257
- property: { type: 'string', description: 'Property name to create or overwrite.' },
1258
- value: {
1259
- description:
1260
- 'Any JSON-serialisable value (string, number, boolean, object, array).',
1261
- },
1262
- user: { type: 'string', description: 'API user' },
1263
- },
1264
- required: ['product', 'property', 'value'],
1265
- },
1266
- handler: toolProductPropertySet,
1267
- },
1268
- {
1269
- name: 'thinr_product_property_delete',
1270
- description: `Delete a product-level property. Idempotent — returns success even if the property was not set.`,
1271
- inputSchema: {
1272
- type: 'object',
1273
- properties: {
1274
- product: { type: 'string', description: 'Product ID.' },
1275
- property: { type: 'string', description: 'Property name to delete.' },
1276
- user: { type: 'string', description: 'API user' },
1277
- },
1278
- required: ['product', 'property'],
1279
- },
1280
- handler: toolProductPropertyDelete,
1281
- },
1282
- {
1283
- name: 'thinr_product_metric_list',
1284
- description: `List the dashboard metrics configured on a product. Each metric describes a value the dashboard should stream (resource to poll, aggregation, visualization, refresh interval). Metrics are stored under the \`${DASHBOARD_METRICS_PROPERTY}\` product property.`,
1285
- inputSchema: {
1286
- type: 'object',
1287
- properties: {
1288
- product: { type: 'string', description: 'Product ID.' },
1289
- user: { type: 'string', description: 'API user' },
1290
- },
1291
- required: ['product'],
1292
- },
1293
- handler: toolProductMetricList,
1294
- },
1295
- {
1296
- name: 'thinr_product_metric_set',
1297
- description: `Upsert a dashboard metric on a product: creates a new entry if the \`name\` key is unused, overwrites the existing entry in place otherwise. Under the hood this edits the \`${DASHBOARD_METRICS_PROPERTY}\` product property — prefer this tool over thinr_product_property_set for dashboard metrics so the shape stays consistent.
1298
-
1299
- Typical flow: create the product first (thinr_product_create), write a script that returns the raw data (thinr_product_script_write), then register the metric so the dashboard knows how to surface it.
1300
-
1301
- Naming convention: \`*_set\` denotes upsert, shared with every per-product leaf. The product container itself uses split thinr_product_create / thinr_product_update tools to make accidental overwrites of product metadata impossible.`,
1302
- inputSchema: {
1303
- type: 'object',
1304
- properties: {
1305
- product: { type: 'string', description: 'Product ID.' },
1306
- name: {
1307
- type: 'string',
1308
- description:
1309
- 'Stable identifier for the metric (letters, digits, underscore, dash). Reusing a name updates the existing entry.',
1310
- },
1311
- label: {
1312
- type: 'string',
1313
- description: 'Human-readable label shown in the dashboard panel.',
1314
- },
1315
- resource: {
1316
- type: 'string',
1317
- description:
1318
- 'Product API resource the dashboard invokes per device to get the value (e.g. the stem of a product script).',
1319
- },
1320
- field: {
1321
- type: 'string',
1322
- description:
1323
- 'Dot-path into the resource response pointing at the numeric value to aggregate (e.g. "connections.devices"). Omit for scalar responses.',
1324
- },
1325
- aggregation: {
1326
- type: 'string',
1327
- description:
1328
- 'How to combine the per-device values across the fleet. Numeric reducers: "sum", "avg", "max", "min". "count" returns the number of devices reporting any non-null value, irrespective of value. "top" lists the values without counting repetitions. "none" returns the raw per-device array. "distribution" returns a flat object mapping each distinct reported value (coerced to string) to the number of devices reporting it — use it for categorical data such as agent version, release/branch, kernel, hostname pattern; it is the right choice when the question is "how is the fleet split across these values?". The output of "distribution" is an object value→count, not a number; the dashboard renders it as a sorted list with per-value percentages and a total row.',
1329
- enum: ['sum', 'avg', 'max', 'min', 'count', 'top', 'none', 'distribution'],
1330
- },
1331
- visualization: {
1332
- type: 'string',
1333
- description:
1334
- 'Dashboard rendering hint. "kpi" (default) shows the aggregated value plus its age. "sparkline" renders the same value alongside a ▁▂▃▅▇ bar of the last ~32 samples, useful for metrics where the trend matters (throughput, connected devices). "list" pairs naturally with aggregation "distribution" — it lays out each value as its own row with count and percentage, plus a total. "bar" is reserved for future use and currently falls back to kpi.',
1335
- enum: ['kpi', 'bar', 'sparkline', 'list'],
1336
- },
1337
- interval: {
1338
- type: 'number',
1339
- description:
1340
- 'Refresh interval in seconds the dashboard should subscribe with. Minimum: 1. Omit (or set to 0) to subscribe without a server-side poll — the stream then only relays values the device pushes on its own.',
1341
- },
1342
- unit: {
1343
- type: 'string',
1344
- description: 'Optional unit suffix for display (e.g. "%", "devices").',
1345
- },
1346
- user: { type: 'string', description: 'API user' },
1347
- },
1348
- required: ['product', 'name', 'resource'],
1349
- },
1350
- handler: toolProductMetricSet,
1351
- },
1352
- {
1353
- name: 'thinr_product_metric_delete',
1354
- description: `Remove a dashboard metric from a product by its \`name\`. If it was the last metric configured, the underlying \`${DASHBOARD_METRICS_PROPERTY}\` product property is removed entirely.`,
1355
- inputSchema: {
1356
- type: 'object',
1357
- properties: {
1358
- product: { type: 'string', description: 'Product ID.' },
1359
- name: { type: 'string', description: 'Metric name previously registered.' },
1360
- user: { type: 'string', description: 'API user' },
1361
- },
1362
- required: ['product', 'name'],
1363
- },
1364
- handler: toolProductMetricDelete,
1365
- },
1366
- {
1367
- name: 'thinr_product_playbook_list',
1368
- description: `List the playbooks registered on a product, with their description and storage path. Use this to discover available playbooks before reading or running them; pair with thinr_product_playbook_read when you need the full YAML of a specific entry. Returns an empty listing if the product has no \`playbooks\` index property yet.`,
1369
- inputSchema: {
1370
- type: 'object',
1371
- properties: {
1372
- product: { type: 'string', description: 'Product ID.' },
1373
- user: { type: 'string', description: 'API user' },
1374
- },
1375
- required: ['product'],
1376
- },
1377
- handler: toolProductPlaybookList,
1378
- },
1379
- {
1380
- name: 'thinr_product_playbook_read',
1381
- description: `Return the raw YAML source of a single playbook stored on a product. Use this when you need the full document (to review it, edit and re-upload, or hand the content to thinr_playbook_run). For just the catalogue of available entries use thinr_product_playbook_list — it is cheaper and does not download each file.`,
1382
- inputSchema: {
1383
- type: 'object',
1384
- properties: {
1385
- product: { type: 'string', description: 'Product ID.' },
1386
- name: {
1387
- type: 'string',
1388
- description:
1389
- 'Playbook name as registered in the product index (letters, digits, underscore, dash — no extension).',
1390
- },
1391
- user: { type: 'string', description: 'API user' },
1392
- },
1393
- required: ['product', 'name'],
1394
- },
1395
- handler: toolProductPlaybookRead,
1396
- },
1397
- {
1398
- name: 'thinr_product_playbook_write',
1399
- description: `Upsert a playbook on a product from inline YAML: creates a new playbook if the name is unused, replaces the existing one in place otherwise. This is the upload step of the natural-language authoring flow: after the user describes what they want ("update the nginx package and restart the service on product X"), draft the YAML, iterate against thinr_playbook_validate until it is clean, then call this tool with the validated content.
1400
-
1401
- The YAML is re-validated here against the playbook schema; an invalid document fails fast with a descriptive error and nothing is written — never pass \`skip_validation: true\` to work around validation errors, fix the YAML instead. Uploads are two-phase and self-healing: the file is written under \`playbooks/<name>.yaml\` in the product storage first, then the entry is registered in the \`playbooks\` product property; if the index update fails on a brand-new playbook, the file is rolled back so the index never points at a missing file.
1402
-
1403
- Name rules: letters, digits, underscore, dash — no extension, no slashes. \`description\` is optional; when omitted, the playbook's own top-level \`description\` (if any) is recorded in the index. Author from scratch by calling thinr_playbook_schema first so the generated YAML matches the schema on the first draft.
1404
-
1405
- Naming convention: \`*_write\` denotes upsert, shared with every per-product leaf. The product container itself uses split thinr_product_create / thinr_product_update tools to make accidental overwrites of product metadata impossible.`,
1406
- inputSchema: {
1407
- type: 'object',
1408
- properties: {
1409
- product: { type: 'string', description: 'Product ID.' },
1410
- name: {
1411
- type: 'string',
1412
- description:
1413
- 'Playbook name (letters, digits, underscore, dash). Used as the key in the product index and as the filename under `playbooks/`.',
1414
- },
1415
- content: {
1416
- type: 'string',
1417
- description:
1418
- 'Full YAML source of the playbook. Validated before upload unless skip_validation is true.',
1419
- },
1420
- description: {
1421
- type: 'string',
1422
- description:
1423
- "Optional description stored alongside the index entry. When omitted, falls back to the playbook's own `description` field.",
1424
- },
1425
- skip_validation: {
1426
- type: 'boolean',
1427
- description:
1428
- 'Escape hatch for rare cases where the schema cannot yet express a field the runtime already accepts (default: false). Do NOT set this to work around a validation error reported by thinr_playbook_validate — fix the YAML instead. An invalid document will still fail at run time.',
1429
- },
1430
- user: { type: 'string', description: 'API user' },
1431
- },
1432
- required: ['product', 'name', 'content'],
1433
- },
1434
- handler: toolProductPlaybookWrite,
1435
- },
1436
- {
1437
- name: 'thinr_product_playbook_run',
1438
- description: `Run a playbook stored on a product against a single device — the safe pre-flight before rolling out across a fleet. Downloads the playbook by name, seeds variables from the playbook's declared defaults, applies any overrides supplied in \`vars\`, validates them (unknown / non-overridable / wrong-type / missing-required all surface as errors), and executes every step against \`device\`.
1439
-
1440
- Pass \`dry_run: true\` to get the resolved plan without contacting the device. Pass \`check: true\` to contact the device read-only and report what each step would change. Overrides never mutate the stored playbook — they are per-invocation only. Use this tool before \`thinr_playbook_run\` (fleet mode) to validate behaviour on a single device first.`,
1441
- inputSchema: {
1442
- type: 'object',
1443
- properties: {
1444
- product: { type: 'string', description: 'Product ID.' },
1445
- name: {
1446
- type: 'string',
1447
- description: 'Playbook name as registered in the product index.',
1448
- },
1449
- device_id: {
1450
- type: 'string',
1451
- description:
1452
- 'Target device ID (single-device only). Pick any device of the product.',
1453
- },
1454
- vars: {
1455
- type: 'object',
1456
- description:
1457
- 'Per-invocation variable overrides keyed by variable name. Values must match the declared type; unknown / non-overridable names are rejected.',
1458
- additionalProperties: true,
1459
- },
1460
- dry_run: {
1461
- type: 'boolean',
1462
- description:
1463
- 'Return the resolved plan (variables + step summaries) without contacting the device (default: false).',
1464
- },
1465
- check: {
1466
- type: 'boolean',
1467
- description:
1468
- 'Contact the device read-only and report what each step would change, without writing (default: false).',
1469
- },
1470
- user: { type: 'string', description: 'API user (admin impersonation).' },
1471
- },
1472
- required: ['product', 'name', 'device_id'],
1473
- },
1474
- handler: toolProductPlaybookRun,
1475
- },
1476
- {
1477
- name: 'thinr_product_playbook_rollout',
1478
- description: `Roll a playbook stored on a product across every matching device, progressively and with safety rails. Devices are attempted in discrete batches of \`batch_size\`; a batch completes fully before the next starts. After each batch the cumulative failure rate is compared against \`failure_threshold\` — reaching it aborts the rollout without touching the remaining devices.
1479
-
1480
- Destructive: prefer running \`thinr_product_playbook_run\` on a single device first to validate behaviour. Variables resolve the same way (declared defaults + \`vars\` overrides, validated against declared types; unknown / non-overridable / missing-required all rejected). The rollout writes a persistent report to \`playbooks/runs/<timestamp>-<name>-<user>.json\` in the product file storage, capturing the resolved variables, per-device outcome, and stop reason if aborted — best-effort, a failed upload is reported but does not fail the rollout.
1481
-
1482
- Filtering: \`group\` restricts to an asset group; \`filter\` passes extra server-side key=value filters (repeatable keys only — letters/digits/underscore). By default only active devices are targeted; pass \`include_offline: true\` to attempt offline ones too.
1483
-
1484
- Use \`dry_run: true\` to get the resolved plan and the list of devices that would be targeted without contacting any of them. \`check: true\` contacts devices read-only and reports what each step would change, without writing.`,
1485
- inputSchema: {
1486
- type: 'object',
1487
- properties: {
1488
- product: { type: 'string', description: 'Product ID.' },
1489
- name: {
1490
- type: 'string',
1491
- description: 'Playbook name as registered in the product index.',
1492
- },
1493
- batch_size: {
1494
- type: 'number',
1495
- description:
1496
- 'Devices attempted in parallel per batch. Next batch starts only after the current one finishes. Default: 5.',
1497
- },
1498
- failure_threshold: {
1499
- type: 'number',
1500
- description: `Abort rollout when cumulative failure rate (percentage of attempted devices that failed) reaches this value. Must be between 0 and 100. Default: ${DEFAULT_FAILURE_THRESHOLD}.`,
1501
- },
1502
- group: {
1503
- type: 'string',
1504
- description:
1505
- 'Restrict to devices in this asset group. Combined with `filter` if both are set.',
1506
- },
1507
- filter: {
1508
- type: 'object',
1509
- description:
1510
- 'Extra server-side filters passed as query params when listing devices. Keys must match [A-Za-z0-9_]+. Typical uses: narrowing by device field.',
1511
- additionalProperties: { type: 'string' },
1512
- },
1513
- include_offline: {
1514
- type: 'boolean',
1515
- description:
1516
- 'Include offline devices in the rollout (default: false — only active devices are attempted).',
1517
- },
1518
- vars: {
1519
- type: 'object',
1520
- description:
1521
- 'Per-invocation variable overrides applied to every device. Values must match the declared type; unknown / non-overridable names are rejected.',
1522
- additionalProperties: true,
1523
- },
1524
- dry_run: {
1525
- type: 'boolean',
1526
- description:
1527
- 'Return the resolved plan and target devices without contacting any of them (default: false).',
1528
- },
1529
- check: {
1530
- type: 'boolean',
1531
- description:
1532
- 'Contact devices read-only and report what each step would change, without writing (default: false).',
1533
- },
1534
- user: { type: 'string', description: 'API user (admin impersonation).' },
1535
- },
1536
- required: ['product', 'name'],
1537
- },
1538
- handler: toolProductPlaybookRollout,
1539
- },
1540
- {
1541
- name: 'thinr_product_playbook_delete',
1542
- description: `Remove a playbook from a product: first unregisters it from the \`playbooks\` index property, then deletes the underlying \`playbooks/<name>.yaml\` file from the product storage. Idempotent — calling it on a missing playbook reports success without erroring.`,
1543
- inputSchema: {
1544
- type: 'object',
1545
- properties: {
1546
- product: { type: 'string', description: 'Product ID.' },
1547
- name: {
1548
- type: 'string',
1549
- description: 'Playbook name previously registered on the product.',
1550
- },
1551
- user: { type: 'string', description: 'API user' },
1552
- },
1553
- required: ['product', 'name'],
1554
- },
1555
- handler: toolProductPlaybookDelete,
1556
- },
1557
- ];