@rayrun/cli 0.1.0 → 0.3.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.
package/src/main.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { clientDefinitions, detectClients } from './clients.js';
2
+ import { executionUsage, isExecutionCommand, runExecutionCommand } from './execution.js';
3
+ import { isManagementCommand, managementUsage, runManagementCommand } from './management.js';
2
4
  import { applySetup, rollbackSetup } from './setup.js';
3
5
  import { createRequire } from 'node:module';
4
6
  import { createInterface } from 'node:readline/promises';
@@ -9,7 +11,11 @@ const usage = `Usage:
9
11
  rayrun setup <endpoint> [--client <id> ... | --all] [--project] [--yes] [--dry-run] [--login | --no-login]
10
12
  rayrun setup rollback <backup-id>
11
13
 
12
- Clients: claude-code, codex, cursor, vscode, windsurf, devin, opencode`;
14
+ Clients: claude-code, codex, cursor, vscode, windsurf, devin, opencode
15
+
16
+ ${executionUsage}
17
+
18
+ ${managementUsage}`;
13
19
 
14
20
  export const validateEndpoint = (value) => {
15
21
  let endpoint;
@@ -97,7 +103,7 @@ const selectInteractively = async (clients) => {
97
103
  return clients.filter((_client, index) => indexes.has(index));
98
104
  };
99
105
 
100
- export const main = async (arguments_) => {
106
+ export const main = async (arguments_, dependencies) => {
101
107
  if (arguments_.includes('--help') || arguments_.includes('-h')) {
102
108
  process.stdout.write(`${usage}\n`);
103
109
  return;
@@ -106,6 +112,14 @@ export const main = async (arguments_) => {
106
112
  process.stdout.write(`${String(packageVersion)}\n`);
107
113
  return;
108
114
  }
115
+ if (isExecutionCommand(arguments_)) {
116
+ await runExecutionCommand(arguments_, { ...dependencies, version: packageVersion });
117
+ return;
118
+ }
119
+ if (isManagementCommand(arguments_[0])) {
120
+ await runManagementCommand(arguments_, dependencies);
121
+ return;
122
+ }
109
123
  const { options, positional } = parseArguments(arguments_);
110
124
 
111
125
  if (positional[0] !== 'setup') {
@@ -0,0 +1,377 @@
1
+ /* eslint-disable node/no-process-env -- management intentionally reads the invoking user's Rayrun environment */
2
+ import { Rayrun, RayrunApiError } from '@rayrun/sdk';
3
+ import { stripVTControlCharacters } from 'node:util';
4
+
5
+ export const managementUsage = `Management:
6
+ rayrun connect mcp <url> --name <name> [--transport <streamable-http|sse>] [--json]
7
+ rayrun connect openapi <spec-url> [--name <name>] [--base-url <url>] [--json]
8
+ rayrun connections list [--limit <1-100>] [--cursor <cursor>] [--json]
9
+ rayrun clients list [--limit <1-100>] [--cursor <cursor>] [--json]
10
+ rayrun tools search <query> [--connection <uid>] [--limit <1-100>] [--cursor <cursor>] [--json]
11
+ rayrun policy inspect <client-uid> [--query <query>] [--limit <1-100>] [--cursor <cursor>] [--json]
12
+ rayrun approvals list [--limit <1-100>] [--cursor <cursor>] [--json]
13
+ rayrun activity list [--limit <1-100>] [--cursor <cursor>] [--json]
14
+
15
+ Environment:
16
+ RAYRUN_API_KEY API key created in Dashboard -> Settings -> API keys
17
+ RAYRUN_API_URL Optional API origin (defaults to https://ray.run)`;
18
+
19
+ const managementCommands = new Set([
20
+ 'activity',
21
+ 'approvals',
22
+ 'clients',
23
+ 'connect',
24
+ 'connections',
25
+ 'policy',
26
+ 'tools',
27
+ ]);
28
+
29
+ export const isManagementCommand = (command) => managementCommands.has(command);
30
+
31
+ const optionNames = new Map([
32
+ ['--base-url', 'baseUrl'],
33
+ ['--connection', 'connectionUid'],
34
+ ['--cursor', 'cursor'],
35
+ ['--limit', 'limit'],
36
+ ['--name', 'name'],
37
+ ['--query', 'query'],
38
+ ['--transport', 'transport'],
39
+ ]);
40
+
41
+ const parseCommandArguments = (arguments_, allowedOptions) => {
42
+ const options = { json: false };
43
+ const positional = [];
44
+
45
+ for (let index = 0; index < arguments_.length; index += 1) {
46
+ const argument = arguments_[index];
47
+
48
+ if (argument === '--json') {
49
+ if (!allowedOptions.has('json')) throw new Error(`Unknown option: ${argument}`);
50
+ options.json = true;
51
+ continue;
52
+ }
53
+
54
+ const optionName = optionNames.get(argument);
55
+ if (optionName) {
56
+ if (!allowedOptions.has(optionName)) throw new Error(`Unknown option: ${argument}`);
57
+ const value = arguments_[index + 1];
58
+ if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value.`);
59
+ if (options[optionName] !== undefined) throw new Error(`${argument} can only be used once.`);
60
+ options[optionName] = value;
61
+ index += 1;
62
+ continue;
63
+ }
64
+
65
+ if (argument?.startsWith('--')) throw new Error(`Unknown option: ${argument}`);
66
+ if (argument) positional.push(argument);
67
+ }
68
+
69
+ if (options.limit !== undefined) {
70
+ const limit = Number(options.limit);
71
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
72
+ throw new Error('--limit must be an integer from 1 to 100.');
73
+ }
74
+ options.limit = limit;
75
+ }
76
+
77
+ return { options, positional };
78
+ };
79
+
80
+ export const validateApiBaseUrl = (value) => {
81
+ let url;
82
+
83
+ try {
84
+ url = new URL(value);
85
+ } catch {
86
+ throw new Error('RAYRUN_API_URL must be an absolute HTTP or HTTPS URL.');
87
+ }
88
+
89
+ if (!['http:', 'https:'].includes(url.protocol)) {
90
+ throw new Error('RAYRUN_API_URL must use HTTP or HTTPS.');
91
+ }
92
+ if (url.protocol === 'http:' && !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)) {
93
+ throw new Error('RAYRUN_API_URL may use HTTP only on localhost.');
94
+ }
95
+ if (url.username || url.password || url.search || url.hash) {
96
+ throw new Error('RAYRUN_API_URL cannot contain credentials, a query string, or a fragment.');
97
+ }
98
+
99
+ return url.href.replace(/\/$/u, '');
100
+ };
101
+
102
+ const formatCell = (value) => {
103
+ if (value === null || value === undefined || value === '') return '-';
104
+ if (typeof value === 'boolean') return value ? 'yes' : 'no';
105
+ const sanitized = stripVTControlCharacters(String(value))
106
+ .replaceAll(/\p{Cc}+/gu, ' ')
107
+ .replaceAll(/\s+/gu, ' ')
108
+ .trim();
109
+
110
+ return sanitized || '-';
111
+ };
112
+
113
+ export const renderTable = (items, columns) => {
114
+ if (items.length === 0) return 'No results.\n';
115
+
116
+ const rows = items.map((item) => columns.map(({ value }) => formatCell(value(item))));
117
+ const widths = columns.map(({ label }, index) =>
118
+ Math.max(label.length, ...rows.map((row) => row[index].length)),
119
+ );
120
+ const line = (cells) =>
121
+ cells
122
+ .map((cell, index) => cell.padEnd(widths[index], ' '))
123
+ .join(' ')
124
+ .trimEnd();
125
+
126
+ return `${line(columns.map(({ label }) => label))}\n${line(widths.map((width) => '-'.repeat(width)))}\n${rows.map(line).join('\n')}\n`;
127
+ };
128
+
129
+ const pageQuery = (options, extra = {}) => ({
130
+ ...extra,
131
+ cursor: options.cursor,
132
+ limit: options.limit,
133
+ });
134
+
135
+ const writeValue = (output, value) => {
136
+ output.write(`${JSON.stringify(value, null, 2)}\n`);
137
+ };
138
+
139
+ const writePage = ({ columns, options, output, page }) => {
140
+ if (options.json) {
141
+ writeValue(output, page);
142
+ return;
143
+ }
144
+
145
+ output.write(renderTable(page.items, columns));
146
+ if (page.page.nextCursor) output.write(`Next cursor: ${page.page.nextCursor}\n`);
147
+ };
148
+
149
+ const requireShape = (condition) => {
150
+ if (!condition) throw new Error(managementUsage);
151
+ };
152
+
153
+ const runCommand = async ({ arguments_, client, output }) => {
154
+ const command = arguments_[0];
155
+ const action = arguments_[1];
156
+
157
+ if (command === 'connect' && action === 'mcp') {
158
+ const { options, positional } = parseCommandArguments(
159
+ arguments_.slice(2),
160
+ new Set(['json', 'name', 'transport']),
161
+ );
162
+ requireShape(positional.length === 1 && typeof options.name === 'string');
163
+ if (options.transport && !['sse', 'streamable-http'].includes(options.transport)) {
164
+ throw new Error('--transport must be streamable-http or sse.');
165
+ }
166
+ const result = await client.connections.create({
167
+ kind: 'mcp',
168
+ name: options.name,
169
+ transport: options.transport,
170
+ url: positional[0],
171
+ });
172
+ if (options.json) writeValue(output, result);
173
+ else output.write(`Connected ${result.connection.uid}.\n`);
174
+ return;
175
+ }
176
+
177
+ if (command === 'connect' && action === 'openapi') {
178
+ const { options, positional } = parseCommandArguments(
179
+ arguments_.slice(2),
180
+ new Set(['baseUrl', 'json', 'name']),
181
+ );
182
+ requireShape(positional.length === 1);
183
+ const result = await client.connections.create({
184
+ baseUrl: options.baseUrl,
185
+ kind: 'openapi',
186
+ name: options.name,
187
+ specificationUrl: positional[0],
188
+ });
189
+ if (options.json) writeValue(output, result);
190
+ else output.write(`Connected ${result.connection.uid}.\n`);
191
+ return;
192
+ }
193
+
194
+ if (command === 'connections' && action === 'list') {
195
+ const { options, positional } = parseCommandArguments(
196
+ arguments_.slice(2),
197
+ new Set(['cursor', 'json', 'limit']),
198
+ );
199
+ requireShape(positional.length === 0);
200
+ const page = await client.connections.list(pageQuery(options));
201
+ writePage({
202
+ columns: [
203
+ { label: 'UID', value: (item) => item.uid },
204
+ { label: 'NAME', value: (item) => item.displayName },
205
+ { label: 'KIND', value: (item) => item.kind },
206
+ { label: 'STATUS', value: (item) => item.status },
207
+ { label: 'POLICY', value: (item) => item.toolAccessMode },
208
+ ],
209
+ options,
210
+ output,
211
+ page,
212
+ });
213
+ return;
214
+ }
215
+
216
+ if (command === 'clients' && action === 'list') {
217
+ const { options, positional } = parseCommandArguments(
218
+ arguments_.slice(2),
219
+ new Set(['cursor', 'json', 'limit']),
220
+ );
221
+ requireShape(positional.length === 0);
222
+ const page = await client.clients.list(pageQuery(options));
223
+ writePage({
224
+ columns: [
225
+ { label: 'UID', value: (item) => item.uid },
226
+ { label: 'CLIENT', value: (item) => item.clientName },
227
+ { label: 'USER', value: (item) => item.userName },
228
+ { label: 'POLICY', value: (item) => item.toolAccessMode },
229
+ { label: 'PROFILE', value: (item) => item.accessProfile?.name },
230
+ { label: 'LAST USED', value: (item) => item.lastUsedAt },
231
+ ],
232
+ options,
233
+ output,
234
+ page,
235
+ });
236
+ return;
237
+ }
238
+
239
+ if (command === 'tools' && action === 'search') {
240
+ const { options, positional } = parseCommandArguments(
241
+ arguments_.slice(2),
242
+ new Set(['connectionUid', 'cursor', 'json', 'limit']),
243
+ );
244
+ const query = positional.join(' ').trim();
245
+ requireShape(query.length > 0);
246
+ const page = await client.tools.list(
247
+ pageQuery(options, { connectionUid: options.connectionUid, query }),
248
+ );
249
+ writePage({
250
+ columns: [
251
+ { label: 'UID', value: (item) => item.uid },
252
+ { label: 'NAME', value: (item) => item.name },
253
+ { label: 'CONNECTION', value: (item) => item.connectionUid },
254
+ { label: 'RISK', value: (item) => item.risk },
255
+ { label: 'APPROVED', value: (item) => item.approved },
256
+ { label: 'RULE', value: (item) => item.rule },
257
+ ],
258
+ options,
259
+ output,
260
+ page,
261
+ });
262
+ return;
263
+ }
264
+
265
+ if (command === 'policy' && action === 'inspect') {
266
+ const { options, positional } = parseCommandArguments(
267
+ arguments_.slice(2),
268
+ new Set(['cursor', 'json', 'limit', 'query']),
269
+ );
270
+ requireShape(positional.length === 1);
271
+ const page = await client.clients.listTools(
272
+ positional[0],
273
+ pageQuery(options, { query: options.query }),
274
+ );
275
+ writePage({
276
+ columns: [
277
+ { label: 'SERVICE', value: (item) => item.serviceName },
278
+ { label: 'TOOL', value: (item) => item.name },
279
+ { label: 'EFFECTIVE', value: (item) => item.effectiveDecision },
280
+ { label: 'REASON', value: (item) => item.effectiveReason },
281
+ { label: 'CLIENT', value: (item) => item.clientDecision },
282
+ { label: 'WORKSPACE', value: (item) => item.workspaceDecision },
283
+ ],
284
+ options,
285
+ output,
286
+ page,
287
+ });
288
+ return;
289
+ }
290
+
291
+ if (command === 'approvals' && action === 'list') {
292
+ const { options, positional } = parseCommandArguments(
293
+ arguments_.slice(2),
294
+ new Set(['cursor', 'json', 'limit']),
295
+ );
296
+ requireShape(positional.length === 0);
297
+ const page = await client.reviews.list(pageQuery(options));
298
+ writePage({
299
+ columns: [
300
+ { label: 'UID', value: (item) => item.uid },
301
+ { label: 'SERVICE', value: (item) => item.connectionName },
302
+ { label: 'TOOL', value: (item) => item.toolName },
303
+ { label: 'STATUS', value: (item) => item.status },
304
+ { label: 'CREATED', value: (item) => item.createdAt },
305
+ { label: 'EXPIRES', value: (item) => item.expiresAt },
306
+ ],
307
+ options,
308
+ output,
309
+ page,
310
+ });
311
+ return;
312
+ }
313
+
314
+ if (command === 'activity' && action === 'list') {
315
+ const { options, positional } = parseCommandArguments(
316
+ arguments_.slice(2),
317
+ new Set(['cursor', 'json', 'limit']),
318
+ );
319
+ requireShape(positional.length === 0);
320
+ const page = await client.activity.list(pageQuery(options));
321
+ writePage({
322
+ columns: [
323
+ { label: 'ID', value: (item) => item.id },
324
+ { label: 'WHEN', value: (item) => item.calledAt },
325
+ { label: 'TOOL', value: (item) => item.toolName },
326
+ { label: 'SERVICE', value: (item) => item.connectionName },
327
+ { label: 'CLIENT', value: (item) => item.clientName },
328
+ { label: 'DECISION', value: (item) => item.policyDecision },
329
+ {
330
+ label: 'RESULT',
331
+ value: (item) =>
332
+ item.succeeded === null
333
+ ? item.errorCode === 'outcome_unknown'
334
+ ? 'outcome unknown'
335
+ : 'pending'
336
+ : item.succeeded
337
+ ? 'success'
338
+ : 'failed',
339
+ },
340
+ ],
341
+ options,
342
+ output,
343
+ page,
344
+ });
345
+ return;
346
+ }
347
+
348
+ throw new Error(managementUsage);
349
+ };
350
+
351
+ export const runManagementCommand = async (
352
+ arguments_,
353
+ {
354
+ environment = process.env,
355
+ fetchImplementation = globalThis.fetch,
356
+ output = process.stdout,
357
+ } = {},
358
+ ) => {
359
+ const apiKey = environment.RAYRUN_API_KEY;
360
+ if (!apiKey?.trim()) {
361
+ throw new Error(
362
+ 'RAYRUN_API_KEY is required. Create a scoped key in Dashboard -> Settings -> API keys.',
363
+ );
364
+ }
365
+ const baseUrl = environment.RAYRUN_API_URL
366
+ ? validateApiBaseUrl(environment.RAYRUN_API_URL)
367
+ : undefined;
368
+ const client = new Rayrun({ apiKey, baseUrl, fetch: fetchImplementation });
369
+
370
+ try {
371
+ await runCommand({ arguments_, client, output });
372
+ } catch (error) {
373
+ if (!(error instanceof RayrunApiError)) throw error;
374
+ const requestId = error.requestId ? `, request ${error.requestId}` : '';
375
+ throw new Error(`${error.message} (${error.code}${requestId})`, { cause: error });
376
+ }
377
+ };