rterm-cli 3.2.10 → 3.2.11

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 (3) hide show
  1. package/README.md +1 -0
  2. package/package.json +2 -2
  3. package/rterm-cli.mjs +200 -93
package/README.md CHANGED
@@ -34,6 +34,7 @@ rterm version # backend version + method count
34
34
  rterm methods [--category terminal] # self-describing RPC surface
35
35
  rterm call <method> [json-params] # raw JSON-RPC call
36
36
  rterm terminals # list terminal tabs
37
+ rterm connections # list saved SSH/WinRM/Serial connections
37
38
  rterm open <saved-connection-name> # open a tab for a saved connection
38
39
  rterm close <tabIdOrName> # close a terminal tab
39
40
  rterm run <tabIdOrName> <command> # run a command in a tab (waits)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rterm-cli",
3
- "version": "3.2.10",
4
- "description": "rterm — zero-dependency command CLI for the RTerm / neuralOS backend WebSocket gateway (ping, terminals, run, fleet, chat, dashboard, metrics, raw call)",
3
+ "version": "3.2.11",
4
+ "description": "rterm — zero-dependency command CLI for the RTerm / neuralOS backend WebSocket gateway (ping, terminals, connections, open, run, fleet, chat, dashboard, metrics, raw call)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "rterm": "./rterm-cli.mjs",
package/rterm-cli.mjs CHANGED
@@ -2,21 +2,14 @@
2
2
  /**
3
3
  * rterm-cli — a `gyll`-style command CLI for the RTerm / neuralOS backend.
4
4
  *
5
- * Speaks the backend's WebSocket JSON-RPC gateway natively (ws://host:17888):
6
- * rterm ping
7
- * rterm methods [--category cat] [--prefix p]
8
- * rterm call <method> [json-params]
9
- * rterm terminals
10
- * rterm open <saved-connection-name>
11
- * rterm run <tabIdOrName> <command>
12
- * rterm chat <sessionId> <message> (blocking; prints the final answer)
13
- * rterm sessions
14
- * rterm dashboard
15
- * rterm metrics [--format prometheus|summary]
16
- * rterm fleet <tab1,tab2> <command>
5
+ * Speaks the backend's WebSocket JSON-RPC gateway natively (ws://host:17888).
6
+ * Zero runtime dependencies: Node's built-in WebSocket (>= 21) with a `ws`
7
+ * package fallback.
17
8
  *
18
- * Zero runtime dependencies: uses Node's built-in WebSocket (Node >= 21) and
19
- * falls back to the `ws` package from the backend install when present.
9
+ * Commands:
10
+ * rterm ping | version | methods | call | terminals | connections
11
+ * rterm open <name> | close <tab> | run <tab> <cmd> | fleet <tabs> <cmd>
12
+ * rterm sessions | chat <session> <msg> | dashboard | metrics
20
13
  */
21
14
 
22
15
  import { readFileSync, existsSync } from 'node:fs'
@@ -50,6 +43,25 @@ function parseArgs(argv) {
50
43
  return { positional, flags }
51
44
  }
52
45
 
46
+ // ── error formatting (fix: errors used to print "[object Object]") ──────────
47
+
48
+ /** Extract a readable message from any thrown/rejected value. */
49
+ function errorMessage(value) {
50
+ if (value instanceof Error) return value.message
51
+ if (typeof value === 'string') return value
52
+ if (value && typeof value === 'object') {
53
+ // Gateway errors arrive as { code, message } or Error with .cause.
54
+ if (typeof value.message === 'string' && value.message) {
55
+ return value.code ? `${value.code}: ${value.message}` : value.message
56
+ }
57
+ if (typeof value.error === 'object' && value.error?.message) {
58
+ return `${value.error.code || 'ERROR'}: ${value.error.message}`
59
+ }
60
+ try { return JSON.stringify(value) } catch { return String(value) }
61
+ }
62
+ return String(value)
63
+ }
64
+
53
65
  // ── gateway client ──────────────────────────────────────────────────────────
54
66
 
55
67
  let nextId = 1
@@ -73,8 +85,8 @@ function loadToken() {
73
85
  return null
74
86
  }
75
87
 
76
- async function openSocket(url) {
77
- // Node >= 21 ships a native WebSocket client.
88
+ async function openSocket(url, token) {
89
+ const headers = token ? { Authorization: `Bearer ${token}` } : undefined
78
90
  if (typeof globalThis.WebSocket === 'function') {
79
91
  return await new Promise((resolve, reject) => {
80
92
  const ws = new globalThis.WebSocket(url)
@@ -82,13 +94,12 @@ async function openSocket(url) {
82
94
  ws.onerror = () => reject(new Error(`Cannot connect to ${url}. Is the backend running? (gybackend)`))
83
95
  })
84
96
  }
85
- // Fallback: the `ws` package (present in a backend checkout/install).
86
97
  try {
87
98
  const { createRequire } = await import('node:module')
88
99
  const require = createRequire(import.meta.url)
89
100
  const WS = require('ws')
90
101
  return await new Promise((resolve, reject) => {
91
- const ws = new WS(url)
102
+ const ws = new WS(url, { headers })
92
103
  ws.on('open', () => resolve(ws))
93
104
  ws.on('error', () => reject(new Error(`Cannot connect to ${url}. Is the backend running? (gybackend)`)))
94
105
  })
@@ -97,8 +108,8 @@ async function openSocket(url) {
97
108
  }
98
109
  }
99
110
 
100
- async function call(url, method, params) {
101
- const ws = await openSocket(url)
111
+ async function call(url, method, params, token) {
112
+ const ws = await openSocket(url, token)
102
113
  const id = String(nextId++)
103
114
  return await new Promise((resolve, reject) => {
104
115
  const timer = setTimeout(() => {
@@ -112,14 +123,15 @@ async function call(url, method, params) {
112
123
  clearTimeout(timer)
113
124
  try { ws.close() } catch { /* ignore */ }
114
125
  if (frame.ok) resolve(frame.result)
115
- else reject(new Error(frame.error || `gateway error: ${method}`))
126
+ else reject(frame.error || new Error(`gateway error: ${method}`))
116
127
  }
117
128
  } catch { /* ignore non-JSON frames */ }
118
129
  }
119
- const onClose = () => { clearTimeout(timer); reject(new Error('Connection closed before response.')) }
130
+ const onClose = () => {
131
+ clearTimeout(timer)
132
+ reject(new Error('Connection closed before response. Is the backend still running?'))
133
+ }
120
134
 
121
- // Normalize the native WebSocket (addEventListener) and the `ws` package
122
- // (on/on('message')) behind one interface.
123
135
  if (typeof ws.addEventListener === 'function') {
124
136
  ws.addEventListener('message', (event) => onMessage(event.data))
125
137
  ws.addEventListener('close', onClose)
@@ -138,6 +150,13 @@ async function call(url, method, params) {
138
150
  })
139
151
  }
140
152
 
153
+ /** Client bound to a URL + token, so commands don't repeat them. */
154
+ function makeClient(url, token) {
155
+ return {
156
+ call: (method, params) => call(url, method, params, token),
157
+ }
158
+ }
159
+
141
160
  // ── output helpers ──────────────────────────────────────────────────────────
142
161
 
143
162
  function printJson(value) {
@@ -145,7 +164,7 @@ function printJson(value) {
145
164
  }
146
165
 
147
166
  function fail(message) {
148
- console.error(`Error: ${message}`)
167
+ console.error(`Error: ${errorMessage(message)}`)
149
168
  process.exit(1)
150
169
  }
151
170
 
@@ -153,9 +172,11 @@ const HELP = `rterm — command CLI for the RTerm / neuralOS backend gateway
153
172
 
154
173
  Usage:
155
174
  rterm ping Liveness check
175
+ rterm version Backend version + method count
156
176
  rterm methods [--category c] [--prefix p] List gateway RPC methods
157
177
  rterm call <method> [json] Raw JSON-RPC call (params as JSON)
158
178
  rterm terminals List terminal tabs
179
+ rterm connections List saved SSH/WinRM/Serial connections
159
180
  rterm open <connection-name> Open a terminal tab for a saved connection
160
181
  rterm close <tabIdOrName> Close a terminal tab
161
182
  rterm run <tabIdOrName> <command> Run a command in a terminal tab (waits)
@@ -164,7 +185,6 @@ Usage:
164
185
  rterm chat <sessionId> <message> Send a message to the agent (blocking)
165
186
  rterm dashboard Print the live dashboard state
166
187
  rterm metrics [--format prometheus] Host metrics
167
- rterm version Backend version + method count
168
188
 
169
189
  Options:
170
190
  --url ws://host:port Gateway URL (default ${DEFAULT_URL}, env RTERM_URL)
@@ -174,12 +194,121 @@ Environment:
174
194
  RTERM_URL, RTERM_HOST, RTERM_PORT, RTERM_TOKEN
175
195
  `
176
196
 
197
+ // ── command helpers ─────────────────────────────────────────────────────────
198
+
199
+ const ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
200
+
201
+ function stripAnsi(text) {
202
+ return text.replace(ANSI_RE, '')
203
+ }
204
+
205
+ /**
206
+ * Resolve a saved connection by name or id across ssh/winrm/serial and build
207
+ * an inline terminal config the backend accepts (createTab does NOT resolve
208
+ * saved-connection names itself).
209
+ */
210
+ function connectionToConfig(entry, kind) {
211
+ if (kind === 'ssh') {
212
+ return {
213
+ type: 'ssh',
214
+ host: entry.host,
215
+ port: entry.port ?? 22,
216
+ username: entry.username,
217
+ ...(entry.authMethod === 'privateKey'
218
+ ? { privateKey: entry.privateKey }
219
+ : { password: entry.password }),
220
+ ...(entry.algorithmsPreset ? { algorithmsPreset: entry.algorithmsPreset } : {}),
221
+ ...(entry.termType ? { termType: entry.termType } : {}),
222
+ }
223
+ }
224
+ if (kind === 'winrm') {
225
+ return {
226
+ type: 'winrm',
227
+ host: entry.host,
228
+ port: entry.port ?? 5985,
229
+ username: entry.username,
230
+ password: entry.password,
231
+ ...(entry.transport ? { transport: entry.transport } : {}),
232
+ }
233
+ }
234
+ return {
235
+ type: 'serial',
236
+ path: entry.path,
237
+ baudRate: entry.baudRate ?? 9600,
238
+ }
239
+ }
240
+
241
+ async function fetchConnections(client) {
242
+ const settings = await client.call('settings:get', {})
243
+ const conns = settings?.connections || {}
244
+ return {
245
+ ssh: Array.isArray(conns.ssh) ? conns.ssh : [],
246
+ winrm: Array.isArray(conns.winrm) ? conns.winrm : [],
247
+ serial: Array.isArray(conns.serial) ? conns.serial : [],
248
+ }
249
+ }
250
+
251
+ async function resolveConnection(client, nameOrId) {
252
+ const { ssh, winrm, serial } = await fetchConnections(client)
253
+ const pools = [['ssh', ssh], ['winrm', winrm], ['serial', serial]]
254
+ for (const [kind, list] of pools) {
255
+ const hit = list.find((c) => c.id === nameOrId || c.name === nameOrId)
256
+ if (hit) return { kind, entry: hit }
257
+ }
258
+ return null
259
+ }
260
+
261
+ /** Resolve a tab id-or-name to a real tab id via terminal:list. */
262
+ async function resolveTabId(client, tabIdOrName) {
263
+ const result = await client.call('terminal:list', {})
264
+ const terminals = result?.terminals || []
265
+ const hit = terminals.find((t) => t.id === tabIdOrName || t.title === tabIdOrName)
266
+ return hit?.id || null
267
+ }
268
+
269
+ /**
270
+ * Run a command in a tab via write + buffer-delta polling; returns output.
271
+ * Validates the tab exists first — the gateway silently returns empty output
272
+ * for unknown terminal ids, which would otherwise look like success.
273
+ */
274
+ async function runInTab(client, tabIdOrName, commandText) {
275
+ const tabId = await resolveTabId(client, tabIdOrName)
276
+ if (!tabId) {
277
+ const result = await client.call('terminal:list', {})
278
+ const known = (result?.terminals || []).map((t) => `${t.id} (${t.title})`).join(', ')
279
+ throw new Error(`No terminal tab "${tabIdOrName}". Open tabs: ${known || '(none)'}`)
280
+ }
281
+ const before = await client.call('terminal:getBufferDelta', { terminalId: tabId, fromOffset: 0 })
282
+ const startOffset = Number(before?.offset ?? 0)
283
+ await client.call('terminal:write', { terminalId: tabId, data: `${commandText}\n` })
284
+ let output = ''
285
+ let lastOffset = startOffset
286
+ let stable = 0
287
+ const deadline = Date.now() + 30_000
288
+ while (Date.now() < deadline) {
289
+ await new Promise((resolve) => setTimeout(resolve, 400))
290
+ const delta = await client.call('terminal:getBufferDelta', { terminalId: tabId, fromOffset: lastOffset })
291
+ const data = typeof delta?.data === 'string' ? delta.data : ''
292
+ const offset = Number(delta?.offset ?? lastOffset)
293
+ if (data) output += data
294
+ if (offset === lastOffset && !data) {
295
+ stable += 1
296
+ if (stable >= 3) break
297
+ } else {
298
+ stable = 0
299
+ }
300
+ lastOffset = offset
301
+ }
302
+ return stripAnsi(output).trimEnd()
303
+ }
304
+
177
305
  // ── commands ────────────────────────────────────────────────────────────────
178
306
 
179
307
  async function main() {
180
308
  const argv = process.argv.slice(2)
181
309
  const { positional, flags } = parseArgs(argv)
182
310
  const url = (typeof flags.url === 'string' && flags.url) || DEFAULT_URL
311
+ const token = (typeof flags.token === 'string' && flags.token) || loadToken()
183
312
  const command = positional[0]
184
313
 
185
314
  if (!command || command === 'help' || flags.help) {
@@ -187,15 +316,16 @@ async function main() {
187
316
  process.exit(0)
188
317
  }
189
318
 
319
+ const client = makeClient(url, token)
320
+
190
321
  try {
191
322
  switch (command) {
192
323
  case 'ping': {
193
- const result = await call(url, 'gateway:ping')
194
- printJson(result)
324
+ printJson(await client.call('gateway:ping'))
195
325
  break
196
326
  }
197
327
  case 'version': {
198
- const result = await call(url, 'gateway:describe')
328
+ const result = await client.call('gateway:describe')
199
329
  printJson({ version: result.version, methodCount: result.count, categories: result.categories })
200
330
  break
201
331
  }
@@ -203,7 +333,7 @@ async function main() {
203
333
  const params = {}
204
334
  if (typeof flags.category === 'string') params.category = flags.category
205
335
  if (typeof flags.prefix === 'string') params.prefix = flags.prefix
206
- const result = await call(url, 'gateway:describe', params)
336
+ const result = await client.call('gateway:describe', params)
207
337
  printJson(result.methods)
208
338
  break
209
339
  }
@@ -214,57 +344,58 @@ async function main() {
214
344
  if (positional[2]) {
215
345
  try { params = JSON.parse(positional[2]) } catch { fail('params must be valid JSON') }
216
346
  }
217
- const result = await call(url, method, params)
218
- printJson(result)
347
+ printJson(await client.call(method, params))
219
348
  break
220
349
  }
221
350
  case 'terminals': {
222
- const result = await call(url, 'terminal:list')
223
- printJson(result)
351
+ printJson(await client.call('terminal:list'))
352
+ break
353
+ }
354
+ case 'connections': {
355
+ const { ssh, winrm, serial } = await fetchConnections(client)
356
+ const out = { connections: [] }
357
+ for (const kind of ['ssh', 'winrm', 'serial']) {
358
+ const list = kind === 'ssh' ? ssh : kind === 'winrm' ? winrm : serial
359
+ for (const c of list) {
360
+ out.connections.push({
361
+ kind,
362
+ name: c.name,
363
+ id: c.id,
364
+ host: c.host || c.path || '',
365
+ port: c.port ?? c.baudRate ?? '',
366
+ username: c.username || '',
367
+ })
368
+ }
369
+ }
370
+ printJson(out)
224
371
  break
225
372
  }
226
373
  case 'open': {
227
374
  const name = positional[1]
228
375
  if (!name) fail('open needs a saved connection name: rterm open <name>')
229
- const result = await call(url, 'terminal:createTab', { config: { savedConnectionName: name } })
230
- printJson(result)
376
+ const found = await resolveConnection(client, name)
377
+ if (!found) {
378
+ const { ssh, winrm, serial } = await fetchConnections(client)
379
+ const names = [...ssh, ...winrm, ...serial].map((c) => c.name).filter(Boolean)
380
+ fail(`No saved connection named "${name}". Available: ${names.join(', ') || '(none)'}`)
381
+ }
382
+ const config = connectionToConfig(found.entry, found.kind)
383
+ const result = await client.call('terminal:createTab', { config })
384
+ printJson({ opened: name, kind: found.kind, ...result })
231
385
  break
232
386
  }
233
387
  case 'close': {
234
388
  const tab = positional[1]
235
389
  if (!tab) fail('close needs a tab id or name: rterm close <tabIdOrName>')
236
- const result = await call(url, 'terminal:kill', { terminalId: tab })
237
- printJson(result)
390
+ const tabId = (await resolveTabId(client, tab)) || tab
391
+ printJson(await client.call('terminal:kill', { terminalId: tabId }))
238
392
  break
239
393
  }
240
394
  case 'run': {
241
395
  const tab = positional[1]
242
396
  const commandText = positional.slice(2).join(' ')
243
397
  if (!tab || !commandText) fail('run needs: rterm run <tabIdOrName> <command>')
244
- // The gateway's terminal surface is write + buffer-delta: send the
245
- // command with a newline, then poll the buffer until it settles.
246
- const before = await call(url, 'terminal:getBufferDelta', { terminalId: tab, fromOffset: 0 })
247
- const startOffset = Number(before?.offset ?? 0)
248
- await call(url, 'terminal:write', { terminalId: tab, data: `${commandText}\n` })
249
- let output = ''
250
- let lastOffset = startOffset
251
- let stable = 0
252
- const deadline = Date.now() + 30_000
253
- while (Date.now() < deadline) {
254
- await new Promise((resolve) => setTimeout(resolve, 400))
255
- const delta = await call(url, 'terminal:getBufferDelta', { terminalId: tab, fromOffset: lastOffset })
256
- const data = typeof delta?.data === 'string' ? delta.data : ''
257
- const offset = Number(delta?.offset ?? lastOffset)
258
- if (data) output += data
259
- if (offset === lastOffset && !data) {
260
- stable += 1
261
- if (stable >= 3) break
262
- } else {
263
- stable = 0
264
- }
265
- lastOffset = offset
266
- }
267
- console.log(output.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').trimEnd())
398
+ console.log(await runInTab(client, tab, commandText))
268
399
  break
269
400
  }
270
401
  case 'fleet': {
@@ -274,56 +405,32 @@ async function main() {
274
405
  for (const tab of tabs) {
275
406
  console.log(`── ${tab} ──`)
276
407
  try {
277
- const before = await call(url, 'terminal:getBufferDelta', { terminalId: tab, fromOffset: 0 })
278
- const startOffset = Number(before?.offset ?? 0)
279
- await call(url, 'terminal:write', { terminalId: tab, data: `${commandText}\n` })
280
- let output = ''
281
- let lastOffset = startOffset
282
- let stable = 0
283
- const deadline = Date.now() + 30_000
284
- while (Date.now() < deadline) {
285
- await new Promise((resolve) => setTimeout(resolve, 400))
286
- const delta = await call(url, 'terminal:getBufferDelta', { terminalId: tab, fromOffset: lastOffset })
287
- const data = typeof delta?.data === 'string' ? delta.data : ''
288
- const offset = Number(delta?.offset ?? lastOffset)
289
- if (data) output += data
290
- if (offset === lastOffset && !data) {
291
- stable += 1
292
- if (stable >= 3) break
293
- } else {
294
- stable = 0
295
- }
296
- lastOffset = offset
297
- }
298
- console.log(output.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').trimEnd())
408
+ console.log(await runInTab(client, tab, commandText))
299
409
  } catch (error) {
300
- console.log(`Error: ${error instanceof Error ? error.message : String(error)}`)
410
+ console.log(`Error: ${errorMessage(error)}`)
301
411
  process.exitCode = 2
302
412
  }
303
413
  }
304
414
  break
305
415
  }
306
416
  case 'sessions': {
307
- const result = await call(url, 'session:list')
308
- printJson(result)
417
+ printJson(await client.call('session:list'))
309
418
  break
310
419
  }
311
420
  case 'chat': {
312
421
  const sessionId = positional[1]
313
422
  const message = positional.slice(2).join(' ')
314
423
  if (!sessionId || !message) fail('chat needs: rterm chat <sessionId> <message>')
315
- const result = await call(url, 'agent:startTask', { sessionId, userInput: message })
316
- printJson(result)
424
+ printJson(await client.call('agent:startTask', { sessionId, userInput: message }))
317
425
  break
318
426
  }
319
427
  case 'dashboard': {
320
- const result = await call(url, 'observability:liveDashboardState')
321
- printJson(result)
428
+ printJson(await client.call('observability:liveDashboardState'))
322
429
  break
323
430
  }
324
431
  case 'metrics': {
325
432
  const format = flags.format === 'prometheus' ? 'prometheus' : 'summary'
326
- const result = await call(url, 'observability:metricsPrometheus', { format })
433
+ const result = await client.call('observability:metricsPrometheus', { format })
327
434
  console.log(typeof result === 'string' ? result : JSON.stringify(result, null, 2))
328
435
  break
329
436
  }
@@ -331,7 +438,7 @@ async function main() {
331
438
  fail(`Unknown command: ${command}. Run "rterm help".`)
332
439
  }
333
440
  } catch (error) {
334
- fail(error instanceof Error ? error.message : String(error))
441
+ fail(error)
335
442
  }
336
443
  }
337
444