@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,79 +0,0 @@
1
- // @ts-check
2
- import { getDeviceProperties, getDeviceProperty, setDeviceProperty } from '../property.js';
3
- import { inputError } from '../errors.js';
4
-
5
- async function toolPropertyGet(args) {
6
- const device = args.device_id;
7
- if (!device)
8
- throw inputError('No device specified. Use thinr_devices to list available devices.');
9
- if (args.property) {
10
- const value = await getDeviceProperty(device, args.property, args.user);
11
- return {
12
- content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
13
- isError: false,
14
- };
15
- }
16
- const list = await getDeviceProperties(device, args.user);
17
- const names = Array.isArray(list) ? list.map((p) => p.property).filter(Boolean) : [];
18
- return {
19
- content: [{ type: 'text', text: names.length ? names.join('\n') : '(no properties)' }],
20
- isError: false,
21
- };
22
- }
23
-
24
- async function toolPropertySet(args) {
25
- const device = args.device_id;
26
- if (!device)
27
- throw inputError('No device specified. Use thinr_devices to list available devices.');
28
- if (!args.property) throw inputError('property is required');
29
- if (args.value === undefined) throw inputError('value is required');
30
- const saved = await setDeviceProperty(device, args.property, args.value, args.user);
31
- return {
32
- content: [
33
- {
34
- type: 'text',
35
- text: `Saved property "${saved.property}" on ${saved.device} (value: ${JSON.stringify(saved.value)})`,
36
- },
37
- ],
38
- isError: false,
39
- };
40
- }
41
-
42
- export const tools = [
43
- {
44
- name: 'thinr_property_get',
45
- description: `Read a device property. Properties are the device's structured state (e.g. configuration, last values, calibration). Call without \`property\` to list the names of every property the device has. Prefer this over thinr_exec when you need config data — it returns the raw typed value without shell parsing.`,
46
- inputSchema: {
47
- type: 'object',
48
- properties: {
49
- property: {
50
- type: 'string',
51
- description:
52
- 'Property name. Omit to list the names of all properties on the device.',
53
- },
54
- device_id: { type: 'string', description: 'Device ID' },
55
- user: { type: 'string', description: 'API user' },
56
- },
57
- required: ['device_id'],
58
- },
59
- handler: toolPropertyGet,
60
- },
61
- {
62
- name: 'thinr_property_set',
63
- description: `Create or overwrite a device property. The value can be any JSON-serialisable shape (string, number, object, array). Existing properties are replaced wholesale; missing ones are created. Use this instead of thinr_exec when you want to persist structured state on the device record, since properties survive device restarts and are visible from the dashboard.`,
64
- inputSchema: {
65
- type: 'object',
66
- properties: {
67
- property: { type: 'string', description: 'Property name to create or overwrite.' },
68
- value: {
69
- description:
70
- 'Any JSON-serialisable value (string, number, boolean, object, array).',
71
- },
72
- device_id: { type: 'string', description: 'Device ID' },
73
- user: { type: 'string', description: 'API user' },
74
- },
75
- required: ['property', 'value', 'device_id'],
76
- },
77
- handler: toolPropertySet,
78
- },
79
- ];
@@ -1,78 +0,0 @@
1
- // @ts-check
2
- import { callDeviceResource, listDeviceResourcesWithSchemas } from '../resource.js';
3
- import { inputError } from '../errors.js';
4
-
5
- async function toolResourceList(args) {
6
- const device = args.device_id;
7
- if (!device)
8
- throw inputError('No device specified. Use thinr_devices to list available devices.');
9
- const entries = await listDeviceResourcesWithSchemas(device, { user: args.user });
10
- const fnLabel = (f) =>
11
- ({ 1: 'no params', 2: 'input', 3: 'output', 4: 'input/output' })[f] || 'unknown';
12
- const lines = entries.map((e) => {
13
- const parts = [`${e.name} [${fnLabel(e.fn)}]`];
14
- if (e.in !== undefined) parts.push(` in: ${JSON.stringify(e.in)}`);
15
- if (e.out !== undefined) parts.push(` out: ${JSON.stringify(e.out)}`);
16
- return parts.join('\n');
17
- });
18
- return {
19
- content: [{ type: 'text', text: lines.length ? lines.join('\n\n') : '(no resources)' }],
20
- isError: false,
21
- };
22
- }
23
-
24
- async function toolResourceCall(args) {
25
- const device = args.device_id;
26
- if (!device)
27
- throw inputError('No device specified. Use thinr_devices to list available devices.');
28
- if (!args.resource) throw inputError('resource is required');
29
- const result = await callDeviceResource(device, args.resource, args.inputs, {
30
- user: args.user,
31
- });
32
- return {
33
- content: [
34
- {
35
- type: 'text',
36
- text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
37
- },
38
- ],
39
- isError: false,
40
- };
41
- }
42
-
43
- export const tools = [
44
- {
45
- name: 'thinr_resource_list',
46
- description: `List the callable resources exposed by a device, with their input/output schemas when available. Resources are the device's public API — typed endpoints like "cmd" (run a shell command), "monitoring" (live metrics), "update" (upgrade the agent), "system" (OS info), etc. Prefer this before calling thinr_resource_call to discover what a device offers and how to call each resource.`,
47
- inputSchema: {
48
- type: 'object',
49
- properties: {
50
- device_id: { type: 'string', description: 'Device ID' },
51
- user: { type: 'string', description: 'API user' },
52
- },
53
- required: ['device_id'],
54
- },
55
- handler: toolResourceList,
56
- },
57
- {
58
- name: 'thinr_resource_call',
59
- description: `Invoke a resource on a device. Pass \`inputs\` when the resource declares an input schema (e.g. cmd, update); omit for read-only resources like monitoring/system/agent. Returns the resource's output. Use thinr_resource_list first to discover available resources and their expected inputs.`,
60
- inputSchema: {
61
- type: 'object',
62
- properties: {
63
- resource: {
64
- type: 'string',
65
- description: 'Resource name as returned by thinr_resource_list.',
66
- },
67
- inputs: {
68
- description:
69
- 'JSON object with the input parameters declared by the resource. Omit or pass {} for input-less resources.',
70
- },
71
- device_id: { type: 'string', description: 'Device ID' },
72
- user: { type: 'string', description: 'API user' },
73
- },
74
- required: ['resource', 'device_id'],
75
- },
76
- handler: toolResourceCall,
77
- },
78
- ];
@@ -1,212 +0,0 @@
1
- // @ts-check
2
- import { callDeviceResource, getDeviceResources, readDeviceResource } from '../resource.js';
3
- import { inputError } from '../errors.js';
4
- import { getAPI } from './helpers.js';
5
-
6
- async function toolScriptList(args) {
7
- const device = args.device_id;
8
- if (!device)
9
- throw inputError('No device specified. Use thinr_devices to list available devices.');
10
- const info = await readDeviceResource(device, '$scripts/info', { user: args.user });
11
- const scripts = Array.isArray(info?.scripts) ? info.scripts : [];
12
- const header = `Scripts directory: ${info?.path || '(unknown)'}\n${scripts.length} script(s) registered:`;
13
- const entries = scripts.map((s) => {
14
- const parts = [`\n- ${s.name} (${s.path})`];
15
- if (s.describe?.input !== undefined)
16
- parts.push(` in: ${JSON.stringify(s.describe.input)}`);
17
- if (s.describe?.output !== undefined)
18
- parts.push(` out: ${JSON.stringify(s.describe.output)}`);
19
- return parts.join('\n');
20
- });
21
- return {
22
- content: [{ type: 'text', text: [header, ...entries].join('\n') }],
23
- isError: false,
24
- };
25
- }
26
-
27
- async function toolScriptWrite(args) {
28
- const device = args.device_id;
29
- if (!device)
30
- throw inputError('No device specified. Use thinr_devices to list available devices.');
31
- if (!args.name) throw inputError('name is required');
32
- if (typeof args.content !== 'string') throw inputError('content is required (string)');
33
-
34
- const { api: deviceApi } = getAPI(device, args.user);
35
-
36
- const info = await readDeviceResource(device, '$scripts/info', { user: args.user });
37
- const baseDir = info?.path;
38
- if (!baseDir)
39
- throw new Error('Agent did not return a scripts directory (is $scripts/info supported?)');
40
-
41
- if (!/^[A-Za-z0-9._-]+$/.test(args.name)) {
42
- throw inputError(
43
- `Invalid script name "${args.name}" (allowed: letters, digits, dot, dash, underscore)`,
44
- );
45
- }
46
- const scriptPath = `${baseDir}/${args.name}`;
47
-
48
- // The agent registers scripts at the root of the device's resource
49
- // namespace, so a script named after a built-in resource replaces it and
50
- // the device silently stops answering the real one. Refuse unless the
51
- // caller says that is the intent. A name that already belongs to one of
52
- // this device's own scripts is an update, not a collision.
53
- const registeredName = args.name.replace(/\.[^./]+$/, '');
54
- if (!args.force) {
55
- const [native, current] = await Promise.all([
56
- getDeviceResources(device, { user: args.user }).catch(() => ({})),
57
- Promise.resolve(Array.isArray(info?.scripts) ? info.scripts : []),
58
- ]);
59
- const isOwnScript = current.some((s) => s.name === registeredName);
60
- if (!isOwnScript && Object.prototype.hasOwnProperty.call(native, registeredName)) {
61
- throw inputError(
62
- `"${registeredName}" is already a resource on this device, and installing this ` +
63
- `script would replace it. Rename the script, or pass force: true if replacing ` +
64
- `it is what you want.`,
65
- );
66
- }
67
- }
68
-
69
- await deviceApi.writeFile(scriptPath, args.content, true);
70
-
71
- const chmod = await deviceApi.exec(`chmod +x ${JSON.stringify(scriptPath)}`, 10);
72
- if (chmod.retcode !== 0) {
73
- throw new Error(`chmod +x failed: ${chmod.stderr || `exit ${chmod.retcode}`}`);
74
- }
75
-
76
- const reloaded = await callDeviceResource(device, '$scripts/reload', {}, { user: args.user });
77
- const found = Array.isArray(reloaded?.scripts)
78
- ? reloaded.scripts.find((s) => s.name === registeredName)
79
- : null;
80
-
81
- const lines = [`Installed script "${registeredName}" at ${scriptPath}`];
82
- if (found?.describe) {
83
- lines.push(`Schema: ${JSON.stringify(found.describe)}`);
84
- } else {
85
- lines.push(
86
- 'Schema: (no --describe provided; script will accept any input and return raw output)',
87
- );
88
- }
89
- lines.push(`Resource: ${registeredName} — call it with thinr_resource_call.`);
90
- return {
91
- content: [{ type: 'text', text: lines.join('\n') }],
92
- isError: false,
93
- };
94
- }
95
-
96
- async function toolScriptDelete(args) {
97
- const device = args.device_id;
98
- if (!device)
99
- throw inputError('No device specified. Use thinr_devices to list available devices.');
100
- if (!args.name) throw inputError('name is required');
101
-
102
- const { api: deviceApi } = getAPI(device, args.user);
103
- const info = await readDeviceResource(device, '$scripts/info', { user: args.user });
104
- const baseDir = info?.path;
105
- if (!baseDir)
106
- throw new Error('Agent did not return a scripts directory (is $scripts/info supported?)');
107
-
108
- if (!/^[A-Za-z0-9._-]+$/.test(args.name)) {
109
- throw inputError(`Invalid script name "${args.name}"`);
110
- }
111
-
112
- // Callers name a script either way round: `battery` as it appears in the
113
- // resource list, or `battery.sh` as it sits on disk. Resolve against what
114
- // the agent reports so both land on the same file, and say what is there
115
- // when neither matches instead of letting a 404 through.
116
- const scripts = Array.isArray(info?.scripts) ? info.scripts : [];
117
- const stem = args.name.replace(/\.[^./]+$/, '');
118
- const match = scripts.find((s) => s.name === stem || s.path?.endsWith(`/${args.name}`));
119
- if (!match) {
120
- const registered = scripts.map((s) => s.name).join(', ') || 'none';
121
- throw inputError(
122
- `No script named "${args.name}" on this device. Registered: ${registered}.`,
123
- );
124
- }
125
- const scriptPath = match.path || `${baseDir}/${args.name}`;
126
-
127
- await deviceApi.delete(scriptPath, false);
128
- const reloaded = await callDeviceResource(device, '$scripts/reload', {}, { user: args.user });
129
- const remaining = Array.isArray(reloaded?.scripts) ? reloaded.scripts.length : 0;
130
- return {
131
- content: [
132
- {
133
- type: 'text',
134
- text: `Removed script "${match.name}" (${scriptPath}). ${remaining} script(s) remain registered.`,
135
- },
136
- ],
137
- isError: false,
138
- };
139
- }
140
-
141
- export const tools = [
142
- {
143
- name: 'thinr_script_list',
144
- description: `List the custom scripts registered by the agent's scripts extension, including the on-device scripts directory and each script's --describe schema. Scripts are user-provided executables in <agent_base>/scripts/ that the agent registers as first-class resources under their own names (e.g. a file named \`battery.sh\` becomes the resource \`battery\`). Use this to discover which scripted capabilities the device exposes.`,
145
- inputSchema: {
146
- type: 'object',
147
- properties: {
148
- device_id: { type: 'string', description: 'Device ID' },
149
- user: { type: 'string', description: 'API user' },
150
- },
151
- required: ['device_id'],
152
- },
153
- handler: toolScriptList,
154
- },
155
- {
156
- name: 'thinr_script_write',
157
- description: `Install or replace a custom script on the device and reload the scripts extension so the new resource is immediately callable.
158
-
159
- Scripts turn an ad-hoc operation into a reusable, typed, discoverable resource on the device. Prefer this over thinr_exec when you want to formalise a recurring task (read sensors, rotate a log, run a backup, collect a health report) so it becomes a persistent resource with an input/output schema that future tool calls can introspect and invoke via thinr_resource_call. A script named \`battery.sh\` is registered as the resource \`battery\`. A name that already belongs to a built-in resource (\`monitoring\`, \`system\`, \`cmd\`, \`update\`, \`agent\`) would replace it and leave the device no longer answering the real one, so this tool refuses that unless you pass \`force: true\`.
160
-
161
- The script runs with the agent's privileges, which for a system install means root. Installing one grants permanent remote execution of that code on the device; treat it like adding a service, not like running a command.
162
-
163
- Script contract:
164
- - Must be executable code (any interpreter works — add a shebang like \`#!/bin/bash\` or \`#!/usr/bin/env python3\`).
165
- - When invoked with \`--describe\`, it should print a JSON object with \`input\` and/or \`output\` sample objects on stdout (e.g. \`{"input": {"path": ""}, "output": {"size": 0, "mtime": 0}}\`). These populate the resource's schema.
166
- - When invoked normally, the full input JSON object is passed on stdin; stdout is parsed as JSON. If stdout is a JSON object, its keys become the output fields; otherwise the value is exposed under \`output\`. Non-zero exit codes surface as errors.
167
- - Emit JSON that matches the \`--describe\` schema. Interpolating values you do not control (hostnames, command output, file contents) into a hand-built string breaks on the first quote or backslash; use \`jq\`, or \`python3 -c\` with \`json.dumps\` and the value passed through the environment. Output landing under \`output\` means the JSON was invalid, not that the raw form was accepted.
168
-
169
- This tool writes the script, makes it executable, and calls the agent's \`$scripts/reload\` so the change takes effect without restarting the agent.`,
170
- inputSchema: {
171
- type: 'object',
172
- properties: {
173
- name: {
174
- type: 'string',
175
- description:
176
- 'Script filename. The file extension (e.g. .sh, .py) is stripped to form the resource name, so battery.sh registers as the resource "battery".',
177
- },
178
- force: {
179
- type: 'boolean',
180
- description:
181
- "Install even when the resulting resource name replaces one of the device's built-in resources.",
182
- },
183
- content: {
184
- type: 'string',
185
- description: 'Full script source code, including shebang.',
186
- },
187
- device_id: { type: 'string', description: 'Device ID' },
188
- user: { type: 'string', description: 'API user' },
189
- },
190
- required: ['name', 'content', 'device_id'],
191
- },
192
- handler: toolScriptWrite,
193
- },
194
- {
195
- name: 'thinr_script_delete',
196
- description: `Remove a previously installed custom script from the device and reload the scripts extension so the corresponding resource is unregistered. Use this to retire a scripted capability you no longer want the device to expose.`,
197
- inputSchema: {
198
- type: 'object',
199
- properties: {
200
- name: {
201
- type: 'string',
202
- description:
203
- 'Name of the script to remove (must match the file basename, without extension).',
204
- },
205
- device_id: { type: 'string', description: 'Device ID' },
206
- user: { type: 'string', description: 'API user' },
207
- },
208
- required: ['name', 'device_id'],
209
- },
210
- handler: toolScriptDelete,
211
- },
212
- ];