@thinremote/thinr-cli 1.2.0-alpha.1 → 1.2.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 (91) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +192 -16
  3. package/bin/thinr.js +23 -24
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +30 -17
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +199 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +12 -4
  51. package/lib/constants.js +76 -0
  52. package/lib/dashboard/event-stream.js +239 -0
  53. package/lib/dashboard/metric-aggregate.js +64 -0
  54. package/lib/dashboard/metric-filter.js +39 -0
  55. package/lib/debug-log.js +93 -0
  56. package/lib/device-api.js +109 -147
  57. package/lib/devices.js +18 -21
  58. package/lib/fleet-upgrade.js +106 -0
  59. package/lib/format.js +114 -0
  60. package/lib/mcp/registry.js +14 -0
  61. package/lib/mcp/tools-alarms.js +510 -0
  62. package/lib/mcp/tools-buckets.js +103 -0
  63. package/lib/mcp/tools-devices.js +33 -3
  64. package/lib/mcp/tools-exec.js +30 -17
  65. package/lib/mcp/tools-filesystem.js +102 -6
  66. package/lib/mcp/tools-install.js +153 -0
  67. package/lib/mcp/tools-logs.js +225 -0
  68. package/lib/mcp/tools-playbook.js +211 -0
  69. package/lib/mcp/tools-products.js +1226 -105
  70. package/lib/mcp/tools-profile.js +634 -0
  71. package/lib/mcp/tools-tokens.js +371 -0
  72. package/lib/monitoring.js +26 -1
  73. package/lib/playbook/actions.js +447 -0
  74. package/lib/playbook/expression.js +302 -0
  75. package/lib/playbook/fleet.js +175 -0
  76. package/lib/playbook/loader.js +247 -0
  77. package/lib/playbook/runner.js +300 -0
  78. package/lib/playbook/schema.js +209 -0
  79. package/lib/playbook/vars.js +209 -0
  80. package/lib/product/log-presets.js +196 -0
  81. package/lib/product/logs.js +485 -0
  82. package/lib/product-orchestrator.js +124 -0
  83. package/lib/product.js +523 -20
  84. package/lib/profile.js +665 -0
  85. package/lib/property.js +7 -40
  86. package/lib/proxy.js +9 -8
  87. package/lib/resource.js +10 -14
  88. package/lib/status.js +78 -37
  89. package/lib/tokens.js +374 -0
  90. package/package.json +14 -3
  91. package/commands/product.js +0 -407
@@ -0,0 +1,103 @@
1
+ // @ts-check
2
+ import { getBucketData } from '../bucket.js';
3
+ import { inputError } from '../errors.js';
4
+
5
+ async function toolBucketRead(args) {
6
+ if (!args.bucket)
7
+ throw inputError(
8
+ 'bucket is required. Buckets are declared in the product profile (default: "monitoring").',
9
+ );
10
+ const data = await getBucketData({
11
+ bucket: args.bucket,
12
+ device: args.device,
13
+ user: args.user,
14
+ items: args.items || 10,
15
+ sort: args.sort || 'desc',
16
+ minutes: args.minutes,
17
+ min_ts: args.min_ts,
18
+ max_ts: args.max_ts,
19
+ agg: args.agg,
20
+ agg_type: args.agg_type,
21
+ fields: args.fields,
22
+ group_by: args.group_by,
23
+ });
24
+ const text =
25
+ Array.isArray(data) && data.length === 0
26
+ ? 'No data'
27
+ : JSON.stringify(data, null, 2);
28
+ return {
29
+ content: [{ type: 'text', text }],
30
+ isError: false,
31
+ };
32
+ }
33
+
34
+ export const tools = [
35
+ {
36
+ name: 'thinr_bucket_read',
37
+ description: `Query any time-series bucket declared in the product profile. Use this for custom metrics (domain data, business events, custom sensors) beyond the default \`monitoring\` bucket — for \`monitoring\` itself prefer \`thinr_monitoring\`, which formats the output.
38
+
39
+ Modes:
40
+ - device set: historical time series scoped to that device.
41
+ - no device: user-level query across the fleet. By default returns raw interleaved samples; pass \`group_by=device\` (assuming the bucket tags samples with \`device\`) to get one row per device.
42
+
43
+ Custom buckets may not share the \`monitoring\` schema — don't assume a \`device\` tag exists unless the product profile defines it.`,
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: {
47
+ bucket: {
48
+ type: 'string',
49
+ description:
50
+ 'Bucket ID as declared in the product profile (e.g. "monitoring", or a custom one like "sensors", "events").',
51
+ },
52
+ device: {
53
+ type: 'string',
54
+ description:
55
+ 'Device ID. Omit for a one-row-per-device fleet view.',
56
+ },
57
+ items: {
58
+ type: 'number',
59
+ description:
60
+ 'Number of data points to return (default: 10). In multi-device mode set ≥ number of devices.',
61
+ },
62
+ minutes: {
63
+ type: 'number',
64
+ description:
65
+ 'Get data from the last N minutes. Alternative to min_ts/max_ts.',
66
+ },
67
+ min_ts: { type: 'number', description: 'Minimum timestamp in milliseconds' },
68
+ max_ts: {
69
+ type: 'number',
70
+ description: 'Maximum timestamp in milliseconds (0 = now)',
71
+ },
72
+ sort: {
73
+ type: 'string',
74
+ description:
75
+ 'Sort order by timestamp: "asc" or "desc" (default: "desc"). Does NOT sort by metric — do that client-side.',
76
+ },
77
+ agg: {
78
+ type: 'string',
79
+ description:
80
+ 'Time-window size for windowed aggregation: "5m", "10m", "1h", "6h". Only meaningful with a specific device. For a per-device single value across the whole range, omit `agg` and pass `agg_type` alone.',
81
+ },
82
+ agg_type: {
83
+ type: 'string',
84
+ description:
85
+ 'Reducer applied to each group/window. Supported: "mean", "max", "min", "sum", "count", "first", "last", "median", "spread", "stddev".',
86
+ },
87
+ fields: {
88
+ type: 'string',
89
+ description:
90
+ 'Comma-separated fields to return (e.g., "temperature,humidity"). Narrower queries are cheaper.',
91
+ },
92
+ group_by: {
93
+ type: 'string',
94
+ description:
95
+ 'Optional group key (e.g. "device" for a one-row-per-device fleet view). Omit for raw samples — custom buckets may not carry a "device" tag.',
96
+ },
97
+ user: { type: 'string', description: 'API user' },
98
+ },
99
+ required: ['bucket'],
100
+ },
101
+ handler: toolBucketRead,
102
+ },
103
+ ];
@@ -1,7 +1,8 @@
1
1
  // @ts-check
2
- import { getDevices, getDevice } from '../devices.js';
2
+ import { getDevices, getDevice, deleteDevice } from '../devices.js';
3
3
  import { setDeviceProduct } from '../product.js';
4
4
  import { inputError } from '../errors.js';
5
+ import { BYTES } from '../constants.js';
5
6
  import { getAPI } from './helpers.js';
6
7
 
7
8
  async function toolDevices(args) {
@@ -87,11 +88,11 @@ async function toolDeviceInfo(args) {
87
88
  );
88
89
  if (mon.memory)
89
90
  parts.push(
90
- `Memory: ${mon.memory.usage?.toFixed(1)}% (${(mon.memory.total / 1073741824).toFixed(1)}GB total)`,
91
+ `Memory: ${mon.memory.usage?.toFixed(1)}% (${(mon.memory.total / BYTES.GB).toFixed(1)}GB total)`,
91
92
  );
92
93
  if (mon.disk?.root)
93
94
  parts.push(
94
- `Disk: ${mon.disk.root.usage?.toFixed(1)}% (${(mon.disk.root.total / 1073741824).toFixed(0)}GB total)`,
95
+ `Disk: ${mon.disk.root.usage?.toFixed(1)}% (${(mon.disk.root.total / BYTES.GB).toFixed(0)}GB total)`,
95
96
  );
96
97
  if (mon.network)
97
98
  parts.push(
@@ -131,6 +132,21 @@ async function toolDeviceSetProduct(args) {
131
132
  return { content: [{ type: 'text', text: label }], isError: false };
132
133
  }
133
134
 
135
+ async function toolDeviceDelete(args) {
136
+ const device = args.device;
137
+ if (!device) throw inputError('device is required');
138
+ const removed = await deleteDevice(device, args.user);
139
+ return {
140
+ content: [
141
+ {
142
+ type: 'text',
143
+ text: removed ? `Deleted ${device}` : `Device ${device} did not exist`,
144
+ },
145
+ ],
146
+ isError: false,
147
+ };
148
+ }
149
+
134
150
  export const tools = [
135
151
  {
136
152
  name: 'thinr_devices',
@@ -191,4 +207,18 @@ export const tools = [
191
207
  },
192
208
  handler: toolDeviceSetProduct,
193
209
  },
210
+ {
211
+ name: 'thinr_device_delete',
212
+ description:
213
+ 'Delete a device record from the platform. Destructive and irreversible — drops the device, its server-side metadata, and stored properties. The agent on the host (if still running) will be left orphaned and will not auto-recreate the record. Idempotent: returns success when the device was already absent.',
214
+ inputSchema: {
215
+ type: 'object',
216
+ properties: {
217
+ device: { type: 'string', description: 'Device ID to delete.' },
218
+ user: { type: 'string', description: 'API user' },
219
+ },
220
+ required: ['device'],
221
+ },
222
+ handler: toolDeviceDelete,
223
+ },
194
224
  ];
@@ -1,14 +1,17 @@
1
1
  // @ts-check
2
2
  import { getMonitoringData } from '../monitoring.js';
3
3
  import { inputError } from '../errors.js';
4
+ import { BYTES, TIMEOUTS } from '../constants.js';
4
5
  import { getAPI } from './helpers.js';
5
6
 
6
7
  async function toolExec(args) {
7
8
  const { api } = getAPI(args.device, args.user);
8
- // Default to 30s so a hung command doesn't lock the session
9
- // indefinitely. Callers can pass a larger value for known-long
10
- // operations (compiles, backups, …).
11
- const timeout = Number.isFinite(args.timeout) && args.timeout > 0 ? args.timeout : 30;
9
+ // Default guards against hung commands locking the session; callers
10
+ // override for known-long operations. Knob lives in lib/constants.js.
11
+ const timeout =
12
+ Number.isFinite(args.timeout) && args.timeout > 0
13
+ ? args.timeout
14
+ : TIMEOUTS.DEFAULT_EXEC_SECONDS;
12
15
  let output = '';
13
16
  const { exitCode, timedOut } = await api.execStream(args.command, {
14
17
  timeout,
@@ -58,15 +61,15 @@ async function toolMonitoring(args) {
58
61
  );
59
62
  if (mon.memory)
60
63
  parts.push(
61
- `Memory: ${mon.memory.usage?.toFixed(1)}% (available: ${(mon.memory.available / 1073741824).toFixed(1)}GB / total: ${(mon.memory.total / 1073741824).toFixed(1)}GB)`,
64
+ `Memory: ${mon.memory.usage?.toFixed(1)}% (available: ${(mon.memory.available / BYTES.GB).toFixed(1)}GB / total: ${(mon.memory.total / BYTES.GB).toFixed(1)}GB)`,
62
65
  );
63
66
  if (mon.disk?.root)
64
67
  parts.push(
65
- `Disk: ${mon.disk.root.usage?.toFixed(1)}% (available: ${(mon.disk.root.available / 1073741824).toFixed(1)}GB / total: ${(mon.disk.root.total / 1073741824).toFixed(0)}GB)`,
68
+ `Disk: ${mon.disk.root.usage?.toFixed(1)}% (available: ${(mon.disk.root.available / BYTES.GB).toFixed(1)}GB / total: ${(mon.disk.root.total / BYTES.GB).toFixed(0)}GB)`,
66
69
  );
67
70
  if (mon.network)
68
71
  parts.push(
69
- `Network: rx=${(mon.network.rx_rate / 1024).toFixed(1)}KB/s tx=${(mon.network.tx_rate / 1024).toFixed(1)}KB/s`,
72
+ `Network: rx=${(mon.network.rx_rate / BYTES.KB).toFixed(1)}KB/s tx=${(mon.network.tx_rate / BYTES.KB).toFixed(1)}KB/s`,
70
73
  );
71
74
  if (mon.load)
72
75
  parts.push(
@@ -99,8 +102,10 @@ async function toolMonitoring(args) {
99
102
  if (Array.isArray(data) && data.length > 0) {
100
103
  output = data
101
104
  .map((d) => {
102
- const ts = new Date(d.ts).toISOString();
103
- const parts = [ts];
105
+ // Single-window aggregated rows (group_by without `agg`) come
106
+ // back without `ts`; skip it rather than emitting "Invalid Date".
107
+ const parts = [];
108
+ if (d.ts) parts.push(new Date(d.ts).toISOString());
104
109
  if (d.device) parts.push(`device=${d.device}`);
105
110
  if (d.cpu) {
106
111
  const cpuParts = [];
@@ -113,7 +118,7 @@ async function toolMonitoring(args) {
113
118
  if (d.disk?.root) parts.push(`disk=${d.disk.root.usage?.toFixed(1)}%`);
114
119
  if (d.network)
115
120
  parts.push(
116
- `net_rx=${(d.network.rx_rate / 1024)?.toFixed(1)}KB/s tx=${(d.network.tx_rate / 1024)?.toFixed(1)}KB/s`,
121
+ `net_rx=${(d.network.rx_rate / BYTES.KB)?.toFixed(1)}KB/s tx=${(d.network.tx_rate / BYTES.KB)?.toFixed(1)}KB/s`,
117
122
  );
118
123
  if (d.load) parts.push(`load=${d.load['1m']?.toFixed(2)}`);
119
124
  if (d.processes) parts.push(`procs=${d.processes.total}`);
@@ -166,7 +171,12 @@ export const tools = [
166
171
  },
167
172
  {
168
173
  name: 'thinr_monitoring',
169
- description: `Get monitoring data (CPU, memory, disk, network, temperature, load) from a device or all devices. Use realtime=true for live data from the device, or omit for historical data from the monitoring bucket.`,
174
+ description: `Get monitoring data (CPU, memory, disk, network, temperature, load).
175
+
176
+ Modes:
177
+ - realtime=true + device: live sample read straight from the agent.
178
+ - device only: historical time series for that device. Use \`agg\`+\`agg_type\` for time-windowed aggregates (e.g. avg CPU every 5m over the last hour).
179
+ - no device: historical fleet view — returns ONE ROW PER DEVICE. Without \`agg_type\` each row is the latest sample for that device ("current fleet state"). With \`agg_type\` each row is the reducer applied over the time window per device (e.g. \`agg_type=max\`+\`minutes=60\` ⇒ peak per device in the last hour, ideal for ranking "which device had the most X"). Sort the returned array client-side on the metric to get the top N.`,
170
180
  inputSchema: {
171
181
  type: 'object',
172
182
  properties: {
@@ -178,11 +188,11 @@ export const tools = [
178
188
  device: {
179
189
  type: 'string',
180
190
  description:
181
- 'Device ID. Required for realtime. Omit for historical data from ALL devices.',
191
+ 'Device ID. Required for realtime. Omit for a one-row-per-device fleet view.',
182
192
  },
183
193
  items: {
184
194
  type: 'number',
185
- description: 'Number of data points to return (default: 10)',
195
+ description: 'Number of data points to return (default: 10). In multi-device mode set ≥ number of devices (e.g. 200) since each device contributes one row.',
186
196
  },
187
197
  minutes: {
188
198
  type: 'number',
@@ -195,14 +205,17 @@ export const tools = [
195
205
  },
196
206
  sort: {
197
207
  type: 'string',
198
- description: 'Sort order: "asc" or "desc" (default: "desc")',
208
+ description: 'Sort order by timestamp: "asc" or "desc" (default: "desc"). Does NOT sort by metric — do that client-side on the result.',
209
+ },
210
+ agg: { type: 'string', description: 'Time-window size for windowed aggregation: "5m", "10m", "1h", "6h". Only meaningful with a specific device. For a per-device single value across the whole range, omit `agg` and pass `agg_type` alone.' },
211
+ agg_type: {
212
+ type: 'string',
213
+ description: 'Reducer applied to each group/window. Supported: "mean", "max", "min", "sum", "count", "first", "last", "median", "spread" (max-min), "stddev". Most common for monitoring: max, mean, min.',
199
214
  },
200
- agg: { type: 'string', description: 'Aggregation period: "5m", "10m", "1h", "6h"' },
201
- agg_type: { type: 'string', description: 'Aggregation type: "mean", "min", "max"' },
202
215
  fields: {
203
216
  type: 'string',
204
217
  description:
205
- 'Comma-separated fields to return (e.g., "cpu.usage,memory.usage,disk.root.usage")',
218
+ 'Comma-separated fields to return (e.g., "cpu.usage,memory.usage,disk.root.usage"). Narrower queries are cheaper and produce tidier output.',
206
219
  },
207
220
  user: { type: 'string', description: 'API user' },
208
221
  },
@@ -1,5 +1,8 @@
1
1
  // @ts-check
2
+ import { readFile as readLocalFile, writeFile as writeLocalFile } from 'fs/promises';
3
+ import { resolve as resolvePath, basename } from 'path';
2
4
  import { getAPI } from './helpers.js';
5
+ import { inputError } from '../errors.js';
3
6
 
4
7
  async function toolRead(args) {
5
8
  const { api } = getAPI(args.device, args.user);
@@ -43,7 +46,7 @@ async function toolMkdir(args) {
43
46
  };
44
47
  }
45
48
 
46
- async function toolDelete(args) {
49
+ async function toolRm(args) {
47
50
  const { api } = getAPI(args.device, args.user);
48
51
  await api.delete(args.path, args.recursive !== false);
49
52
  return {
@@ -52,7 +55,7 @@ async function toolDelete(args) {
52
55
  };
53
56
  }
54
57
 
55
- async function toolMove(args) {
58
+ async function toolMv(args) {
56
59
  const { api } = getAPI(args.device, args.user);
57
60
  await api.move(args.source, args.destination, args.overwrite || false);
58
61
  return {
@@ -61,6 +64,55 @@ async function toolMove(args) {
61
64
  };
62
65
  }
63
66
 
67
+ async function toolPush(args) {
68
+ if (typeof args.source !== 'string' || !args.source) {
69
+ throw inputError('`source` (local file path) is required');
70
+ }
71
+ if (typeof args.destination !== 'string' || !args.destination) {
72
+ throw inputError('`destination` (remote path on the device) is required');
73
+ }
74
+ const localPath = resolvePath(args.source);
75
+ const remotePath = args.destination.endsWith('/')
76
+ ? args.destination + basename(localPath)
77
+ : args.destination;
78
+ const { api } = getAPI(args.device, args.user);
79
+ const content = await readLocalFile(localPath);
80
+ await api.writeFile(remotePath, content);
81
+ return {
82
+ content: [
83
+ {
84
+ type: 'text',
85
+ text: `Pushed ${content.byteLength} bytes: ${localPath} → ${remotePath}`,
86
+ },
87
+ ],
88
+ isError: false,
89
+ };
90
+ }
91
+
92
+ async function toolPull(args) {
93
+ if (typeof args.source !== 'string' || !args.source) {
94
+ throw inputError('`source` (remote path on the device) is required');
95
+ }
96
+ if (typeof args.destination !== 'string' || !args.destination) {
97
+ throw inputError('`destination` (local file path) is required');
98
+ }
99
+ const localPath = args.destination.endsWith('/')
100
+ ? resolvePath(args.destination, basename(args.source))
101
+ : resolvePath(args.destination);
102
+ const { api } = getAPI(args.device, args.user);
103
+ const buf = await api.readFile(args.source);
104
+ await writeLocalFile(localPath, buf);
105
+ return {
106
+ content: [
107
+ {
108
+ type: 'text',
109
+ text: `Pulled ${buf.byteLength} bytes: ${args.source} → ${localPath}`,
110
+ },
111
+ ],
112
+ isError: false,
113
+ };
114
+ }
115
+
64
116
  export const tools = [
65
117
  {
66
118
  name: 'thinr_read',
@@ -124,7 +176,7 @@ export const tools = [
124
176
  handler: toolMkdir,
125
177
  },
126
178
  {
127
- name: 'thinr_delete',
179
+ name: 'thinr_rm',
128
180
  description: `Delete a file or directory on a remote device. Paths are absolute on the device filesystem (e.g., "/etc/hosts", "/var/log/syslog"). Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths like /etc.`,
129
181
  inputSchema: {
130
182
  type: 'object',
@@ -139,10 +191,10 @@ export const tools = [
139
191
  },
140
192
  required: ['path', 'device'],
141
193
  },
142
- handler: toolDelete,
194
+ handler: toolRm,
143
195
  },
144
196
  {
145
- name: 'thinr_move',
197
+ name: 'thinr_mv',
146
198
  description: `Move or rename a file or directory on a remote device. Paths are absolute on the device filesystem (e.g., "/etc/hosts", "/var/log/syslog"). Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths like /etc.`,
147
199
  inputSchema: {
148
200
  type: 'object',
@@ -158,6 +210,50 @@ export const tools = [
158
210
  },
159
211
  required: ['source', 'destination', 'device'],
160
212
  },
161
- handler: toolMove,
213
+ handler: toolMv,
214
+ },
215
+ {
216
+ name: 'thinr_push',
217
+ description: `Upload a local file to a remote device. \`source\` is a path on the machine running the MCP server; \`destination\` is an absolute path on the device (trailing "/" keeps the source basename). Use this for binary or large payloads that don't fit as inline strings in thinr_write.`,
218
+ inputSchema: {
219
+ type: 'object',
220
+ properties: {
221
+ source: {
222
+ type: 'string',
223
+ description: 'Local file path (absolute or relative to the MCP server CWD).',
224
+ },
225
+ destination: {
226
+ type: 'string',
227
+ description:
228
+ 'Absolute path on the device. Trailing "/" keeps the source basename.',
229
+ },
230
+ device: { type: 'string', description: 'Device ID' },
231
+ user: { type: 'string', description: 'API user' },
232
+ },
233
+ required: ['source', 'destination', 'device'],
234
+ },
235
+ handler: toolPush,
236
+ },
237
+ {
238
+ name: 'thinr_pull',
239
+ description: `Download a remote file from a device to the local disk of the machine running the MCP server. \`source\` is an absolute path on the device; \`destination\` is a local file path (trailing "/" keeps the source basename).`,
240
+ inputSchema: {
241
+ type: 'object',
242
+ properties: {
243
+ source: {
244
+ type: 'string',
245
+ description: 'Absolute file path on the device.',
246
+ },
247
+ destination: {
248
+ type: 'string',
249
+ description:
250
+ 'Local file path (absolute or relative to the MCP server CWD). Trailing "/" keeps the source basename.',
251
+ },
252
+ device: { type: 'string', description: 'Device ID' },
253
+ user: { type: 'string', description: 'API user' },
254
+ },
255
+ required: ['source', 'destination', 'device'],
256
+ },
257
+ handler: toolPull,
162
258
  },
163
259
  ];
@@ -0,0 +1,153 @@
1
+ // @ts-check
2
+ import { requireConfig } from '../config.js';
3
+ import { inputError } from '../errors.js';
4
+
5
+ const CHANNEL_SCRIPT = {
6
+ latest: 'install.sh',
7
+ main: 'install-main.sh',
8
+ develop: 'install-develop.sh',
9
+ };
10
+
11
+ function shQuote(value) {
12
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
13
+ }
14
+
15
+ function decodeJwtClaims(token) {
16
+ const parts = String(token).split('.');
17
+ if (parts.length !== 3) return null;
18
+ try {
19
+ const pad = parts[1].length % 4 === 0 ? '' : '='.repeat(4 - (parts[1].length % 4));
20
+ const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/') + pad;
21
+ return JSON.parse(Buffer.from(normalized, 'base64').toString('utf8'));
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ async function toolAgentInstallCommand(args) {
28
+ // requireConfig throws `not_configured` if there's no active profile
29
+ // with a token — classifyError in server.js surfaces that cleanly.
30
+ const config = requireConfig();
31
+
32
+ const channel = args.channel || 'latest';
33
+ const script = CHANNEL_SCRIPT[channel];
34
+ if (!script) {
35
+ throw inputError(
36
+ `Unknown channel "${channel}". Valid channels: ${Object.keys(CHANNEL_SCRIPT).join(', ')}.`,
37
+ );
38
+ }
39
+
40
+ const scheme = args.http ? 'http' : 'https';
41
+ const url = `${scheme}://get.thinremote.io/${script}`;
42
+
43
+ const agentArgs = ['install', '--token', shQuote(config.token)];
44
+ if (args.device) agentArgs.push('--device', shQuote(args.device));
45
+ if (args.product) agentArgs.push('--product', shQuote(args.product));
46
+ if (args.overwrite) agentArgs.push('--overwrite');
47
+ if (args.no_start) agentArgs.push('--no-start');
48
+ if (args.no_verify_ssl) agentArgs.push('--no-verify-ssl');
49
+
50
+ const runner = args.sudo ? 'sudo sh' : 'sh';
51
+ const command = `curl -fsSL ${url} | ${runner} -s -- ${agentArgs.join(' ')}`;
52
+
53
+ // The agent's `install --token` flow decodes the JWT and requires
54
+ // both `svr` and `usr` claims. Permanent user tokens minted via
55
+ // /v1/users/{user}/tokens are missing `svr` on some server versions,
56
+ // which makes auto-provisioning fail with "Invalid token". Warn up
57
+ // front so the caller can switch to an OAuth-issued token or ask
58
+ // the platform for a proper provisioning token.
59
+ const claims = decodeJwtClaims(config.token);
60
+ const warnings = [];
61
+ if (!claims) {
62
+ warnings.push(
63
+ 'The active profile token is not a decodable JWT. Auto-provisioning expects a JWT with `svr` and `usr` claims.',
64
+ );
65
+ } else {
66
+ if (!claims.svr) {
67
+ warnings.push(
68
+ `The profile token is missing the \`svr\` claim. The agent will reject it with "Invalid token: missing 'svr' or 'usr' claim". Re-authenticate this profile via OAuth (\`thinr\`) or generate a provisioning token from the ${config.server} console.`,
69
+ );
70
+ }
71
+ if (!claims.usr) {
72
+ warnings.push('The profile token is missing the `usr` claim.');
73
+ }
74
+ }
75
+
76
+ const sections = [
77
+ `Server: ${config.server}`,
78
+ `User: ${config.username}`,
79
+ `Channel: ${channel}`,
80
+ '',
81
+ 'Run on the target host (directly or via `ssh host "<command>"`):',
82
+ '',
83
+ command,
84
+ '',
85
+ 'Notes:',
86
+ '- The command embeds the active profile\'s API token — treat it as a credential.',
87
+ '- Installing the system service needs root. Pass `sudo: true` if the target host is not already root.',
88
+ '- The agent reads the target server from the JWT, so `--host` is not needed.',
89
+ ];
90
+
91
+ if (warnings.length) {
92
+ sections.push('', 'Warnings:', ...warnings.map((w) => `- ${w}`));
93
+ }
94
+
95
+ return {
96
+ content: [{ type: 'text', text: sections.join('\n') }],
97
+ isError: false,
98
+ };
99
+ }
100
+
101
+ export const tools = [
102
+ {
103
+ name: 'thinr_agent_install_command',
104
+ description:
105
+ "Generate a one-liner shell command that installs thinr-agent on a remote host using the active profile's token for auto-provisioning. Meant to be run on the target machine (directly or piped through `ssh`). The token is embedded verbatim, so only share the output through trusted channels.",
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ device: {
110
+ type: 'string',
111
+ description:
112
+ 'Custom device identifier. Omit to let the agent default to the target host\'s hostname.',
113
+ },
114
+ product: {
115
+ type: 'string',
116
+ description:
117
+ 'Product to associate the device with. Omit to auto-detect or create the default product.',
118
+ },
119
+ overwrite: {
120
+ type: 'boolean',
121
+ description:
122
+ 'Auto-overwrite an existing device with the same id on the server.',
123
+ },
124
+ no_start: {
125
+ type: 'boolean',
126
+ description: 'Install the service but do not start it.',
127
+ },
128
+ no_verify_ssl: {
129
+ type: 'boolean',
130
+ description:
131
+ 'Disable TLS certificate verification (for self-signed servers).',
132
+ },
133
+ channel: {
134
+ type: 'string',
135
+ enum: ['latest', 'main', 'develop'],
136
+ description: 'Release channel. Defaults to `latest` (stable).',
137
+ },
138
+ sudo: {
139
+ type: 'boolean',
140
+ description:
141
+ 'Pipe into `sudo sh` instead of `sh`. Needed on Linux hosts when running as a non-root user, since installing the systemd service writes under /etc.',
142
+ },
143
+ http: {
144
+ type: 'boolean',
145
+ description:
146
+ 'Use http://get.thinremote.io instead of https (for devices without working HTTPS).',
147
+ },
148
+ },
149
+ required: [],
150
+ },
151
+ handler: toolAgentInstallCommand,
152
+ },
153
+ ];