rterm-cli 3.2.9

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 +61 -0
  2. package/package.json +34 -0
  3. package/rterm-cli.mjs +338 -0
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # rterm CLI
2
+
3
+ A `gyll`-style command CLI for the RTerm / neuralOS backend. Speaks the backend's
4
+ WebSocket JSON-RPC gateway (`ws://host:17888`) natively — **zero dependencies**.
5
+
6
+ ```bash
7
+ # no install (Node >= 21)
8
+ npx rterm-cli ping
9
+
10
+ # global install
11
+ npm install -g rterm-cli
12
+ rterm ping
13
+ ```
14
+
15
+ Requires a running backend (`gybackend` from `npm i -g neuralos`, or the RTerm
16
+ desktop app) on `ws://127.0.0.1:17888`.
17
+
18
+ ## Install / run
19
+
20
+ ```bash
21
+ # from a checkout
22
+ node apps/cli/rterm-cli.mjs ping
23
+
24
+ # link it
25
+ npm --workspace @rterm/cli run build 2>/dev/null || true
26
+ ln -s "$(pwd)/apps/cli/rterm-cli.mjs" /usr/local/bin/rterm
27
+ ```
28
+
29
+ ## Commands
30
+
31
+ ```bash
32
+ rterm ping # liveness check
33
+ rterm version # backend version + method count
34
+ rterm methods [--category terminal] # self-describing RPC surface
35
+ rterm call <method> [json-params] # raw JSON-RPC call
36
+ rterm terminals # list terminal tabs
37
+ rterm open <saved-connection-name> # open a tab for a saved connection
38
+ rterm close <tabIdOrName> # close a terminal tab
39
+ rterm run <tabIdOrName> <command> # run a command in a tab (waits)
40
+ rterm fleet <tab1,tab2,...> <command> # run on many tabs at once
41
+ rterm sessions # list chat sessions
42
+ rterm chat <sessionId> <message> # send a message to the agent (blocking)
43
+ rterm dashboard # live dashboard state
44
+ rterm metrics [--format prometheus] # host metrics
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ | Env | Default | Meaning |
50
+ |---|---|---|
51
+ | `RTERM_URL` | `ws://127.0.0.1:17888` | Gateway URL |
52
+ | `RTERM_HOST` / `RTERM_PORT` | `127.0.0.1` / `17888` | Build the URL if `RTERM_URL` is unset |
53
+ | `RTERM_TOKEN` | — | Access token (required for non-localhost gateways) |
54
+
55
+ The CLI also auto-loads the first token from
56
+ `~/.gybackend-data/access-tokens.json` when present.
57
+
58
+ ## Node version
59
+
60
+ Uses the native `WebSocket` client (Node ≥ 21). On older Node it falls back to
61
+ the `ws` package when resolvable.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "rterm-cli",
3
+ "version": "3.2.9",
4
+ "description": "rterm — zero-dependency command CLI for the RTerm / neuralOS backend WebSocket gateway (ping, terminals, run, fleet, chat, dashboard, metrics, raw call)",
5
+ "type": "module",
6
+ "bin": {
7
+ "rterm": "./rterm-cli.mjs",
8
+ "rterm-cli": "./rterm-cli.mjs"
9
+ },
10
+ "files": [
11
+ "rterm-cli.mjs",
12
+ "README.md"
13
+ ],
14
+ "keywords": [
15
+ "rterm",
16
+ "neuralos",
17
+ "terminal",
18
+ "ssh",
19
+ "cli",
20
+ "websocket",
21
+ "json-rpc",
22
+ "devops",
23
+ "remote"
24
+ ],
25
+ "license": "MIT",
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/DrOlu/RTerm.git",
32
+ "directory": "apps/cli"
33
+ }
34
+ }
package/rterm-cli.mjs ADDED
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * rterm-cli — a `gyll`-style command CLI for the RTerm / neuralOS backend.
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>
17
+ *
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.
20
+ */
21
+
22
+ import { readFileSync, existsSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { join } from 'node:path'
25
+
26
+ const DEFAULT_HOST = process.env.RTERM_HOST || '127.0.0.1'
27
+ const DEFAULT_PORT = Number(process.env.RTERM_PORT || 17888)
28
+ const DEFAULT_URL = process.env.RTERM_URL || `ws://${DEFAULT_HOST}:${DEFAULT_PORT}`
29
+
30
+ // ── tiny arg parser ─────────────────────────────────────────────────────────
31
+
32
+ function parseArgs(argv) {
33
+ const positional = []
34
+ const flags = {}
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const arg = argv[i]
37
+ if (arg.startsWith('--')) {
38
+ const key = arg.slice(2)
39
+ const next = argv[i + 1]
40
+ if (next !== undefined && !next.startsWith('--')) {
41
+ flags[key] = next
42
+ i++
43
+ } else {
44
+ flags[key] = true
45
+ }
46
+ } else {
47
+ positional.push(arg)
48
+ }
49
+ }
50
+ return { positional, flags }
51
+ }
52
+
53
+ // ── gateway client ──────────────────────────────────────────────────────────
54
+
55
+ let nextId = 1
56
+
57
+ function loadToken() {
58
+ if (process.env.RTERM_TOKEN) return process.env.RTERM_TOKEN
59
+ const candidates = [
60
+ join(homedir(), '.gybackend-data', 'access-tokens.json'),
61
+ join(process.cwd(), '.gybackend-data', 'access-tokens.json'),
62
+ ]
63
+ for (const path of candidates) {
64
+ if (existsSync(path)) {
65
+ try {
66
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
67
+ const first = Array.isArray(parsed) ? parsed[0] : parsed
68
+ if (first && typeof first.token === 'string') return first.token
69
+ if (typeof parsed === 'string') return parsed
70
+ } catch { /* ignore malformed */ }
71
+ }
72
+ }
73
+ return null
74
+ }
75
+
76
+ async function openSocket(url) {
77
+ // Node >= 21 ships a native WebSocket client.
78
+ if (typeof globalThis.WebSocket === 'function') {
79
+ return await new Promise((resolve, reject) => {
80
+ const ws = new globalThis.WebSocket(url)
81
+ ws.onopen = () => resolve(ws)
82
+ ws.onerror = () => reject(new Error(`Cannot connect to ${url}. Is the backend running? (gybackend)`))
83
+ })
84
+ }
85
+ // Fallback: the `ws` package (present in a backend checkout/install).
86
+ try {
87
+ const { createRequire } = await import('node:module')
88
+ const require = createRequire(import.meta.url)
89
+ const WS = require('ws')
90
+ return await new Promise((resolve, reject) => {
91
+ const ws = new WS(url)
92
+ ws.on('open', () => resolve(ws))
93
+ ws.on('error', () => reject(new Error(`Cannot connect to ${url}. Is the backend running? (gybackend)`)))
94
+ })
95
+ } catch {
96
+ throw new Error('No WebSocket client available. Use Node >= 21 or install the `ws` package.')
97
+ }
98
+ }
99
+
100
+ async function call(url, method, params) {
101
+ const ws = await openSocket(url)
102
+ const id = String(nextId++)
103
+ return await new Promise((resolve, reject) => {
104
+ const timer = setTimeout(() => {
105
+ try { ws.close() } catch { /* ignore */ }
106
+ reject(new Error(`Timeout calling ${method} (60s)`))
107
+ }, 60_000)
108
+ const onMessage = (raw) => {
109
+ try {
110
+ const frame = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
111
+ if (frame.type === 'gateway:response' && frame.id === id) {
112
+ clearTimeout(timer)
113
+ try { ws.close() } catch { /* ignore */ }
114
+ if (frame.ok) resolve(frame.result)
115
+ else reject(new Error(frame.error || `gateway error: ${method}`))
116
+ }
117
+ } catch { /* ignore non-JSON frames */ }
118
+ }
119
+ const onClose = () => { clearTimeout(timer); reject(new Error('Connection closed before response.')) }
120
+
121
+ // Normalize the native WebSocket (addEventListener) and the `ws` package
122
+ // (on/on('message')) behind one interface.
123
+ if (typeof ws.addEventListener === 'function') {
124
+ ws.addEventListener('message', (event) => onMessage(event.data))
125
+ ws.addEventListener('close', onClose)
126
+ ws.addEventListener('error', onClose)
127
+ } else if (typeof ws.on === 'function') {
128
+ ws.on('message', (data) => onMessage(data))
129
+ ws.on('close', onClose)
130
+ ws.on('error', onClose)
131
+ } else {
132
+ ws.onmessage = (event) => onMessage(event.data)
133
+ ws.onclose = onClose
134
+ }
135
+
136
+ const payload = { id, method, ...(params !== undefined ? { params } : {}) }
137
+ ws.send(JSON.stringify(payload))
138
+ })
139
+ }
140
+
141
+ // ── output helpers ──────────────────────────────────────────────────────────
142
+
143
+ function printJson(value) {
144
+ console.log(JSON.stringify(value, null, 2))
145
+ }
146
+
147
+ function fail(message) {
148
+ console.error(`Error: ${message}`)
149
+ process.exit(1)
150
+ }
151
+
152
+ const HELP = `rterm — command CLI for the RTerm / neuralOS backend gateway
153
+
154
+ Usage:
155
+ rterm ping Liveness check
156
+ rterm methods [--category c] [--prefix p] List gateway RPC methods
157
+ rterm call <method> [json] Raw JSON-RPC call (params as JSON)
158
+ rterm terminals List terminal tabs
159
+ rterm open <connection-name> Open a terminal tab for a saved connection
160
+ rterm close <tabIdOrName> Close a terminal tab
161
+ rterm run <tabIdOrName> <command> Run a command in a terminal tab (waits)
162
+ rterm fleet <tab1,tab2,...> <command> Run a command on many tabs at once
163
+ rterm sessions List chat sessions
164
+ rterm chat <sessionId> <message> Send a message to the agent (blocking)
165
+ rterm dashboard Print the live dashboard state
166
+ rterm metrics [--format prometheus] Host metrics
167
+ rterm version Backend version + method count
168
+
169
+ Options:
170
+ --url ws://host:port Gateway URL (default ${DEFAULT_URL}, env RTERM_URL)
171
+ --token <token> Access token (env RTERM_TOKEN; non-localhost requires one)
172
+
173
+ Environment:
174
+ RTERM_URL, RTERM_HOST, RTERM_PORT, RTERM_TOKEN
175
+ `
176
+
177
+ // ── commands ────────────────────────────────────────────────────────────────
178
+
179
+ async function main() {
180
+ const argv = process.argv.slice(2)
181
+ const { positional, flags } = parseArgs(argv)
182
+ const url = (typeof flags.url === 'string' && flags.url) || DEFAULT_URL
183
+ const command = positional[0]
184
+
185
+ if (!command || command === 'help' || flags.help) {
186
+ console.log(HELP)
187
+ process.exit(0)
188
+ }
189
+
190
+ try {
191
+ switch (command) {
192
+ case 'ping': {
193
+ const result = await call(url, 'gateway:ping')
194
+ printJson(result)
195
+ break
196
+ }
197
+ case 'version': {
198
+ const result = await call(url, 'gateway:describe')
199
+ printJson({ version: result.version, methodCount: result.count, categories: result.categories })
200
+ break
201
+ }
202
+ case 'methods': {
203
+ const params = {}
204
+ if (typeof flags.category === 'string') params.category = flags.category
205
+ if (typeof flags.prefix === 'string') params.prefix = flags.prefix
206
+ const result = await call(url, 'gateway:describe', params)
207
+ printJson(result.methods)
208
+ break
209
+ }
210
+ case 'call': {
211
+ const method = positional[1]
212
+ if (!method) fail('call needs a method name: rterm call <method> [json-params]')
213
+ let params
214
+ if (positional[2]) {
215
+ try { params = JSON.parse(positional[2]) } catch { fail('params must be valid JSON') }
216
+ }
217
+ const result = await call(url, method, params)
218
+ printJson(result)
219
+ break
220
+ }
221
+ case 'terminals': {
222
+ const result = await call(url, 'terminal:list')
223
+ printJson(result)
224
+ break
225
+ }
226
+ case 'open': {
227
+ const name = positional[1]
228
+ 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)
231
+ break
232
+ }
233
+ case 'close': {
234
+ const tab = positional[1]
235
+ 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)
238
+ break
239
+ }
240
+ case 'run': {
241
+ const tab = positional[1]
242
+ const commandText = positional.slice(2).join(' ')
243
+ 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())
268
+ break
269
+ }
270
+ case 'fleet': {
271
+ const tabs = (positional[1] || '').split(',').map((s) => s.trim()).filter(Boolean)
272
+ const commandText = positional.slice(2).join(' ')
273
+ if (tabs.length === 0 || !commandText) fail('fleet needs: rterm fleet <tab1,tab2,...> <command>')
274
+ for (const tab of tabs) {
275
+ console.log(`── ${tab} ──`)
276
+ 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())
299
+ } catch (error) {
300
+ console.log(`Error: ${error instanceof Error ? error.message : String(error)}`)
301
+ process.exitCode = 2
302
+ }
303
+ }
304
+ break
305
+ }
306
+ case 'sessions': {
307
+ const result = await call(url, 'session:list')
308
+ printJson(result)
309
+ break
310
+ }
311
+ case 'chat': {
312
+ const sessionId = positional[1]
313
+ const message = positional.slice(2).join(' ')
314
+ if (!sessionId || !message) fail('chat needs: rterm chat <sessionId> <message>')
315
+ const result = await call(url, 'agent:startTask', { sessionId, userInput: message })
316
+ printJson(result)
317
+ break
318
+ }
319
+ case 'dashboard': {
320
+ const result = await call(url, 'observability:liveDashboardState')
321
+ printJson(result)
322
+ break
323
+ }
324
+ case 'metrics': {
325
+ const format = flags.format === 'prometheus' ? 'prometheus' : 'summary'
326
+ const result = await call(url, 'observability:metricsPrometheus', { format })
327
+ console.log(typeof result === 'string' ? result : JSON.stringify(result, null, 2))
328
+ break
329
+ }
330
+ default:
331
+ fail(`Unknown command: ${command}. Run "rterm help".`)
332
+ }
333
+ } catch (error) {
334
+ fail(error instanceof Error ? error.message : String(error))
335
+ }
336
+ }
337
+
338
+ main()