@runneth/cli 0.0.0-sha.19a36f654ef6.production

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/dist/cli.js ADDED
@@ -0,0 +1,1112 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { resolveBuildDefaultMcpResourceUrl } from "./build-defaults.js";
7
+ import { assertRunnethVmCopyTargetsUseSameResource, ensureDaemon, getOAuthAccessToken, installRunnethSshAccess, installRunnethSkills, loginWithOAuth, logoutOAuthCredential, normalizeRunnethSshTargetName, normalizeSessionName, readOAuthCredentialStatus, readRunnethSshTargetStore, removeRunnethSshTarget, resolveRunnethSshTarget, resolveSocketPath, RunnethCliDaemon, runOpenSsh, runRunnethSshProxy, runRunnethVmCopy, saveRunnethSshTarget, sendRunnethCliRequest, setDefaultRunnethSshTarget, } from "./index.js";
8
+ import { runRunnethSshStdio } from "./ssh-stdio.js";
9
+ const DEFAULT_TIMEOUT_MS = 600_000;
10
+ const DEFAULT_OAUTH_CLIENT_NAME = "Runneth MCP";
11
+ const DEFAULT_OAUTH_SCOPE = "openid profile email offline_access";
12
+ const MAX_STDIN_BYTES = 1_048_576;
13
+ const isRecord = (value) => {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ };
16
+ const parseStringField = (record, key) => {
17
+ const value = record[key];
18
+ if (typeof value !== "string" || value.trim().length === 0) {
19
+ throw new Error(`${key} must be a non-empty string`);
20
+ }
21
+ return value;
22
+ };
23
+ const parseOptionalStringField = (record, key) => {
24
+ const value = record[key];
25
+ if (value === undefined) {
26
+ return undefined;
27
+ }
28
+ if (typeof value !== "string" || value.trim().length === 0) {
29
+ throw new Error(`${key} must be a non-empty string`);
30
+ }
31
+ return value;
32
+ };
33
+ const resolveDefaultResourceUrl = () => {
34
+ return resolveBuildDefaultMcpResourceUrl();
35
+ };
36
+ const requireResourceUrl = (resourceUrl, commandDescription) => {
37
+ if (resourceUrl === undefined) {
38
+ throw new Error(`${commandDescription} requires --resource <url> because this CLI build does not include MONDRIAN_API_URL`);
39
+ }
40
+ return resourceUrl;
41
+ };
42
+ const readStdin = async () => {
43
+ const chunks = [];
44
+ let totalBytes = 0;
45
+ for await (const chunk of process.stdin) {
46
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
47
+ totalBytes += buffer.length;
48
+ if (totalBytes > MAX_STDIN_BYTES) {
49
+ throw new Error(`stdin input exceeds ${String(MAX_STDIN_BYTES)} bytes`);
50
+ }
51
+ chunks.push(buffer);
52
+ }
53
+ return Buffer.concat(chunks, totalBytes).toString("utf8");
54
+ };
55
+ const printHelp = () => {
56
+ process.stdout.write([
57
+ "Usage: runneth-cli <command> [options]",
58
+ "",
59
+ "Commands:",
60
+ " open Open or reuse a persistent shell session",
61
+ " send Send a shell command to a persistent session",
62
+ " status Show one session",
63
+ " list List sessions",
64
+ " close Close one session",
65
+ " oauth Authorize against an OAuth-protected MCP resource",
66
+ " ssh Connect to Runneth runtime SSH through OAuth",
67
+ " copy Copy one VM path to another VM through Builder file-share upload",
68
+ " skills Install or update bundled Claude/Codex skills",
69
+ " shutdown Stop the local runneth-cli daemon",
70
+ "",
71
+ "Examples:",
72
+ " runneth-cli open",
73
+ " runneth-cli send 'cd ~/project && pnpm test'",
74
+ " runneth-cli send -- git status --short",
75
+ " printf 'cd ~/project\\npwd\\n' | runneth-cli send --stdin",
76
+ " runneth-cli oauth login",
77
+ " runneth-cli ssh target add primary-vm --host 93c7ca56-debe-4b95-8be2-a873afe72234.app.runneth.com --default",
78
+ " runneth-cli ssh -- 'pwd'",
79
+ " runneth-cli copy --from source-vm --to destination-vm /agent/project /agent/imports/project",
80
+ " runneth-cli skills install --agent all",
81
+ "",
82
+ ].join("\n"));
83
+ };
84
+ const parseNumber = (rawValue, label) => {
85
+ const value = Number(rawValue);
86
+ if (!Number.isInteger(value) || value <= 0) {
87
+ throw new Error(`${label} must be a positive integer`);
88
+ }
89
+ return value;
90
+ };
91
+ const parseArgs = (argv) => {
92
+ const values = [];
93
+ let cwd;
94
+ let name = "default";
95
+ let shell;
96
+ let stdin = false;
97
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
98
+ let parseOptions = true;
99
+ for (let index = 0; index < argv.length; index += 1) {
100
+ const token = argv[index];
101
+ if (parseOptions && token === "--") {
102
+ parseOptions = false;
103
+ continue;
104
+ }
105
+ if (!parseOptions || !token.startsWith("--")) {
106
+ values.push(token);
107
+ continue;
108
+ }
109
+ const readValue = (label) => {
110
+ const value = argv[index + 1];
111
+ if (value === undefined) {
112
+ throw new Error(`Missing value for ${label}`);
113
+ }
114
+ index += 1;
115
+ return value;
116
+ };
117
+ switch (token) {
118
+ case "--cwd": {
119
+ cwd = readValue(token);
120
+ break;
121
+ }
122
+ case "--name": {
123
+ name = normalizeSessionName(readValue(token));
124
+ break;
125
+ }
126
+ case "--shell": {
127
+ shell = readValue(token);
128
+ break;
129
+ }
130
+ case "--stdin": {
131
+ stdin = true;
132
+ break;
133
+ }
134
+ case "--timeout-ms": {
135
+ timeoutMs = parseNumber(readValue(token), token);
136
+ break;
137
+ }
138
+ default: {
139
+ throw new Error(`Unknown option: ${token}`);
140
+ }
141
+ }
142
+ }
143
+ return {
144
+ cwd,
145
+ name,
146
+ shell,
147
+ stdin,
148
+ timeoutMs,
149
+ values,
150
+ };
151
+ };
152
+ const parseSkillAgent = (value) => {
153
+ if (value === "all") {
154
+ return ["claude", "codex"];
155
+ }
156
+ if (value === "claude" || value === "codex") {
157
+ return [value];
158
+ }
159
+ throw new Error(`Invalid skill agent: ${value}. Use claude, codex, or all.`);
160
+ };
161
+ const parseSkillsArgs = (argv) => {
162
+ let agents = ["claude", "codex"];
163
+ const values = [];
164
+ for (let index = 0; index < argv.length; index += 1) {
165
+ const token = argv[index];
166
+ if (token === "--agent") {
167
+ const value = argv[index + 1];
168
+ if (value === undefined) {
169
+ throw new Error("Missing value for --agent");
170
+ }
171
+ agents = parseSkillAgent(value);
172
+ index += 1;
173
+ continue;
174
+ }
175
+ values.push(token);
176
+ }
177
+ if (values.length > 0) {
178
+ throw new Error("runneth-cli skills install only accepts --agent");
179
+ }
180
+ return {
181
+ agents,
182
+ };
183
+ };
184
+ const parseOAuthArgs = (argv) => {
185
+ const values = [];
186
+ let clientName = DEFAULT_OAUTH_CLIENT_NAME;
187
+ let openBrowser = true;
188
+ let resourceUrl;
189
+ let scope = DEFAULT_OAUTH_SCOPE;
190
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
191
+ let parseOptions = true;
192
+ for (let index = 0; index < argv.length; index += 1) {
193
+ const token = argv[index];
194
+ if (parseOptions && token === "--") {
195
+ parseOptions = false;
196
+ continue;
197
+ }
198
+ if (!parseOptions || !token.startsWith("--")) {
199
+ values.push(token);
200
+ continue;
201
+ }
202
+ const readValue = (label) => {
203
+ const value = argv[index + 1];
204
+ if (value === undefined) {
205
+ throw new Error(`Missing value for ${label}`);
206
+ }
207
+ index += 1;
208
+ return value;
209
+ };
210
+ switch (token) {
211
+ case "--client-name": {
212
+ clientName = readValue(token);
213
+ break;
214
+ }
215
+ case "--no-open": {
216
+ openBrowser = false;
217
+ break;
218
+ }
219
+ case "--resource": {
220
+ resourceUrl = readValue(token);
221
+ break;
222
+ }
223
+ case "--scope": {
224
+ scope = readValue(token);
225
+ break;
226
+ }
227
+ case "--timeout-ms": {
228
+ timeoutMs = parseNumber(readValue(token), token);
229
+ break;
230
+ }
231
+ default: {
232
+ throw new Error(`Unknown option: ${token}`);
233
+ }
234
+ }
235
+ }
236
+ return {
237
+ clientName,
238
+ openBrowser,
239
+ resourceUrl,
240
+ scope,
241
+ timeoutMs,
242
+ values,
243
+ };
244
+ };
245
+ const parseSshArgs = (argv) => {
246
+ const values = [];
247
+ let clientName = DEFAULT_OAUTH_CLIENT_NAME;
248
+ let identityFilePath;
249
+ let makeDefault = false;
250
+ let openBrowser = true;
251
+ let resourceUrl;
252
+ let scope = DEFAULT_OAUTH_SCOPE;
253
+ let sshHost;
254
+ let sshUrl;
255
+ let targetName;
256
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
257
+ let uniqueKey = false;
258
+ let argumentSeparatorSeen = false;
259
+ let parseOptions = true;
260
+ for (let index = 0; index < argv.length; index += 1) {
261
+ const token = argv[index];
262
+ if (parseOptions && token === "--") {
263
+ argumentSeparatorSeen = true;
264
+ parseOptions = false;
265
+ continue;
266
+ }
267
+ if (!parseOptions || !token.startsWith("--")) {
268
+ values.push(token);
269
+ continue;
270
+ }
271
+ const readValue = (label) => {
272
+ const value = argv[index + 1];
273
+ if (value === undefined) {
274
+ throw new Error(`Missing value for ${label}`);
275
+ }
276
+ index += 1;
277
+ return value;
278
+ };
279
+ switch (token) {
280
+ case "--client-name": {
281
+ clientName = readValue(token);
282
+ break;
283
+ }
284
+ case "--default": {
285
+ makeDefault = true;
286
+ break;
287
+ }
288
+ case "--host": {
289
+ sshHost = readValue(token);
290
+ break;
291
+ }
292
+ case "--identity-file": {
293
+ identityFilePath = readValue(token);
294
+ break;
295
+ }
296
+ case "--no-open": {
297
+ openBrowser = false;
298
+ break;
299
+ }
300
+ case "--resource": {
301
+ resourceUrl = readValue(token);
302
+ break;
303
+ }
304
+ case "--scope": {
305
+ scope = readValue(token);
306
+ break;
307
+ }
308
+ case "--ssh-url": {
309
+ sshUrl = readValue(token);
310
+ break;
311
+ }
312
+ case "--target": {
313
+ targetName = readValue(token);
314
+ break;
315
+ }
316
+ case "--timeout-ms": {
317
+ timeoutMs = parseNumber(readValue(token), token);
318
+ break;
319
+ }
320
+ case "--unique-key": {
321
+ uniqueKey = true;
322
+ break;
323
+ }
324
+ default: {
325
+ throw new Error(`Unknown option: ${token}`);
326
+ }
327
+ }
328
+ }
329
+ return {
330
+ argumentSeparatorSeen,
331
+ clientName,
332
+ ...(identityFilePath === undefined ? {} : { identityFilePath }),
333
+ makeDefault,
334
+ openBrowser,
335
+ resourceUrl,
336
+ scope,
337
+ ...(sshHost === undefined ? {} : { sshHost }),
338
+ sshUrl,
339
+ ...(targetName === undefined ? {} : { targetName }),
340
+ timeoutMs,
341
+ uniqueKey,
342
+ values,
343
+ };
344
+ };
345
+ const parseCopyArgs = (argv) => {
346
+ const values = [];
347
+ let clientName = DEFAULT_OAUTH_CLIENT_NAME;
348
+ let destinationTargetName;
349
+ const ignoredPaths = [];
350
+ let openBrowser = true;
351
+ let scope = DEFAULT_OAUTH_SCOPE;
352
+ let sourceTargetName;
353
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
354
+ let parseOptions = true;
355
+ for (let index = 0; index < argv.length; index += 1) {
356
+ const token = argv[index];
357
+ if (parseOptions && token === "--") {
358
+ parseOptions = false;
359
+ continue;
360
+ }
361
+ if (!parseOptions || !token.startsWith("--")) {
362
+ values.push(token);
363
+ continue;
364
+ }
365
+ const readValue = (label) => {
366
+ const value = argv[index + 1];
367
+ if (value === undefined) {
368
+ throw new Error(`Missing value for ${label}`);
369
+ }
370
+ index += 1;
371
+ return value;
372
+ };
373
+ switch (token) {
374
+ case "--client-name": {
375
+ clientName = readValue(token);
376
+ break;
377
+ }
378
+ case "--from": {
379
+ sourceTargetName = normalizeRunnethSshTargetName(readValue(token));
380
+ break;
381
+ }
382
+ case "--ignore": {
383
+ ignoredPaths.push(readValue(token));
384
+ break;
385
+ }
386
+ case "--no-open": {
387
+ openBrowser = false;
388
+ break;
389
+ }
390
+ case "--scope": {
391
+ scope = readValue(token);
392
+ break;
393
+ }
394
+ case "--timeout-ms": {
395
+ timeoutMs = parseNumber(readValue(token), token);
396
+ break;
397
+ }
398
+ case "--to": {
399
+ destinationTargetName = normalizeRunnethSshTargetName(readValue(token));
400
+ break;
401
+ }
402
+ default: {
403
+ throw new Error(`Unknown option: ${token}`);
404
+ }
405
+ }
406
+ }
407
+ if (sourceTargetName === undefined) {
408
+ throw new Error("runneth-cli copy requires --from <target>");
409
+ }
410
+ if (destinationTargetName === undefined) {
411
+ throw new Error("runneth-cli copy requires --to <target>");
412
+ }
413
+ return {
414
+ clientName,
415
+ destinationTargetName,
416
+ ignoredPaths,
417
+ openBrowser,
418
+ scope,
419
+ sourceTargetName,
420
+ timeoutMs,
421
+ values,
422
+ };
423
+ };
424
+ const resolveOAuthResourceUrl = (parsed) => {
425
+ return requireResourceUrl(parsed.resourceUrl ?? parsed.values[0] ?? resolveDefaultResourceUrl(), "runneth-cli oauth");
426
+ };
427
+ const normalizeSshHost = (value) => {
428
+ const sshHost = value.trim();
429
+ if (sshHost.length === 0 ||
430
+ sshHost.includes("/") ||
431
+ sshHost.includes("?") ||
432
+ sshHost.includes("#") ||
433
+ sshHost.includes("@") ||
434
+ /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(sshHost) ||
435
+ /\s/u.test(sshHost)) {
436
+ throw new Error("SSH host must be a full hostname without scheme or path");
437
+ }
438
+ const url = new URL(`https://${sshHost}/runneth/ssh`);
439
+ if (url.hostname.length === 0) {
440
+ throw new Error("SSH host must be a full hostname without scheme or path");
441
+ }
442
+ return url.host;
443
+ };
444
+ const resolveSshUrlFromHost = (host) => {
445
+ return `https://${normalizeSshHost(host)}/runneth/ssh`;
446
+ };
447
+ const resolveSshUrlOption = (parsed) => {
448
+ if (parsed.sshUrl !== undefined && parsed.sshHost !== undefined) {
449
+ throw new Error("Use either --ssh-url or --host, not both");
450
+ }
451
+ if (parsed.sshHost === undefined) {
452
+ return parsed.sshUrl;
453
+ }
454
+ return resolveSshUrlFromHost(parsed.sshHost);
455
+ };
456
+ const resolveSshResourceUrlOption = (parsed) => {
457
+ if (parsed.resourceUrl !== undefined) {
458
+ return parsed.resourceUrl;
459
+ }
460
+ if (parsed.sshHost !== undefined || parsed.sshUrl !== undefined) {
461
+ return resolveDefaultResourceUrl();
462
+ }
463
+ return undefined;
464
+ };
465
+ const resolveSshTargetForCommand = async (parsed) => {
466
+ const sshUrl = resolveSshUrlOption(parsed);
467
+ const resourceUrl = resolveSshResourceUrlOption(parsed);
468
+ if (sshUrl !== undefined && resourceUrl === undefined) {
469
+ requireResourceUrl(resourceUrl, "runneth-cli ssh");
470
+ }
471
+ return await resolveRunnethSshTarget({
472
+ resourceUrl,
473
+ sshUrl,
474
+ targetName: parsed.targetName,
475
+ });
476
+ };
477
+ const assertNoSshTargetDefaultFlag = (parsed) => {
478
+ if (parsed.makeDefault) {
479
+ throw new Error("--default is only valid for runneth-cli ssh target add");
480
+ }
481
+ };
482
+ const assertSshRemoteCommandSeparator = (parsed) => {
483
+ if (parsed.values.length > 0 && !parsed.argumentSeparatorSeen) {
484
+ throw new Error("Remote SSH commands must follow --");
485
+ }
486
+ };
487
+ const assertNoSshKeyOptions = (parsed, commandDescription) => {
488
+ if (parsed.identityFilePath !== undefined || parsed.uniqueKey) {
489
+ throw new Error(`${commandDescription} does not accept SSH key options`);
490
+ }
491
+ };
492
+ const resolveSshKeyInstallOptions = (parsed) => {
493
+ if (parsed.identityFilePath !== undefined && parsed.uniqueKey) {
494
+ throw new Error("Use either --identity-file or --unique-key, not both");
495
+ }
496
+ if (parsed.identityFilePath !== undefined) {
497
+ return { identityFilePath: parsed.identityFilePath };
498
+ }
499
+ if (parsed.uniqueKey) {
500
+ return { keyMode: "unique" };
501
+ }
502
+ return {};
503
+ };
504
+ const parseSshTargetImportEntry = (payload, index) => {
505
+ if (!isRecord(payload)) {
506
+ throw new Error(`targets[${String(index)}] must be a JSON object`);
507
+ }
508
+ const name = normalizeRunnethSshTargetName(parseStringField(payload, "name"));
509
+ const host = parseOptionalStringField(payload, "host");
510
+ const resourceUrl = parseOptionalStringField(payload, "resourceUrl");
511
+ const sshUrl = parseOptionalStringField(payload, "sshUrl");
512
+ if ((host === undefined) === (sshUrl === undefined)) {
513
+ throw new Error(`targets[${String(index)}] requires exactly one of host or sshUrl`);
514
+ }
515
+ return {
516
+ ...(host === undefined ? {} : { host }),
517
+ name,
518
+ ...(resourceUrl === undefined ? {} : { resourceUrl }),
519
+ ...(sshUrl === undefined ? {} : { sshUrl }),
520
+ };
521
+ };
522
+ const parseSshTargetImportFile = (payload) => {
523
+ if (!isRecord(payload)) {
524
+ throw new Error("SSH target import file must be a JSON object");
525
+ }
526
+ const rawTargets = payload.targets;
527
+ if (!Array.isArray(rawTargets) || rawTargets.length === 0) {
528
+ throw new Error("SSH target import file requires a non-empty targets array");
529
+ }
530
+ const defaultTargetValue = parseOptionalStringField(payload, "defaultTarget");
531
+ const resourceUrl = parseOptionalStringField(payload, "resourceUrl");
532
+ return {
533
+ ...(defaultTargetValue === undefined
534
+ ? {}
535
+ : { defaultTarget: normalizeRunnethSshTargetName(defaultTargetValue) }),
536
+ ...(resourceUrl === undefined ? {} : { resourceUrl }),
537
+ targets: rawTargets.map((entry, index) => parseSshTargetImportEntry(entry, index)),
538
+ };
539
+ };
540
+ const readSshTargetImportFile = async (filePath) => {
541
+ const resolvedPath = path.resolve(filePath);
542
+ return parseSshTargetImportFile(JSON.parse(await readFile(resolvedPath, "utf8")));
543
+ };
544
+ const resolveImportEntryResourceUrl = (input) => {
545
+ return requireResourceUrl(input.entry.resourceUrl ??
546
+ input.fileResourceUrl ??
547
+ input.commandResourceUrl ??
548
+ resolveDefaultResourceUrl(), "runneth-cli ssh target import");
549
+ };
550
+ const resolveImportEntrySshUrl = (entry) => {
551
+ if (entry.sshUrl !== undefined) {
552
+ return entry.sshUrl;
553
+ }
554
+ if (entry.host === undefined) {
555
+ throw new Error(`Imported SSH target is missing host: ${entry.name}`);
556
+ }
557
+ return resolveSshUrlFromHost(entry.host);
558
+ };
559
+ const resolveSshTargetImportEntries = (input) => {
560
+ const names = new Set();
561
+ for (const target of input.file.targets) {
562
+ if (names.has(target.name)) {
563
+ throw new Error(`Duplicate SSH target in import file: ${target.name}`);
564
+ }
565
+ names.add(target.name);
566
+ }
567
+ if (input.file.defaultTarget !== undefined &&
568
+ !names.has(input.file.defaultTarget)) {
569
+ throw new Error(`Import defaultTarget does not match an imported target: ${input.file.defaultTarget}`);
570
+ }
571
+ return input.file.targets.map((entry, index) => {
572
+ return {
573
+ makeDefault: entry.name === input.file.defaultTarget ||
574
+ (input.file.defaultTarget === undefined &&
575
+ input.makeFirstDefault &&
576
+ index === 0),
577
+ name: entry.name,
578
+ resourceUrl: resolveImportEntryResourceUrl({
579
+ entry,
580
+ ...(input.commandResourceUrl === undefined
581
+ ? {}
582
+ : { commandResourceUrl: input.commandResourceUrl }),
583
+ ...(input.file.resourceUrl === undefined
584
+ ? {}
585
+ : { fileResourceUrl: input.file.resourceUrl }),
586
+ }),
587
+ sshUrl: resolveImportEntrySshUrl(entry),
588
+ };
589
+ });
590
+ };
591
+ const resolveSshOAuthToken = async (parsed, resourceUrl) => {
592
+ const status = await readOAuthCredentialStatus({ resourceUrl });
593
+ if (status.authenticated) {
594
+ return await getOAuthAccessToken({ resourceUrl });
595
+ }
596
+ return await loginWithOAuth({
597
+ authorizationUrlHandler: (authorizationUrl) => {
598
+ process.stderr.write(`Authorize runneth-cli here:\n${authorizationUrl}\n`);
599
+ },
600
+ clientName: parsed.clientName,
601
+ openBrowser: parsed.openBrowser,
602
+ resourceUrl,
603
+ scope: parsed.scope,
604
+ timeoutMs: parsed.timeoutMs,
605
+ });
606
+ };
607
+ const writeJson = (response) => {
608
+ process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
609
+ };
610
+ const runDaemon = async (argv) => {
611
+ const parsed = parseArgs(argv);
612
+ const socketPath = parsed.values[0] ?? resolveSocketPath();
613
+ const daemon = new RunnethCliDaemon();
614
+ await daemon.start({ socketPath });
615
+ };
616
+ const request = async (payload, timeoutMs = DEFAULT_TIMEOUT_MS) => {
617
+ const socketPath = resolveSocketPath();
618
+ await ensureDaemon({
619
+ cliPath: fileURLToPath(import.meta.url),
620
+ socketPath,
621
+ });
622
+ return await sendRunnethCliRequest({
623
+ request: payload,
624
+ socketPath,
625
+ timeoutMs,
626
+ });
627
+ };
628
+ const run = async (argv) => {
629
+ const command = argv[0];
630
+ if (command === undefined || command === "--help" || command === "-h") {
631
+ printHelp();
632
+ return;
633
+ }
634
+ if (command === "daemon") {
635
+ await runDaemon(argv.slice(1));
636
+ return;
637
+ }
638
+ if (command === "open") {
639
+ const parsed = parseArgs(argv.slice(1));
640
+ writeJson(await request({
641
+ cwd: parsed.cwd ?? process.cwd(),
642
+ name: parsed.name,
643
+ op: "open",
644
+ ...(parsed.shell === undefined ? {} : { shell: parsed.shell }),
645
+ }));
646
+ return;
647
+ }
648
+ if (command === "send") {
649
+ const parsed = parseArgs(argv.slice(1));
650
+ const shellCommand = parsed.stdin
651
+ ? await readStdin()
652
+ : parsed.values.join(" ");
653
+ if (shellCommand.trim().length === 0) {
654
+ throw new Error("runneth-cli send requires a command or --stdin");
655
+ }
656
+ const response = await request({
657
+ command: shellCommand,
658
+ cwd: parsed.cwd ?? process.cwd(),
659
+ name: parsed.name,
660
+ op: "send",
661
+ ...(parsed.shell === undefined ? {} : { shell: parsed.shell }),
662
+ }, parsed.timeoutMs);
663
+ writeJson(response);
664
+ if (response.ok && response.op === "send" && response.exitCode !== 0) {
665
+ process.exitCode = response.exitCode;
666
+ }
667
+ return;
668
+ }
669
+ if (command === "status") {
670
+ const parsed = parseArgs(argv.slice(1));
671
+ writeJson(await request({ name: parsed.name, op: "status" }));
672
+ return;
673
+ }
674
+ if (command === "list") {
675
+ writeJson(await request({ op: "list" }));
676
+ return;
677
+ }
678
+ if (command === "oauth") {
679
+ const subcommand = argv[1];
680
+ if (subcommand === undefined ||
681
+ subcommand === "--help" ||
682
+ subcommand === "-h") {
683
+ process.stdout.write([
684
+ "Usage: runneth-cli oauth <login|status|logout> [--resource <url>] [options]",
685
+ "",
686
+ "Options:",
687
+ " --client-name <name> Dynamic OAuth client name",
688
+ " --no-open Print the authorization URL without opening a browser",
689
+ " --resource <url> OAuth-protected MCP resource URL, defaults to build MONDRIAN_API_URL/mcp",
690
+ " --scope <scope> OAuth scopes",
691
+ " --timeout-ms <ms> Authorization callback timeout",
692
+ "",
693
+ ].join("\n"));
694
+ return;
695
+ }
696
+ const parsed = parseOAuthArgs(argv.slice(2));
697
+ const resourceUrl = resolveOAuthResourceUrl(parsed);
698
+ if (subcommand === "login") {
699
+ const result = await loginWithOAuth({
700
+ authorizationUrlHandler: (authorizationUrl) => {
701
+ process.stderr.write(`Authorize runneth-cli here:\n${authorizationUrl}\n`);
702
+ },
703
+ clientName: parsed.clientName,
704
+ openBrowser: parsed.openBrowser,
705
+ resourceUrl,
706
+ scope: parsed.scope,
707
+ timeoutMs: parsed.timeoutMs,
708
+ });
709
+ writeJson({
710
+ accessToken: result.accessToken,
711
+ credentialPath: result.credentialPath,
712
+ expiresAt: result.expiresAt,
713
+ ok: true,
714
+ op: "oauth-login",
715
+ resource: result.credential.resource,
716
+ scope: result.credential.token.scope ?? result.credential.scope,
717
+ tokenType: result.tokenType,
718
+ });
719
+ return;
720
+ }
721
+ if (subcommand === "status") {
722
+ writeJson({
723
+ ok: true,
724
+ op: "oauth-status",
725
+ ...(await readOAuthCredentialStatus({ resourceUrl })),
726
+ });
727
+ return;
728
+ }
729
+ if (subcommand === "logout") {
730
+ writeJson({
731
+ ok: true,
732
+ op: "oauth-logout",
733
+ ...(await logoutOAuthCredential({ resourceUrl })),
734
+ });
735
+ return;
736
+ }
737
+ throw new Error(`Unknown oauth command: ${subcommand}`);
738
+ }
739
+ if (command === "copy") {
740
+ if (argv[1] === "--help" || argv[1] === "-h") {
741
+ process.stdout.write([
742
+ "Usage: runneth-cli copy --from <source-target> --to <destination-target> <source-path> <destination-path> [options]",
743
+ "",
744
+ "Copies one absolute VM path to another VM through Builder file-share upload.",
745
+ "",
746
+ "Options:",
747
+ " --from <target> Source SSH target name",
748
+ " --to <target> Destination SSH target name",
749
+ " --ignore <path> Additional relative folder/path to skip; node_modules is skipped by default",
750
+ " --client-name <name> Dynamic OAuth client name",
751
+ " --no-open Print the authorization URL without opening a browser",
752
+ " --scope <scope> OAuth scopes",
753
+ " --timeout-ms <ms> Copy command timeout",
754
+ "",
755
+ "Examples:",
756
+ " runneth-cli copy --from source-vm --to destination-vm /agent/output.tgz /agent/imports/output.tgz",
757
+ "",
758
+ ].join("\n"));
759
+ return;
760
+ }
761
+ const parsed = parseCopyArgs(argv.slice(1));
762
+ const sourcePath = parsed.values[0];
763
+ const destinationPath = parsed.values[1];
764
+ if (sourcePath === undefined ||
765
+ destinationPath === undefined ||
766
+ parsed.values.length !== 2) {
767
+ throw new Error("runneth-cli copy requires exactly <source-path> and <destination-path>");
768
+ }
769
+ const sourceTarget = await resolveRunnethSshTarget({
770
+ targetName: parsed.sourceTargetName,
771
+ });
772
+ const destinationTarget = await resolveRunnethSshTarget({
773
+ targetName: parsed.destinationTargetName,
774
+ });
775
+ assertRunnethVmCopyTargetsUseSameResource({
776
+ destination: destinationTarget,
777
+ source: sourceTarget,
778
+ });
779
+ const oauthToken = await resolveSshOAuthToken(parsed, sourceTarget.resourceUrl);
780
+ writeJson({
781
+ ok: true,
782
+ op: "copy",
783
+ ...(await runRunnethVmCopy({
784
+ destination: destinationTarget,
785
+ destinationPath,
786
+ ignoredPaths: parsed.ignoredPaths,
787
+ oauthToken,
788
+ source: sourceTarget,
789
+ sourcePath,
790
+ timeoutMs: parsed.timeoutMs,
791
+ })),
792
+ });
793
+ return;
794
+ }
795
+ if (command === "skills") {
796
+ const subcommand = argv[1];
797
+ if (subcommand === undefined ||
798
+ subcommand === "--help" ||
799
+ subcommand === "-h") {
800
+ process.stdout.write([
801
+ "Usage: runneth-cli skills <install> [--agent claude|codex|all]",
802
+ "",
803
+ "Commands:",
804
+ " install Install or update the bundled Runneth skill",
805
+ "",
806
+ "Options:",
807
+ " --agent <agent> claude, codex, or all. Defaults to all",
808
+ "",
809
+ "Examples:",
810
+ " runneth-cli skills install",
811
+ " runneth-cli skills install --agent claude",
812
+ " runneth-cli skills install --agent codex",
813
+ "",
814
+ ].join("\n"));
815
+ return;
816
+ }
817
+ if (subcommand === "install") {
818
+ const parsed = parseSkillsArgs(argv.slice(2));
819
+ const result = await installRunnethSkills({
820
+ agents: parsed.agents,
821
+ });
822
+ writeJson({
823
+ ok: true,
824
+ op: "skills-install",
825
+ ...result,
826
+ });
827
+ return;
828
+ }
829
+ throw new Error(`Unknown skills command: ${subcommand}`);
830
+ }
831
+ if (command === "ssh") {
832
+ const subcommand = argv[1];
833
+ if (subcommand === "--help" || subcommand === "-h") {
834
+ process.stdout.write([
835
+ "Usage: runneth-cli ssh [stdio|target] [--target <name>] [--resource <url>] [--ssh-url <url>] [-- remote-command]",
836
+ "",
837
+ "Commands:",
838
+ " ssh Log in if needed, install the public key, then run OpenSSH",
839
+ " stdio Keep one SSH master open and accept JSONL exec/process requests on stdin",
840
+ " target Manage named Runneth SSH VM targets",
841
+ "",
842
+ "Options:",
843
+ " --client-name <name> Dynamic OAuth client name",
844
+ " --no-open Print the authorization URL without opening a browser",
845
+ " --resource <url> OAuth-protected MCP resource URL, defaults to build MONDRIAN_API_URL/mcp when using --host or --ssh-url",
846
+ " --scope <scope> OAuth scopes",
847
+ " --ssh-url <url> SSH app URL, defaults to <resource-origin>/runneth/ssh",
848
+ " --target <name> Stored SSH target name",
849
+ " --timeout-ms <ms> Authorization callback timeout",
850
+ " --host <host> Build SSH URL as https://<host>/runneth/ssh",
851
+ " --identity-file <path> Use an existing private key and matching .pub file",
852
+ " --unique-key Generate/use a per-target key instead of the shared key",
853
+ "",
854
+ "Target commands:",
855
+ " runneth-cli ssh target add <name> [--resource <url>] (--ssh-url <url> | --host <host>) [--default]",
856
+ " runneth-cli ssh target import <file> [--resource <url>] [--default]",
857
+ " runneth-cli ssh target list",
858
+ " runneth-cli ssh target use <name>",
859
+ " runneth-cli ssh target remove <name>",
860
+ "",
861
+ "Examples:",
862
+ " runneth-cli ssh target add primary-vm --host 93c7ca56-debe-4b95-8be2-a873afe72234.app.runneth.com --default",
863
+ " runneth-cli ssh -- 'pwd'",
864
+ " runneth-cli ssh --target primary-vm -- 'pwd'",
865
+ " runneth-cli ssh stdio --target primary-vm",
866
+ "",
867
+ ].join("\n"));
868
+ return;
869
+ }
870
+ if (subcommand === "target") {
871
+ const targetCommand = argv[2];
872
+ if (targetCommand === undefined ||
873
+ targetCommand === "--help" ||
874
+ targetCommand === "-h") {
875
+ process.stdout.write([
876
+ "Usage: runneth-cli ssh target <add|import|list|use|remove> [options]",
877
+ "",
878
+ "Commands:",
879
+ " add <name> Add or update a named SSH target",
880
+ " import <file> Add or update SSH targets from a JSON file",
881
+ " list List SSH targets",
882
+ " use <name> Set the default SSH target",
883
+ " remove <name> Remove an SSH target",
884
+ "",
885
+ "Add options:",
886
+ " --default Set this target as default",
887
+ " --resource <url> OAuth-protected MCP resource URL, defaults to build MONDRIAN_API_URL/mcp",
888
+ " --ssh-url <url> SSH app URL",
889
+ " --host <host> Build SSH URL as https://<host>/runneth/ssh",
890
+ "",
891
+ "Import file:",
892
+ ' { "defaultTarget": "vm-one", "targets": [{ "name": "vm-one", "host": "vm-one.example.com" }] }',
893
+ "",
894
+ ].join("\n"));
895
+ return;
896
+ }
897
+ if (targetCommand === "add") {
898
+ const parsed = parseSshArgs(argv.slice(3));
899
+ assertNoSshKeyOptions(parsed, "runneth-cli ssh target add");
900
+ const name = parsed.values[0];
901
+ if (name === undefined || parsed.values.length !== 1) {
902
+ throw new Error("runneth-cli ssh target add requires one target name");
903
+ }
904
+ if (parsed.sshHost === undefined && parsed.sshUrl === undefined) {
905
+ throw new Error("runneth-cli ssh target add requires --host <host> or --ssh-url <url>");
906
+ }
907
+ if (parsed.targetName !== undefined) {
908
+ throw new Error("--target is not valid for ssh target add");
909
+ }
910
+ const saved = await saveRunnethSshTarget({
911
+ makeDefault: parsed.makeDefault,
912
+ name,
913
+ resourceUrl: requireResourceUrl(parsed.resourceUrl ?? resolveDefaultResourceUrl(), "runneth-cli ssh target add"),
914
+ sshUrl: resolveSshUrlOption(parsed),
915
+ });
916
+ writeJson({
917
+ defaultTarget: saved.store.defaultTarget,
918
+ ok: true,
919
+ op: "ssh-target-add",
920
+ target: saved.target,
921
+ });
922
+ return;
923
+ }
924
+ if (targetCommand === "import") {
925
+ const parsed = parseSshArgs(argv.slice(3));
926
+ assertNoSshKeyOptions(parsed, "runneth-cli ssh target import");
927
+ const filePath = parsed.values[0];
928
+ if (filePath === undefined || parsed.values.length !== 1) {
929
+ throw new Error("runneth-cli ssh target import requires one JSON file path");
930
+ }
931
+ if (parsed.targetName !== undefined) {
932
+ throw new Error("--target is not valid for ssh target import");
933
+ }
934
+ if (parsed.sshHost !== undefined || parsed.sshUrl !== undefined) {
935
+ throw new Error("runneth-cli ssh target import reads host and sshUrl from the file");
936
+ }
937
+ const importFile = await readSshTargetImportFile(filePath);
938
+ if (parsed.makeDefault && importFile.defaultTarget !== undefined) {
939
+ throw new Error("Use either import file defaultTarget or --default, not both");
940
+ }
941
+ const entries = resolveSshTargetImportEntries({
942
+ ...(parsed.resourceUrl === undefined
943
+ ? {}
944
+ : { commandResourceUrl: parsed.resourceUrl }),
945
+ file: importFile,
946
+ makeFirstDefault: parsed.makeDefault,
947
+ });
948
+ const targets = [];
949
+ let defaultTarget;
950
+ for (const entry of entries) {
951
+ const saved = await saveRunnethSshTarget({
952
+ makeDefault: entry.makeDefault,
953
+ name: entry.name,
954
+ resourceUrl: entry.resourceUrl,
955
+ sshUrl: entry.sshUrl,
956
+ });
957
+ targets.push(saved.target);
958
+ defaultTarget = saved.store.defaultTarget;
959
+ }
960
+ writeJson({
961
+ defaultTarget,
962
+ ok: true,
963
+ op: "ssh-target-import",
964
+ targets,
965
+ });
966
+ return;
967
+ }
968
+ if (targetCommand === "list") {
969
+ const parsed = parseSshArgs(argv.slice(3));
970
+ assertNoSshKeyOptions(parsed, "runneth-cli ssh target list");
971
+ if (parsed.values.length > 0) {
972
+ throw new Error("runneth-cli ssh target list does not accept names");
973
+ }
974
+ const store = await readRunnethSshTargetStore({});
975
+ writeJson({
976
+ defaultTarget: store.defaultTarget,
977
+ ok: true,
978
+ op: "ssh-target-list",
979
+ targets: store.targets,
980
+ });
981
+ return;
982
+ }
983
+ if (targetCommand === "use") {
984
+ const parsed = parseSshArgs(argv.slice(3));
985
+ assertNoSshKeyOptions(parsed, "runneth-cli ssh target use");
986
+ const name = parsed.values[0];
987
+ if (name === undefined || parsed.values.length !== 1) {
988
+ throw new Error("runneth-cli ssh target use requires one target name");
989
+ }
990
+ const store = await setDefaultRunnethSshTarget({ name });
991
+ const target = store.targets.find((item) => item.name === name);
992
+ writeJson({
993
+ defaultTarget: store.defaultTarget,
994
+ ok: true,
995
+ op: "ssh-target-use",
996
+ target,
997
+ });
998
+ return;
999
+ }
1000
+ if (targetCommand === "remove") {
1001
+ const parsed = parseSshArgs(argv.slice(3));
1002
+ assertNoSshKeyOptions(parsed, "runneth-cli ssh target remove");
1003
+ const name = parsed.values[0];
1004
+ if (name === undefined || parsed.values.length !== 1) {
1005
+ throw new Error("runneth-cli ssh target remove requires one target name");
1006
+ }
1007
+ const store = await removeRunnethSshTarget({ name });
1008
+ writeJson({
1009
+ defaultTarget: store.defaultTarget,
1010
+ ok: true,
1011
+ op: "ssh-target-remove",
1012
+ targetName: name,
1013
+ targets: store.targets,
1014
+ });
1015
+ return;
1016
+ }
1017
+ throw new Error(`Unknown ssh target command: ${targetCommand}`);
1018
+ }
1019
+ if (subcommand === "proxy") {
1020
+ const parsed = parseSshArgs(argv.slice(2));
1021
+ if (parsed.values.length > 0) {
1022
+ throw new Error("runneth-cli ssh proxy does not accept extra arguments");
1023
+ }
1024
+ assertNoSshTargetDefaultFlag(parsed);
1025
+ assertNoSshKeyOptions(parsed, "runneth-cli ssh proxy");
1026
+ const target = await resolveSshTargetForCommand(parsed);
1027
+ await runRunnethSshProxy({
1028
+ resourceUrl: target.resourceUrl,
1029
+ sshUrl: target.sshUrl,
1030
+ });
1031
+ return;
1032
+ }
1033
+ if (subcommand === "stdio") {
1034
+ const parsed = parseSshArgs(argv.slice(2));
1035
+ if (parsed.values.length > 0) {
1036
+ throw new Error("runneth-cli ssh stdio does not accept extra arguments");
1037
+ }
1038
+ assertNoSshTargetDefaultFlag(parsed);
1039
+ const target = await resolveSshTargetForCommand(parsed);
1040
+ const oauthToken = await resolveSshOAuthToken(parsed, target.resourceUrl);
1041
+ const setup = await installRunnethSshAccess({
1042
+ cliPath: fileURLToPath(import.meta.url),
1043
+ oauthToken,
1044
+ resourceUrl: target.resourceUrl,
1045
+ sshUrl: target.sshUrl,
1046
+ ...resolveSshKeyInstallOptions(parsed),
1047
+ ...(target.targetName === undefined
1048
+ ? {}
1049
+ : { targetName: target.targetName }),
1050
+ });
1051
+ process.stderr.write(`SSH target ${setup.hostAlias} configured with ${setup.configPath}\n`);
1052
+ await runRunnethSshStdio({
1053
+ defaultTimeoutMs: parsed.timeoutMs,
1054
+ setup,
1055
+ });
1056
+ return;
1057
+ }
1058
+ const parsed = parseSshArgs(argv.slice(1));
1059
+ assertNoSshTargetDefaultFlag(parsed);
1060
+ assertSshRemoteCommandSeparator(parsed);
1061
+ const target = await resolveSshTargetForCommand(parsed);
1062
+ const oauthToken = await resolveSshOAuthToken(parsed, target.resourceUrl);
1063
+ const setup = await installRunnethSshAccess({
1064
+ cliPath: fileURLToPath(import.meta.url),
1065
+ oauthToken,
1066
+ resourceUrl: target.resourceUrl,
1067
+ sshUrl: target.sshUrl,
1068
+ ...resolveSshKeyInstallOptions(parsed),
1069
+ ...(target.targetName === undefined
1070
+ ? {}
1071
+ : { targetName: target.targetName }),
1072
+ });
1073
+ process.stderr.write(`SSH target ${setup.hostAlias} configured with ${setup.configPath}\n`);
1074
+ process.exitCode = await runOpenSsh({
1075
+ extraArgs: parsed.values,
1076
+ setup,
1077
+ });
1078
+ return;
1079
+ }
1080
+ if (command === "close") {
1081
+ const parsed = parseArgs(argv.slice(1));
1082
+ writeJson(await request({ name: parsed.name, op: "close" }));
1083
+ return;
1084
+ }
1085
+ if (command === "shutdown") {
1086
+ writeJson(await request({ op: "shutdown" }));
1087
+ return;
1088
+ }
1089
+ throw new Error(`Unknown command: ${command}`);
1090
+ };
1091
+ const isSshProxyInvocation = (argv) => {
1092
+ return argv[0] === "ssh" && argv[1] === "proxy";
1093
+ };
1094
+ const isSshStdioInvocation = (argv) => {
1095
+ return argv[0] === "ssh" && argv[1] === "stdio";
1096
+ };
1097
+ const argv = process.argv.slice(2);
1098
+ void run(argv).catch((error) => {
1099
+ const message = error instanceof Error ? error.message : String(error);
1100
+ if (isSshProxyInvocation(argv)) {
1101
+ process.stderr.write(`${message}\n`);
1102
+ process.exitCode = 1;
1103
+ return;
1104
+ }
1105
+ if (isSshStdioInvocation(argv)) {
1106
+ process.stdout.write(`${JSON.stringify({ message, ok: false, type: "error" })}\n`);
1107
+ process.exitCode = 1;
1108
+ return;
1109
+ }
1110
+ process.stdout.write(`${JSON.stringify({ error: message, ok: false }, null, 2)}\n`);
1111
+ process.exitCode = 1;
1112
+ });