@rigkit/cli 0.1.8

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/cli.ts ADDED
@@ -0,0 +1,1911 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+ import chalk from "chalk";
5
+ import { Command, CommanderError } from "commander";
6
+ import inquirer from "inquirer";
7
+ import ora from "ora";
8
+ import {
9
+ getOrStartRuntime,
10
+ type RuntimeClient,
11
+ } from "@rigkit/runtime-client";
12
+ import {
13
+ type DevMachineEvent,
14
+ type WorkflowPlan,
15
+ type SnapshotRecord,
16
+ type WorkspaceRecord,
17
+ } from "@rigkit/engine";
18
+ import {
19
+ cmuxHostCapabilities,
20
+ type CmuxHostCapabilityHandler,
21
+ } from "@rigkit/provider-cmux/host";
22
+ import { DEFAULT_CONFIG_FILE, discoverProjectConfigs, resolveConfigPaths, PROJECT_PACKAGE_NAME } from "./project.ts";
23
+ import {
24
+ materializeGithubProject,
25
+ splitGithubProjectTarget,
26
+ type GithubProjectTarget,
27
+ } from "./remote-project.ts";
28
+ import { RIGKIT_CLI_VERSION } from "./version.ts";
29
+ import { initProject, normalizeMachineName, type InitProjectResult } from "./init.ts";
30
+ import { openExternalTarget } from "./interaction.ts";
31
+ import { createRunPresenter, type RunPresenter } from "./run-presenter.ts";
32
+ import {
33
+ completeRig,
34
+ formatCompletionItems,
35
+ renderCompletionScript,
36
+ resolveCompletionShell,
37
+ type CompletionShell,
38
+ } from "./completion.ts";
39
+
40
+ type GlobalOptions = {
41
+ project?: string;
42
+ config?: string;
43
+ state?: string;
44
+ json: boolean;
45
+ };
46
+
47
+ type CliInvocation = {
48
+ global: GlobalOptions;
49
+ json: boolean;
50
+ };
51
+
52
+ type InitOptions = {
53
+ force: boolean;
54
+ name?: string;
55
+ apiKey?: string;
56
+ packageManager?: PackageManager;
57
+ };
58
+
59
+ type CompletionOptions = {
60
+ shell?: CompletionShell;
61
+ index?: string;
62
+ };
63
+
64
+ type DoctorOptions = {
65
+ cli: boolean;
66
+ };
67
+
68
+ type RunOptions = {
69
+ all: boolean;
70
+ discover: boolean;
71
+ };
72
+
73
+ type ListOptions = {
74
+ target?: string;
75
+ };
76
+
77
+ type PackageManager = "npm" | "bun" | "pnpm" | "skip";
78
+
79
+ type InitInstallResult = {
80
+ packageManager: PackageManager;
81
+ command?: string;
82
+ skipped: boolean;
83
+ reported?: boolean;
84
+ };
85
+
86
+ type EngineProjectInfo = {
87
+ projectDir: string;
88
+ configPath: string;
89
+ statePath: string;
90
+ workflow?: {
91
+ name: string;
92
+ providers: string[];
93
+ };
94
+ };
95
+
96
+ type RuntimeOperationManifest = {
97
+ hostMethods?: {
98
+ known?: Array<{ id: string; modes?: string[] }>;
99
+ requiredByOperations?: Record<string, string[]>;
100
+ };
101
+ hostCapabilities?: {
102
+ optional?: Array<{ id: string; schemaHash?: string }>;
103
+ requiredByOperations?: Record<string, string[]>;
104
+ };
105
+ operations: RuntimeOperationDefinition[];
106
+ };
107
+
108
+ type RuntimeOperationDefinition = {
109
+ id: string;
110
+ aliases?: string[];
111
+ title?: string;
112
+ description?: string;
113
+ createsWorkspace?: boolean;
114
+ requiredHostMethods?: Array<{ id: string; modes?: string[] }>;
115
+ requiredHostCapabilities?: Array<{ id: string; schemaHash?: string }>;
116
+ cli?: {
117
+ positionals?: Array<{ name: string; index: number }>;
118
+ options?: Array<{
119
+ name: string;
120
+ flag: string;
121
+ aliases?: string[];
122
+ required?: boolean;
123
+ runtime?: boolean;
124
+ type?: "string" | "boolean" | "number";
125
+ }>;
126
+ };
127
+ inputSchema?: {
128
+ properties?: Record<string, { type?: string; default?: unknown }>;
129
+ required?: string[];
130
+ };
131
+ };
132
+
133
+ type RuntimeOperationCliOption = NonNullable<NonNullable<RuntimeOperationDefinition["cli"]>["options"]>[number];
134
+
135
+ type ParsedOperationInput = {
136
+ input: Record<string, unknown>;
137
+ hostOptions: Record<string, unknown>;
138
+ };
139
+
140
+ const CLI_HOST_METHODS = [
141
+ { id: "message.show" },
142
+ { id: "prompt.text" },
143
+ { id: "prompt.confirm" },
144
+ { id: "prompt.select" },
145
+ { id: "open.external" },
146
+ { id: "host.command.run", modes: ["capture", "interactive"] },
147
+ ];
148
+
149
+ const CLI_HOST_CAPABILITY_HANDLERS = new Map<string, CmuxHostCapabilityHandler>(
150
+ cmuxHostCapabilities.map((capability) => [capability.id, capability]),
151
+ );
152
+
153
+ const CLI_HOST_CAPABILITIES: Array<{ id: string; schemaHash?: string }> = [
154
+ ...CLI_HOST_CAPABILITY_HANDLERS.values(),
155
+ ].map((capability) => ({
156
+ id: capability.id,
157
+ ...(capability.schemaHash ? { schemaHash: capability.schemaHash } : {}),
158
+ }));
159
+
160
+ const MANAGEMENT_COMMANDS = new Set([
161
+ "completion",
162
+ "doctor",
163
+ "help",
164
+ "init",
165
+ "ls",
166
+ "projects",
167
+ "run",
168
+ "version",
169
+ ]);
170
+
171
+ const GLOBAL_OPTIONS_WITH_VALUES = new Set(["-C", "--project", "--config", "--state"]);
172
+
173
+ if (process.argv[2] === "__complete") {
174
+ runCompletionEndpoint(process.argv.slice(3)).catch(handleCliError);
175
+ } else {
176
+ runCli(normalizeOperationArgv(process.argv)).catch(handleCliError);
177
+ }
178
+
179
+ function normalizeOperationArgv(argv: string[]): string[] {
180
+ const args = argv.slice(2);
181
+ for (let index = 0; index < args.length; index += 1) {
182
+ const arg = args[index]!;
183
+ if (arg === "--") return argv;
184
+ if (GLOBAL_OPTIONS_WITH_VALUES.has(arg)) {
185
+ index += 1;
186
+ continue;
187
+ }
188
+ if ([...GLOBAL_OPTIONS_WITH_VALUES].some((option) => arg.startsWith(`${option}=`))) continue;
189
+ if (arg === "--json") continue;
190
+ if (arg.startsWith("-")) return argv;
191
+ if (MANAGEMENT_COMMANDS.has(arg)) return argv;
192
+ return [...argv.slice(0, 2), ...args.slice(0, index), "run", ...args.slice(index)];
193
+ }
194
+ return argv;
195
+ }
196
+
197
+ async function runCli(argv: string[]): Promise<void> {
198
+ const program = new Command();
199
+ program
200
+ .name("rig")
201
+ .description("Rigkit workflow CLI")
202
+ .usage("[options] <command|operation>")
203
+ .version(RIGKIT_CLI_VERSION, "-v, --version", "Show Rigkit CLI version")
204
+ .showHelpAfterError()
205
+ .exitOverride()
206
+ .argument("[command]")
207
+ .option("-C, --project <project>", `Project directory containing ${DEFAULT_CONFIG_FILE}`)
208
+ .option("--config <config>", "Exact config file to load")
209
+ .option("--state <state>", "Local runtime state database path")
210
+ .option("--json", "Print machine-readable JSON where supported")
211
+ .action(async (command?: string) => {
212
+ if (command) program.error(`unknown command '${command}'`);
213
+ await runHelp(makeInvocation(rootOptions(program)));
214
+ });
215
+
216
+ program
217
+ .command("init")
218
+ .description("Initialize a Rigkit project")
219
+ .option("--name <name>", "Project and workflow name")
220
+ .option("--api-key <apiKey>", "Freestyle API key")
221
+ .option("--package-manager <packageManager>", "Install with npm, bun, pnpm, or skip")
222
+ .option("--force", "Overwrite an existing config file")
223
+ .option("--json", "Print machine-readable JSON")
224
+ .action(async (options: {
225
+ name?: string;
226
+ apiKey?: string;
227
+ packageManager?: string;
228
+ force?: boolean;
229
+ json?: boolean;
230
+ }) => {
231
+ await runInit(makeInvocation(rootOptions(program), options.json), {
232
+ name: options.name,
233
+ apiKey: options.apiKey,
234
+ packageManager: parsePackageManagerOption(options.packageManager),
235
+ force: Boolean(options.force),
236
+ });
237
+ });
238
+
239
+ program
240
+ .command("run <operation> [args...]", { hidden: true })
241
+ .description("Run a project operation exposed by the runtime")
242
+ .allowUnknownOption(true)
243
+ .option("--all", "Run against every discovered project")
244
+ .option("--discover", "Discover projects below the selected directory")
245
+ .option("--json", "Print machine-readable JSON")
246
+ .action(async (
247
+ operation: string,
248
+ args: string[],
249
+ options: { all?: boolean; discover?: boolean; json?: boolean },
250
+ ) => {
251
+ await runProjectOperation(makeInvocation(rootOptions(program), options.json), operation, args ?? [], {
252
+ all: Boolean(options.all),
253
+ discover: Boolean(options.discover),
254
+ });
255
+ });
256
+
257
+ program
258
+ .command("ls [target]")
259
+ .description("List project workspaces")
260
+ .option("--json", "Print machine-readable JSON")
261
+ .action(async (target: string | undefined, options: { json?: boolean }) => {
262
+ await runList(makeInvocation(rootOptions(program), options.json), { target });
263
+ });
264
+
265
+ program
266
+ .command("projects")
267
+ .description("Discover Rigkit projects below the current directory")
268
+ .option("--json", "Print machine-readable JSON")
269
+ .action(async (options: { json?: boolean }) => {
270
+ await runProjects(makeInvocation(rootOptions(program), options.json));
271
+ });
272
+
273
+ program
274
+ .command("doctor")
275
+ .description("Show Rigkit runtime diagnostics")
276
+ .option("--cli", "Show CLI diagnostics without connecting to a project runtime")
277
+ .option("--json", "Print machine-readable JSON")
278
+ .action(async (options: { cli?: boolean; json?: boolean }) => {
279
+ await runDoctor(makeInvocation(rootOptions(program), options.json), { cli: Boolean(options.cli) });
280
+ });
281
+
282
+ program
283
+ .command("version")
284
+ .description("Show Rigkit CLI version")
285
+ .option("--json", "Print machine-readable JSON")
286
+ .action(async (options: { json?: boolean }) => {
287
+ await runVersion(makeInvocation(rootOptions(program), options.json));
288
+ });
289
+
290
+ program
291
+ .command("help")
292
+ .description("Show Rigkit CLI help")
293
+ .option("--json", "Print machine-readable JSON")
294
+ .action(async (options: { json?: boolean }) => {
295
+ await runHelp(makeInvocation(rootOptions(program), options.json));
296
+ });
297
+
298
+ program
299
+ .command("completion [shell]")
300
+ .description("Generate shell completion script")
301
+ .action((shell?: string) => {
302
+ console.log(renderCompletionScript(resolveCompletionShell(shell)));
303
+ });
304
+ await program.parseAsync(argv);
305
+ }
306
+
307
+ function rootOptions(program: Command): GlobalOptions {
308
+ const options = program.opts<{
309
+ project?: string;
310
+ config?: string;
311
+ state?: string;
312
+ json?: boolean;
313
+ }>();
314
+ return {
315
+ project: options.project,
316
+ config: options.config,
317
+ state: options.state,
318
+ json: Boolean(options.json),
319
+ };
320
+ }
321
+
322
+ function parsePackageManagerOption(value: string | undefined): PackageManager | undefined {
323
+ if (value === undefined) return undefined;
324
+ if (isPackageManager(value)) return value;
325
+ throw new Error(`Unknown package manager ${value}. Expected npm, bun, pnpm, or skip.`);
326
+ }
327
+
328
+ function makeInvocation(global: GlobalOptions, commandJson = false): CliInvocation {
329
+ return {
330
+ global,
331
+ json: Boolean(global.json || commandJson),
332
+ };
333
+ }
334
+
335
+ async function runCompletionEndpoint(args: string[]): Promise<void> {
336
+ const options = parseCompletionEndpointArgs(args);
337
+ const shell = resolveCompletionShell(options.shell);
338
+ const currentIndex = options.index === undefined ? undefined : Number(options.index);
339
+ const items = await completeRig({
340
+ words: options.words,
341
+ currentIndex: Number.isFinite(currentIndex) ? currentIndex : undefined,
342
+ cwd: process.cwd(),
343
+ });
344
+ const output = formatCompletionItems(items, shell);
345
+ if (output) console.log(output);
346
+ }
347
+
348
+ function parseCompletionEndpointArgs(args: string[]): CompletionOptions & { words: string[] } {
349
+ const words: string[] = [];
350
+ const options: CompletionOptions = {};
351
+
352
+ for (let index = 0; index < args.length; index += 1) {
353
+ const arg = args[index]!;
354
+ if (arg === "--") {
355
+ words.push(...args.slice(index + 1));
356
+ break;
357
+ }
358
+ if (arg === "--shell") {
359
+ options.shell = args[++index] as CompletionShell | undefined;
360
+ continue;
361
+ }
362
+ if (arg.startsWith("--shell=")) {
363
+ options.shell = arg.slice("--shell=".length) as CompletionShell;
364
+ continue;
365
+ }
366
+ if (arg === "--index") {
367
+ options.index = args[++index];
368
+ continue;
369
+ }
370
+ if (arg.startsWith("--index=")) {
371
+ options.index = arg.slice("--index=".length);
372
+ continue;
373
+ }
374
+ words.push(arg);
375
+ }
376
+
377
+ return { ...options, words };
378
+ }
379
+
380
+ function handleCliError(error: unknown): void {
381
+ if (error instanceof CommanderError) {
382
+ process.exitCode = error.exitCode;
383
+ return;
384
+ }
385
+ console.error(error instanceof Error ? error.message : String(error));
386
+ process.exitCode = 1;
387
+ }
388
+
389
+ async function runInit(invocation: CliInvocation, options: InitOptions): Promise<void> {
390
+ const answers = await resolveInitAnswers(options, wantsJson(invocation));
391
+ const paths = resolveInitProjectPaths(invocation, answers.name);
392
+
393
+ if (existsSync(paths.configPath) && !options.force) {
394
+ throw new Error(`${paths.configPath} already exists. Pass --force to overwrite it.`);
395
+ }
396
+
397
+ const result = initProject({
398
+ projectDir: paths.projectDir,
399
+ configPath: paths.configPath,
400
+ name: answers.name,
401
+ apiKey: answers.apiKey,
402
+ force: options.force,
403
+ });
404
+ const install = await runPackageManagerInstall(paths.projectDir, answers.packageManager, wantsJson(invocation));
405
+
406
+ if (wantsJson(invocation)) {
407
+ printJson({ ...result, install });
408
+ return;
409
+ }
410
+
411
+ printInitResult(result, install);
412
+ }
413
+
414
+ async function resolveInitAnswers(
415
+ options: InitOptions,
416
+ jsonMode: boolean,
417
+ ): Promise<{ name: string; apiKey: string; packageManager: PackageManager }> {
418
+ if (jsonMode && (options.name === undefined || !options.apiKey?.trim())) {
419
+ throw new Error(`rig init --json requires --name and --api-key`);
420
+ }
421
+
422
+ if (jsonMode && options.packageManager && options.packageManager !== "skip") {
423
+ throw new Error(`rig init --json only supports --package-manager skip`);
424
+ }
425
+
426
+ if (options.name === undefined || !options.apiKey) {
427
+ assertInteractiveInit();
428
+ }
429
+
430
+ if (!jsonMode) {
431
+ console.log(chalk.bold("Initialize Rigkit"));
432
+ console.log(chalk.dim("This creates a project folder with rig.config.ts, .env, package.json, and local ignore rules."));
433
+ console.log("");
434
+ }
435
+
436
+ const name = options.name !== undefined
437
+ ? normalizeMachineName(options.name)
438
+ : await promptName();
439
+ const apiKey = options.apiKey?.trim() || await promptRequiredSecret("Freestyle API key");
440
+ const packageManager = options.packageManager ?? (jsonMode || !canPrompt() ? "skip" : await promptPackageManager("skip"));
441
+
442
+ return {
443
+ name,
444
+ apiKey,
445
+ packageManager,
446
+ };
447
+ }
448
+
449
+ function assertInteractiveInit(): void {
450
+ if (canPrompt()) return;
451
+ throw new Error(`rig init needs --name and --api-key when not running in an interactive terminal`);
452
+ }
453
+
454
+ function canPrompt(): boolean {
455
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
456
+ }
457
+
458
+ function resolveInitProjectPaths(invocation: CliInvocation, name: string): { projectDir: string; configPath: string } {
459
+ const options = invocation.global;
460
+ if (options.config) {
461
+ throw new Error(`rig init does not support --config. Use -C/--project to choose the parent directory.`);
462
+ }
463
+
464
+ const parentDir = resolve(process.cwd(), options.project ?? ".");
465
+ const projectDir = resolve(parentDir, name);
466
+ return {
467
+ projectDir,
468
+ configPath: join(projectDir, DEFAULT_CONFIG_FILE),
469
+ };
470
+ }
471
+
472
+ async function promptName(): Promise<string> {
473
+ const answers = await inquirer.prompt<{ name: string }>([{
474
+ type: "input",
475
+ name: "name",
476
+ message: "Project name:",
477
+ validate(value: string) {
478
+ try {
479
+ normalizeMachineName(value);
480
+ return true;
481
+ } catch (error) {
482
+ return error instanceof Error ? error.message : String(error);
483
+ }
484
+ },
485
+ filter: (value: string) => normalizeMachineName(value),
486
+ }]);
487
+ return answers.name;
488
+ }
489
+
490
+ async function promptRequiredSecret(label: string): Promise<string> {
491
+ for (;;) {
492
+ const value = (await promptSecret(label)).trim();
493
+ if (value) return value;
494
+ console.log(chalk.red(`${label} is required.`));
495
+ }
496
+ }
497
+
498
+ async function promptPackageManager(defaultValue: PackageManager): Promise<PackageManager> {
499
+ const choices: Array<{ value: PackageManager; label: string; hint: string }> = [
500
+ { value: "npm", label: "npm", hint: "npm install" },
501
+ { value: "bun", label: "bun", hint: "bun install" },
502
+ { value: "pnpm", label: "pnpm", hint: "pnpm install" },
503
+ { value: "skip", label: "skip", hint: "do not install now" },
504
+ ];
505
+ const answers = await inquirer.prompt<{ packageManager: PackageManager }>([{
506
+ type: "select",
507
+ name: "packageManager",
508
+ message: "Install dependencies?",
509
+ default: defaultValue,
510
+ choices: choices.map((choice) => ({
511
+ name: choice.label,
512
+ value: choice.value,
513
+ description: choice.hint,
514
+ })),
515
+ }]);
516
+ return answers.packageManager;
517
+ }
518
+
519
+ async function promptSecret(label: string): Promise<string> {
520
+ const answers = await inquirer.prompt<{ value: string }>([{
521
+ type: "password",
522
+ name: "value",
523
+ message: `${label}:`,
524
+ mask: "*",
525
+ }]);
526
+ return answers.value;
527
+ }
528
+
529
+ async function runPackageManagerInstall(
530
+ projectDir: string,
531
+ packageManager: PackageManager,
532
+ jsonMode: boolean,
533
+ ): Promise<InitInstallResult> {
534
+ if (packageManager === "skip") {
535
+ return { packageManager, skipped: true };
536
+ }
537
+
538
+ const command = packageManagerInstallCommand(packageManager);
539
+ if (!jsonMode && process.stderr.isTTY) {
540
+ const spinner = ora({
541
+ text: `installing ${command.join(" ")}`,
542
+ stream: process.stderr,
543
+ }).start();
544
+ const proc = Bun.spawn(command, {
545
+ cwd: projectDir,
546
+ stdin: "inherit",
547
+ stdout: "pipe",
548
+ stderr: "pipe",
549
+ });
550
+ const [exitCode, stdout, stderr] = await Promise.all([
551
+ proc.exited,
552
+ new Response(proc.stdout).text(),
553
+ new Response(proc.stderr).text(),
554
+ ]);
555
+ if (exitCode !== 0) {
556
+ spinner.fail(`${command.join(" ")} failed`);
557
+ if (stdout) process.stdout.write(stdout);
558
+ if (stderr) process.stderr.write(stderr);
559
+ throw new Error(`${command.join(" ")} failed with exit code ${exitCode}`);
560
+ }
561
+ spinner.succeed(`installed ${command.join(" ")}`);
562
+ return { packageManager, command: command.join(" "), skipped: false, reported: true };
563
+ }
564
+
565
+ if (!jsonMode) {
566
+ console.log("");
567
+ console.log(`${chalk.cyan("installing")} ${command.join(" ")}`);
568
+ }
569
+
570
+ const proc = Bun.spawn(command, {
571
+ cwd: projectDir,
572
+ stdin: "inherit",
573
+ stdout: jsonMode ? "pipe" : "inherit",
574
+ stderr: jsonMode ? "pipe" : "inherit",
575
+ });
576
+ const exitCode = await proc.exited;
577
+
578
+ if (exitCode !== 0) {
579
+ throw new Error(`${command.join(" ")} failed with exit code ${exitCode}`);
580
+ }
581
+
582
+ return { packageManager, command: command.join(" "), skipped: false };
583
+ }
584
+
585
+ function printInitResult(result: InitProjectResult, install: InitInstallResult): void {
586
+ console.log("");
587
+ console.log(`${chalk.green("Rigkit initialized")} ${chalk.bold(result.name)}`);
588
+ printInitLine(result.created.config ? "created" : "updated", result.configPath);
589
+ printInitLine(result.created.env ? "created" : result.updated.envApiKey ? "updated" : "kept", result.envPath);
590
+ printInitLine(result.created.envExample ? "created" : "kept", result.envExamplePath);
591
+ printInitLine(result.created.packageJson ? "created" : result.updated.packageJson ? "updated" : "kept", result.packageJsonPath);
592
+ printInitLine(result.created.gitignore ? "created" : result.updated.gitignore ? "updated" : "kept", result.gitignorePath);
593
+
594
+ if (result.updated.sdkDependency) {
595
+ console.log(`${chalk.green("pinned")} ${PROJECT_PACKAGE_NAME}@${RIGKIT_CLI_VERSION}`);
596
+ }
597
+
598
+ if (install.skipped) {
599
+ console.log(`${chalk.dim("install")} skipped`);
600
+ } else if (install.command && !install.reported) {
601
+ console.log(`${chalk.green("installed")} ${install.command}`);
602
+ }
603
+
604
+ console.log("");
605
+ console.log(chalk.bold("Next steps"));
606
+ console.log(` cd ${displayProjectDir(result.projectDir)}`);
607
+ if (install.skipped) {
608
+ console.log(` ${detectInstallCommand(result.packageJsonPath)}`);
609
+ }
610
+ console.log(" rig plan");
611
+ }
612
+
613
+ function displayProjectDir(projectDir: string): string {
614
+ const path = relative(process.cwd(), projectDir);
615
+ return path && !path.startsWith("..") ? path : projectDir;
616
+ }
617
+
618
+ function printInitLine(status: "created" | "updated" | "kept", path: string): void {
619
+ const color = status === "kept" ? chalk.dim : status === "updated" ? chalk.yellow : chalk.green;
620
+ console.log(`${color(status.padEnd(7))} ${path}`);
621
+ }
622
+
623
+ function detectInstallCommand(packageJsonPath: string): string {
624
+ const projectDir = dirname(packageJsonPath);
625
+ if (existsSync(join(projectDir, "bun.lock")) || existsSync(join(projectDir, "bun.lockb"))) return "bun install";
626
+ if (existsSync(join(projectDir, "pnpm-lock.yaml"))) return "pnpm install";
627
+ if (existsSync(join(projectDir, "yarn.lock"))) return "yarn install";
628
+ if (existsSync(join(projectDir, "package-lock.json"))) return "npm install";
629
+ return "npm install";
630
+ }
631
+
632
+ function isPackageManager(value: string): value is PackageManager {
633
+ return value === "npm" || value === "bun" || value === "pnpm" || value === "skip";
634
+ }
635
+
636
+ function packageManagerInstallCommand(packageManager: Exclude<PackageManager, "skip">): string[] {
637
+ switch (packageManager) {
638
+ case "bun":
639
+ return ["bun", "install"];
640
+ case "pnpm":
641
+ return ["pnpm", "install"];
642
+ case "npm":
643
+ return ["npm", "install"];
644
+ }
645
+ }
646
+
647
+ async function runProjectOperation(
648
+ invocation: CliInvocation,
649
+ requestedOperation: string,
650
+ args: string[],
651
+ options: RunOptions,
652
+ ): Promise<void> {
653
+ const remote = splitGithubProjectTarget(args);
654
+ if ((options.all || options.discover) && remote.target) {
655
+ throw new Error(`Remote GitHub project targets cannot be combined with --all or --discover`);
656
+ }
657
+
658
+ if (options.all || options.discover) {
659
+ await runDiscoveredProjectOperation(invocation, requestedOperation, remote.args, { all: options.all });
660
+ return;
661
+ }
662
+
663
+ const runtime = remote.target
664
+ ? await loadGithubRuntime(invocation, remote.target)
665
+ : await loadRuntime(invocation);
666
+ const { operation, parsed, result } = await executeRuntimeOperation(
667
+ invocation,
668
+ runtime,
669
+ requestedOperation,
670
+ remote.args,
671
+ );
672
+
673
+ if (wantsJson(invocation)) {
674
+ printJson(result);
675
+ return;
676
+ }
677
+
678
+ await renderOperationResult(operation, result, parsed.hostOptions);
679
+ }
680
+
681
+ async function runDiscoveredProjectOperation(
682
+ invocation: CliInvocation,
683
+ requestedOperation: string,
684
+ args: string[],
685
+ options: { all: boolean },
686
+ ): Promise<void> {
687
+ const projects = discoverProjectConfigs({
688
+ project: invocation.global.project,
689
+ config: invocation.global.config,
690
+ });
691
+ if (projects.length === 0) {
692
+ throw new Error("No Rigkit projects found.");
693
+ }
694
+ if (!options.all && projects.length > 1) {
695
+ throw new Error([
696
+ "Multiple Rigkit projects found.",
697
+ "Use `rig projects` to list candidates, pass -C/--project or --config to select one, or pass --all to run every discovered project.",
698
+ ...projects.map((project) => `- ${project.configPath}`),
699
+ ].join("\n"));
700
+ }
701
+ if (invocation.global.state && projects.length > 1) {
702
+ throw new Error(`--state cannot be used with multiple discovered projects`);
703
+ }
704
+
705
+ const results: Array<{
706
+ project: { projectDir: string; configPath: string };
707
+ operation: string;
708
+ result: unknown;
709
+ }> = [];
710
+
711
+ for (const project of projects) {
712
+ const runtime = await getOrStartRuntime({
713
+ projectDir: project.projectDir,
714
+ configPath: project.configPath,
715
+ statePath: invocation.global.state ? resolve(process.cwd(), invocation.global.state) : undefined,
716
+ });
717
+ const { operation, parsed, result } = await executeRuntimeOperation(
718
+ invocation,
719
+ runtime,
720
+ requestedOperation,
721
+ args,
722
+ );
723
+ results.push({
724
+ project,
725
+ operation: operation.id,
726
+ result,
727
+ });
728
+
729
+ if (!wantsJson(invocation)) {
730
+ if (projects.length > 1) {
731
+ console.log(chalk.bold(displayProjectDir(project.projectDir)));
732
+ }
733
+ await renderOperationResult(operation, result, parsed.hostOptions);
734
+ }
735
+ }
736
+
737
+ if (wantsJson(invocation)) {
738
+ printJson({ projects: results });
739
+ }
740
+ }
741
+
742
+ async function runProjects(invocation: CliInvocation): Promise<void> {
743
+ const projects = discoverProjectConfigs({
744
+ project: invocation.global.project,
745
+ config: invocation.global.config,
746
+ });
747
+ if (wantsJson(invocation)) {
748
+ printJson({ projects });
749
+ return;
750
+ }
751
+ if (projects.length === 0) {
752
+ console.log("No Rigkit projects found.");
753
+ return;
754
+ }
755
+ printTable(["project", "config"], projects.map((project) => [
756
+ project.projectDir,
757
+ project.configPath,
758
+ ]));
759
+ }
760
+
761
+ async function runList(invocation: CliInvocation, options: ListOptions): Promise<void> {
762
+ const target = normalizeListTarget(options.target);
763
+ const runtime = await loadRuntime(invocation);
764
+
765
+ if (target === "workspaces") {
766
+ const { workspaces } = await runtime.control.workspaces();
767
+ if (wantsJson(invocation)) {
768
+ printJson({ workspaces });
769
+ return;
770
+ }
771
+ printWorkspaces(workspaces);
772
+ return;
773
+ }
774
+
775
+ if (target === "snapshots") {
776
+ const { snapshots } = await runtime.control.snapshots();
777
+ if (wantsJson(invocation)) {
778
+ printJson({ snapshots });
779
+ return;
780
+ }
781
+ printSnapshots(snapshots as SnapshotRecord[]);
782
+ return;
783
+ }
784
+
785
+ const project = await readRuntimeProject(runtime);
786
+ if (wantsJson(invocation)) {
787
+ printJson(project);
788
+ return;
789
+ }
790
+ printConfig(project);
791
+ }
792
+
793
+ async function executeRuntimeOperation(
794
+ invocation: CliInvocation,
795
+ runtime: RuntimeClient,
796
+ requestedOperation: string,
797
+ args: string[],
798
+ ): Promise<{
799
+ operation: RuntimeOperationDefinition;
800
+ parsed: ParsedOperationInput;
801
+ result: unknown;
802
+ }> {
803
+ const manifest = await readRuntimeOperations(runtime);
804
+ const operation = findRuntimeOperation(manifest.operations, requestedOperation);
805
+ if (!operation) {
806
+ throw new Error(`This project does not define a Rigkit operation named "${requestedOperation}".`);
807
+ }
808
+
809
+ preflightHostSupport(operation);
810
+ const parsed = parseOperationArgs(operation, args);
811
+ enforceHostOnlyBooleanGuards(operation, parsed);
812
+
813
+ const result = await runRuntimeOperation<unknown>(
814
+ runtime,
815
+ operation.id,
816
+ parsed.input,
817
+ { renderEvents: !wantsJson(invocation) },
818
+ );
819
+
820
+ return { operation, parsed, result };
821
+ }
822
+
823
+ function findRuntimeOperation(
824
+ operations: RuntimeOperationDefinition[],
825
+ requestedOperation: string,
826
+ ): RuntimeOperationDefinition | undefined {
827
+ return operations.find((operation) =>
828
+ operation.id === requestedOperation || operation.aliases?.includes(requestedOperation)
829
+ );
830
+ }
831
+
832
+ function preflightHostSupport(operation: RuntimeOperationDefinition): void {
833
+ const unsupportedMethod = operation.requiredHostMethods?.find((method) => {
834
+ const supported = CLI_HOST_METHODS.find((item) => item.id === method.id);
835
+ return !supported || !supportsModes(supported.modes, method.modes);
836
+ });
837
+ if (unsupportedMethod) {
838
+ throw new Error(
839
+ `Operation "${operation.id}" requires host method "${formatMethodRequirement(unsupportedMethod)}". ` +
840
+ `Upgrade Rigkit or use a host that supports this method.`,
841
+ );
842
+ }
843
+
844
+ const unsupportedCapability = operation.requiredHostCapabilities?.find((capability) => {
845
+ const supported = CLI_HOST_CAPABILITIES.find((item) => item.id === capability.id);
846
+ return !supported || (capability.schemaHash && supported.schemaHash !== capability.schemaHash);
847
+ });
848
+ if (unsupportedCapability) {
849
+ throw new Error(
850
+ `Operation "${operation.id}" requires host capability "${formatCapabilityRequirement(unsupportedCapability)}". ` +
851
+ `Install or enable a local host capability handler to use it from this host.`,
852
+ );
853
+ }
854
+ }
855
+
856
+ function supportsModes(hostModes: string[] | undefined, requiredModes: string[] | undefined): boolean {
857
+ if (!requiredModes?.length) return true;
858
+ const supported = new Set(hostModes ?? []);
859
+ return requiredModes.every((mode) => supported.has(mode));
860
+ }
861
+
862
+ function formatMethodRequirement(method: { id: string; modes?: string[] }): string {
863
+ return method.modes?.length ? `${method.id}:${method.modes.join("|")}` : method.id;
864
+ }
865
+
866
+ function formatCapabilityRequirement(capability: { id: string; schemaHash?: string }): string {
867
+ return capability.schemaHash ? `${capability.id}@${capability.schemaHash}` : capability.id;
868
+ }
869
+
870
+ function parseOperationArgs(operation: RuntimeOperationDefinition, args: string[]): ParsedOperationInput {
871
+ const cli = inferCliMetadata(operation);
872
+ const input: Record<string, unknown> = {};
873
+ const hostOptions: Record<string, unknown> = {};
874
+ const positionals = cli.positionals ?? [];
875
+ let positionalIndex = 0;
876
+
877
+ for (let index = 0; index < args.length; index += 1) {
878
+ const arg = args[index]!;
879
+
880
+ if (arg === "--") {
881
+ for (const value of args.slice(index + 1)) {
882
+ assignPositional(positionals, positionalIndex++, value, input);
883
+ }
884
+ break;
885
+ }
886
+
887
+ if (arg.startsWith("--")) {
888
+ const [flag, inlineValue] = splitFlag(arg);
889
+ const option = cli.options?.find((item) => item.flag === flag || item.aliases?.includes(flag));
890
+ if (!option) throw new Error(`Unknown option ${flag} for operation ${operation.id}`);
891
+ const rawValue = option.type === "boolean"
892
+ ? inlineValue ?? true
893
+ : inlineValue ?? readOptionValue(args, ++index, flag);
894
+ assignCliValue(option, rawValue, input, hostOptions);
895
+ continue;
896
+ }
897
+
898
+ if (arg.startsWith("-") && arg !== "-") {
899
+ const option = cli.options?.find((item) => item.flag === arg || item.aliases?.includes(arg));
900
+ if (!option) throw new Error(`Unknown option ${arg} for operation ${operation.id}`);
901
+ const rawValue = option.type === "boolean" ? true : readOptionValue(args, ++index, arg);
902
+ assignCliValue(option, rawValue, input, hostOptions);
903
+ continue;
904
+ }
905
+
906
+ assignPositional(positionals, positionalIndex++, arg, input);
907
+ }
908
+
909
+ for (const option of cli.options ?? []) {
910
+ if (option.required && input[option.name] === undefined && hostOptions[option.name] === undefined) {
911
+ throw new Error(`Operation ${operation.id} requires ${option.flag}`);
912
+ }
913
+ }
914
+
915
+ for (const name of operation.inputSchema?.required ?? []) {
916
+ if (input[name] === undefined) {
917
+ throw new Error(`Operation ${operation.id} requires ${name}`);
918
+ }
919
+ }
920
+
921
+ return { input, hostOptions };
922
+ }
923
+
924
+ function enforceHostOnlyBooleanGuards(
925
+ operation: RuntimeOperationDefinition,
926
+ parsed: ParsedOperationInput,
927
+ ): void {
928
+ const guard = operation.cli?.options?.find((option) =>
929
+ option.runtime === false &&
930
+ option.type === "boolean" &&
931
+ (option.name === "yes" || option.name === "confirm") &&
932
+ parsed.hostOptions[option.name] !== true
933
+ );
934
+ if (!guard) return;
935
+ throw new Error(`Operation ${operation.id} requires ${guard.flag}`);
936
+ }
937
+
938
+ function inferCliMetadata(operation: RuntimeOperationDefinition): Required<NonNullable<RuntimeOperationDefinition["cli"]>> {
939
+ const properties = operation.inputSchema?.properties ?? {};
940
+ return {
941
+ positionals: operation.cli?.positionals ?? [],
942
+ options: operation.cli?.options ?? Object.entries(properties).map(([name, schema]) => ({
943
+ name,
944
+ flag: `--${dashCase(name)}`,
945
+ required: operation.inputSchema?.required?.includes(name),
946
+ type: schema.type === "boolean" ? "boolean" : schema.type === "number" ? "number" : "string",
947
+ })),
948
+ };
949
+ }
950
+
951
+ function dashCase(value: string): string {
952
+ return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
953
+ }
954
+
955
+ function splitFlag(arg: string): [string, string | undefined] {
956
+ const index = arg.indexOf("=");
957
+ return index < 0 ? [arg, undefined] : [arg.slice(0, index), arg.slice(index + 1)];
958
+ }
959
+
960
+ function readOptionValue(args: string[], index: number, flag: string): string {
961
+ const value = args[index];
962
+ if (value === undefined || value.startsWith("-")) throw new Error(`${flag} requires a value`);
963
+ return value;
964
+ }
965
+
966
+ function assignCliValue(
967
+ option: RuntimeOperationCliOption,
968
+ rawValue: unknown,
969
+ input: Record<string, unknown>,
970
+ hostOptions: Record<string, unknown>,
971
+ ): void {
972
+ const value = coerceCliValue(rawValue, option.type ?? "string", option.flag);
973
+ const target = option.runtime === false ? hostOptions : input;
974
+ target[option.name] = value;
975
+ }
976
+
977
+ function assignPositional(
978
+ positionals: Array<{ name: string; index: number }>,
979
+ index: number,
980
+ value: string,
981
+ input: Record<string, unknown>,
982
+ ): void {
983
+ const positional = positionals.find((item) => item.index === index);
984
+ if (!positional) throw new Error(`Unexpected positional argument ${value}`);
985
+ input[positional.name] = value;
986
+ }
987
+
988
+ function coerceCliValue(value: unknown, type: "string" | "boolean" | "number", flag: string): unknown {
989
+ if (type === "boolean") {
990
+ if (typeof value === "boolean") return value;
991
+ if (value === "true") return true;
992
+ if (value === "false") return false;
993
+ throw new Error(`${flag} expects true or false`);
994
+ }
995
+ if (type === "number") {
996
+ const number = Number(value);
997
+ if (!Number.isFinite(number)) throw new Error(`${flag} expects a number`);
998
+ return number;
999
+ }
1000
+ return String(value);
1001
+ }
1002
+
1003
+ async function renderOperationResult(
1004
+ operation: RuntimeOperationDefinition,
1005
+ result: unknown,
1006
+ hostOptions: Record<string, unknown>,
1007
+ ): Promise<void> {
1008
+ if (isWorkflowPlan(result)) {
1009
+ printPlan(result);
1010
+ return;
1011
+ }
1012
+
1013
+ if (isRecord(result) && isWorkflowPlan(result.plan)) {
1014
+ if (result.dryRun === true) {
1015
+ printPlan(result.plan);
1016
+ console.log("No changes applied.");
1017
+ return;
1018
+ }
1019
+ console.log(`resolved ${result.plan.workflow} -> ${String(result.snapshotId ?? "no workspace source")}`);
1020
+ return;
1021
+ }
1022
+
1023
+ if (operation.createsWorkspace && isWorkspaceRecord(result)) {
1024
+ console.log(`${result.name} ${result.resourceId}`);
1025
+ return;
1026
+ }
1027
+
1028
+ if (isRecord(result) && typeof result.command === "string") {
1029
+ const commandResult = isRecord(result.commandResult) ? result.commandResult : undefined;
1030
+ if (!commandResult || hostOptions.print === true) {
1031
+ console.log(result.command);
1032
+ return;
1033
+ }
1034
+ const exitCode = typeof commandResult.exitCode === "number" ? commandResult.exitCode : 0;
1035
+ if (exitCode !== 0) throw new Error(`Host command failed with exit code ${exitCode}`);
1036
+ return;
1037
+ }
1038
+
1039
+ if (isRecord(result)) {
1040
+ const metadata = isRecord(result.metadata) ? result.metadata : {};
1041
+ if (typeof metadata.snapshotId === "string") {
1042
+ console.log(metadata.snapshotId);
1043
+ return;
1044
+ }
1045
+ }
1046
+
1047
+ if (isWorkspaceRecord(result)) {
1048
+ console.log(`${result.name} ${result.resourceId}`);
1049
+ return;
1050
+ }
1051
+
1052
+ printJson(result);
1053
+ }
1054
+
1055
+ async function runDoctor(invocation: CliInvocation, options: DoctorOptions): Promise<void> {
1056
+ if (options.cli) {
1057
+ const diagnostics = {
1058
+ cliVersion: RIGKIT_CLI_VERSION,
1059
+ binary: process.argv[1] ? resolve(process.argv[1]) : undefined,
1060
+ node: process.version,
1061
+ bun: typeof Bun !== "undefined" ? Bun.version : undefined,
1062
+ };
1063
+ if (wantsJson(invocation)) {
1064
+ printJson(diagnostics);
1065
+ return;
1066
+ }
1067
+ printTable(["key", "value"], [
1068
+ ["cli", diagnostics.cliVersion],
1069
+ ["binary", diagnostics.binary ?? ""],
1070
+ ["node", diagnostics.node],
1071
+ ["bun", diagnostics.bun ?? ""],
1072
+ ]);
1073
+ return;
1074
+ }
1075
+
1076
+ const runtime = await loadRuntime(invocation);
1077
+ const [health, runtimeInfo, project] = await Promise.all([
1078
+ runtime.control.health(),
1079
+ runtime.control.runtime(),
1080
+ readRuntimeProject(runtime),
1081
+ ]);
1082
+ const diagnostics = {
1083
+ cliVersion: RIGKIT_CLI_VERSION,
1084
+ project,
1085
+ daemon: {
1086
+ url: runtime.handle.url,
1087
+ pid: runtime.handle.pid,
1088
+ handlePath: runtime.paths.handlePath,
1089
+ tokenPath: runtime.handle.tokenPath,
1090
+ expiresAt: health.expiresAt ?? runtime.handle.expiresAt,
1091
+ },
1092
+ runtime: runtimeInfo,
1093
+ };
1094
+
1095
+ if (wantsJson(invocation)) {
1096
+ printJson(diagnostics);
1097
+ return;
1098
+ }
1099
+
1100
+ printTable(["key", "value"], [
1101
+ ["cli", RIGKIT_CLI_VERSION],
1102
+ ["project", project.projectDir],
1103
+ ["config", project.configPath],
1104
+ ["runtime handle", runtime.paths.handlePath],
1105
+ ["daemon", runtime.handle.url],
1106
+ ["daemon pid", String(runtime.handle.pid)],
1107
+ ["engine", runtimeInfo.engineVersion],
1108
+ ["runtime", runtimeInfo.runtimeVersion],
1109
+ ["api version", String(runtimeInfo.apiVersion)],
1110
+ ["protocol", runtimeInfo.protocolHash],
1111
+ ["state", project.statePath ?? ""],
1112
+ ["expires", health.expiresAt ?? runtime.handle.expiresAt ?? ""],
1113
+ ]);
1114
+ }
1115
+
1116
+ async function runVersion(invocation: CliInvocation): Promise<void> {
1117
+ if (wantsJson(invocation)) {
1118
+ printJson({ cliVersion: RIGKIT_CLI_VERSION });
1119
+ return;
1120
+ }
1121
+ console.log(RIGKIT_CLI_VERSION);
1122
+ }
1123
+
1124
+ async function runHelp(invocation: CliInvocation): Promise<void> {
1125
+ if (wantsJson(invocation)) {
1126
+ printJson({
1127
+ name: "rig",
1128
+ version: RIGKIT_CLI_VERSION,
1129
+ commands: [
1130
+ { name: "help", description: "Show Rigkit CLI help" },
1131
+ { name: "init", description: "Initialize a Rigkit project" },
1132
+ { name: "<operation>", description: "Run a project operation exposed by the runtime" },
1133
+ { name: "ls", description: "List project workspaces" },
1134
+ { name: "projects", description: "Discover Rigkit projects below the current directory" },
1135
+ { name: "doctor", description: "Show Rigkit runtime diagnostics" },
1136
+ { name: "version", description: "Show Rigkit CLI version" },
1137
+ { name: "completion", description: "Generate shell completion script" },
1138
+ ],
1139
+ });
1140
+ return;
1141
+ }
1142
+ console.log([
1143
+ `rig ${RIGKIT_CLI_VERSION}`,
1144
+ "",
1145
+ "Usage:",
1146
+ " rig [options] <command|operation>",
1147
+ "",
1148
+ "Commands:",
1149
+ " help Show Rigkit CLI help",
1150
+ " init Initialize a Rigkit project",
1151
+ " <operation> Run a project operation exposed by the runtime",
1152
+ " ls List project workspaces",
1153
+ " projects Discover Rigkit projects below the current directory",
1154
+ " doctor Show Rigkit runtime diagnostics",
1155
+ " version Show Rigkit CLI version",
1156
+ " completion Generate shell completion script",
1157
+ "",
1158
+ "Options:",
1159
+ " -C, --project <dir> Project directory containing rig.config.ts",
1160
+ " --config <file> Exact config file to load",
1161
+ " --state <file> Local runtime state database path",
1162
+ " --json Print machine-readable JSON where supported",
1163
+ ].join("\n"));
1164
+ }
1165
+
1166
+ async function loadRuntime(invocation: CliInvocation): Promise<RuntimeClient> {
1167
+ const engineOptions = resolveEngineOptions(invocation);
1168
+ return await getOrStartRuntime(engineOptions);
1169
+ }
1170
+
1171
+ async function loadGithubRuntime(invocation: CliInvocation, target: GithubProjectTarget): Promise<RuntimeClient> {
1172
+ if (invocation.global.project || invocation.global.config || invocation.global.state) {
1173
+ throw new Error(`Remote GitHub project targets cannot be combined with -C/--project, --config, or --state`);
1174
+ }
1175
+
1176
+ await confirmGithubProjectTarget(target);
1177
+ const project = await materializeGithubProject(target);
1178
+ if (!wantsJson(invocation)) {
1179
+ console.error(`github ${target.owner}/${target.repo}@${project.commitSha.slice(0, 12)}`);
1180
+ }
1181
+ return await getOrStartRuntime({
1182
+ projectDir: project.projectDir,
1183
+ configPath: project.configPath,
1184
+ statePath: project.statePath,
1185
+ source: {
1186
+ kind: "github",
1187
+ target: target.raw,
1188
+ repoUrl: project.repoUrl,
1189
+ ref: project.ref,
1190
+ commitSha: project.commitSha,
1191
+ },
1192
+ });
1193
+ }
1194
+
1195
+ async function confirmGithubProjectTarget(target: GithubProjectTarget): Promise<void> {
1196
+ if (process.env.RIGKIT_TRUST_REMOTE_CONFIGS === "1") return;
1197
+ const repo = `https://github.com/${target.owner}/${target.repo}`;
1198
+ const ref = target.ref ? ` at ${target.ref}` : "";
1199
+ const message = `Run and install dependencies for ${repo}${ref}?`;
1200
+
1201
+ if (!canPrompt()) {
1202
+ throw new Error(
1203
+ `Remote GitHub project ${target.raw} executes code on this machine. ` +
1204
+ `Run from an interactive terminal or set RIGKIT_TRUST_REMOTE_CONFIGS=1 to allow it explicitly.`,
1205
+ );
1206
+ }
1207
+
1208
+ console.error("");
1209
+ console.error(chalk.yellow("Remote Rigkit configs execute code on this machine."));
1210
+ console.error(`Project: ${repo}${ref}`);
1211
+ const allowed = await promptHostConfirm({ message, defaultValue: false });
1212
+ if (!allowed) throw new Error(`Remote GitHub project denied`);
1213
+ }
1214
+
1215
+ async function readRuntimeProject(runtime: RuntimeClient): Promise<EngineProjectInfo> {
1216
+ return await runtime.control.project() as EngineProjectInfo;
1217
+ }
1218
+
1219
+ async function readRuntimeOperations(runtime: RuntimeClient): Promise<RuntimeOperationManifest> {
1220
+ return await runtime.control.operations() as unknown as RuntimeOperationManifest;
1221
+ }
1222
+
1223
+ async function runRuntimeOperation<T>(
1224
+ runtime: RuntimeClient,
1225
+ operation: string,
1226
+ input: Record<string, unknown>,
1227
+ options: { renderEvents: boolean },
1228
+ ): Promise<T> {
1229
+ const started = await runtime.control.startRun({ operation, input });
1230
+ let presenter: RunPresenter | undefined = options.renderEvents
1231
+ ? createRunPresenter(operation)
1232
+ : undefined;
1233
+ let result: T | undefined;
1234
+ let failure: Error | undefined;
1235
+
1236
+ const handleEvent = async (
1237
+ event: unknown,
1238
+ respond?: (id: string, response: unknown) => void | Promise<void>,
1239
+ sendSession?: (message: unknown) => void | Promise<void>,
1240
+ ) => {
1241
+ if (isHostRequestEvent(event)) {
1242
+ const suspendPresenter = hostRequestNeedsTerminal(event);
1243
+ if (suspendPresenter) presenter?.pause();
1244
+ try {
1245
+ if (respond) {
1246
+ await answerHostRequestOverSession(respond, event, { quietOpen: Boolean(presenter) });
1247
+ } else {
1248
+ await answerHostRequest(runtime, event, { quietOpen: Boolean(presenter) });
1249
+ }
1250
+ } finally {
1251
+ if (suspendPresenter) presenter?.resume();
1252
+ }
1253
+ return;
1254
+ }
1255
+ if (isHostCapabilityRequestEvent(event)) {
1256
+ presenter?.pause();
1257
+ try {
1258
+ if (sendSession) {
1259
+ await answerHostCapabilityRequestOverSession(sendSession, event);
1260
+ } else if (respond) {
1261
+ await answerHostCapabilityRequestOverSession((message) => {
1262
+ if (isRecord(message) && message.type === "response") {
1263
+ const id = typeof message.id === "string" ? message.id : undefined;
1264
+ if (id) return respond(id, "error" in message ? { error: message.error } : { result: message.result });
1265
+ }
1266
+ throw new Error(`Session response channel cannot send ${String(isRecord(message) ? message.type : typeof message)}`);
1267
+ }, event);
1268
+ } else {
1269
+ await answerHostCapabilityRequest(runtime, event);
1270
+ }
1271
+ } finally {
1272
+ presenter?.resume();
1273
+ }
1274
+ return;
1275
+ }
1276
+ if (isRecord(event) && event.type === "run.completed") {
1277
+ presenter?.render({ ...event, type: "run.completed" });
1278
+ result = event.result as T;
1279
+ return;
1280
+ }
1281
+ if (isRecord(event) && event.type === "run.failed") {
1282
+ const message = isRecord(event.error) && typeof event.error.message === "string"
1283
+ ? event.error.message
1284
+ : "Runtime operation failed";
1285
+ failure = new Error(message);
1286
+ presenter?.render({ ...event, type: "run.failed" });
1287
+ return;
1288
+ }
1289
+ if (options.renderEvents && isDevMachineEvent(event)) {
1290
+ if (presenter) presenter.render(event);
1291
+ else renderEvent(event);
1292
+ }
1293
+ };
1294
+
1295
+ try {
1296
+ if (started.sessionUrl) {
1297
+ await runtime.runSession(started.runId, {
1298
+ hello: {
1299
+ type: "hello",
1300
+ transportVersion: 1,
1301
+ host: {
1302
+ name: "rigkit-cli",
1303
+ version: RIGKIT_CLI_VERSION,
1304
+ },
1305
+ hostMethods: CLI_HOST_METHODS,
1306
+ hostCapabilities: CLI_HOST_CAPABILITIES,
1307
+ },
1308
+ onOpen(session) {
1309
+ return installRunCancelHandler(session);
1310
+ },
1311
+ onClose() {
1312
+ uninstallRunCancelHandler();
1313
+ },
1314
+ async onMessage(message, session) {
1315
+ if (isRecord(message) && message.type === "hello.ack") return;
1316
+ if (isRecord(message) && message.type === "run.event") {
1317
+ await handleEvent(message.event);
1318
+ return;
1319
+ }
1320
+ await handleEvent(
1321
+ message,
1322
+ (id, response) => session.send({ type: "response", id, ...(response as object) }),
1323
+ (sessionMessage) => session.send(sessionMessage),
1324
+ );
1325
+ if (result !== undefined || failure) session.close();
1326
+ },
1327
+ });
1328
+ uninstallRunCancelHandler();
1329
+ } else {
1330
+ await runtime.runEvents(started.runId, handleEvent);
1331
+ }
1332
+ } finally {
1333
+ presenter?.close();
1334
+ uninstallRunCancelHandler();
1335
+ }
1336
+
1337
+ if (failure) throw failure;
1338
+ if (result === undefined) throw new Error(`Runtime operation ${operation} finished without a result`);
1339
+ return result;
1340
+ }
1341
+
1342
+ let uninstallActiveRunCancelHandler: (() => void) | undefined;
1343
+
1344
+ function installRunCancelHandler(session: { send(message: unknown): void; close(code?: number, reason?: string): void }): void {
1345
+ uninstallRunCancelHandler();
1346
+ let cancelRequested = false;
1347
+ const onSigint = () => {
1348
+ if (cancelRequested) {
1349
+ session.close(1000, "Run cancelled by host");
1350
+ return;
1351
+ }
1352
+ cancelRequested = true;
1353
+ session.send({ type: "run.cancel", reason: "user" });
1354
+ process.once("SIGINT", onSigint);
1355
+ };
1356
+ process.once("SIGINT", onSigint);
1357
+ uninstallActiveRunCancelHandler = () => {
1358
+ process.off("SIGINT", onSigint);
1359
+ uninstallActiveRunCancelHandler = undefined;
1360
+ };
1361
+ }
1362
+
1363
+ function uninstallRunCancelHandler(): void {
1364
+ uninstallActiveRunCancelHandler?.();
1365
+ }
1366
+
1367
+ function resolveEngineOptions(invocation: CliInvocation): { projectDir: string; configPath: string; statePath?: string } {
1368
+ const paths = resolveCommandConfigPaths(invocation);
1369
+ const options = invocation.global;
1370
+ return {
1371
+ projectDir: paths.projectDir,
1372
+ configPath: paths.configPath,
1373
+ statePath: options.state ? resolve(process.cwd(), options.state) : undefined,
1374
+ };
1375
+ }
1376
+
1377
+ function resolveCommandConfigPaths(invocation: CliInvocation): { projectDir: string; configPath: string } {
1378
+ const options = invocation.global;
1379
+ return resolveConfigPaths({ project: options.project, config: options.config });
1380
+ }
1381
+
1382
+ type HostRequestEvent = {
1383
+ type: "host.request";
1384
+ requestId?: string;
1385
+ id?: string;
1386
+ method: string;
1387
+ params: unknown;
1388
+ };
1389
+
1390
+ type HostCapabilityRequestEvent = {
1391
+ type: "host.capability.request";
1392
+ requestId?: string;
1393
+ id?: string;
1394
+ capability: string;
1395
+ params: unknown;
1396
+ };
1397
+
1398
+ type HostRequestHandlingOptions = {
1399
+ quietOpen?: boolean;
1400
+ };
1401
+
1402
+ class UnsupportedHostCapabilityError extends Error {
1403
+ constructor(capability: string) {
1404
+ super(
1405
+ `Host capability "${capability}" is not registered in this Rigkit CLI host. ` +
1406
+ `Install or enable a local host capability handler to use it from this host.`,
1407
+ );
1408
+ this.name = "UnsupportedHostCapabilityError";
1409
+ }
1410
+ }
1411
+
1412
+ async function answerHostRequest(
1413
+ runtime: RuntimeClient,
1414
+ event: HostRequestEvent,
1415
+ options: HostRequestHandlingOptions = {},
1416
+ ): Promise<void> {
1417
+ if (!event.requestId) throw new Error(`Host request is missing requestId`);
1418
+ try {
1419
+ const result = await handleHostRequest(event.method, event.params, options);
1420
+ await runtime.control.hostResponse(event.requestId, { result });
1421
+ } catch (error) {
1422
+ await runtime.control.hostResponse(event.requestId, {
1423
+ error: {
1424
+ message: error instanceof Error ? error.message : String(error),
1425
+ },
1426
+ });
1427
+ }
1428
+ }
1429
+
1430
+ async function answerHostRequestOverSession(
1431
+ respond: (id: string, response: unknown) => void | Promise<void>,
1432
+ event: HostRequestEvent,
1433
+ options: HostRequestHandlingOptions = {},
1434
+ ): Promise<void> {
1435
+ const id = event.id ?? event.requestId;
1436
+ if (!id) throw new Error(`Host request is missing id`);
1437
+ try {
1438
+ const result = await handleHostRequest(event.method, event.params, options);
1439
+ await respond(id, { result });
1440
+ } catch (error) {
1441
+ await respond(id, {
1442
+ error: {
1443
+ message: error instanceof Error ? error.message : String(error),
1444
+ },
1445
+ });
1446
+ }
1447
+ }
1448
+
1449
+ async function answerHostCapabilityRequest(runtime: RuntimeClient, event: HostCapabilityRequestEvent): Promise<void> {
1450
+ const requestId = event.requestId ?? event.id;
1451
+ if (!requestId) throw new Error(`Host capability request is missing requestId`);
1452
+ try {
1453
+ const handled = await handleHostCapabilityRequest(event.capability, event.params);
1454
+ await runtime.control.hostResponse(requestId, { result: handled.result });
1455
+ } catch (error) {
1456
+ await runtime.control.hostResponse(requestId, {
1457
+ error: hostCapabilityError(error),
1458
+ });
1459
+ }
1460
+ }
1461
+
1462
+ async function answerHostCapabilityRequestOverSession(
1463
+ send: (message: unknown) => void | Promise<void>,
1464
+ event: HostCapabilityRequestEvent,
1465
+ ): Promise<void> {
1466
+ const id = event.id ?? event.requestId;
1467
+ if (!id) throw new Error(`Host capability request is missing id`);
1468
+ try {
1469
+ const handled = await handleHostCapabilityRequest(event.capability, event.params);
1470
+ await send({ type: "response", id, result: handled.result });
1471
+ if (handled.closed) reportHostCapabilityClosed(send, id, handled.closed);
1472
+ } catch (error) {
1473
+ await send({
1474
+ type: "response",
1475
+ id,
1476
+ error: hostCapabilityError(error),
1477
+ });
1478
+ }
1479
+ }
1480
+
1481
+ async function handleHostRequest(
1482
+ method: string,
1483
+ params: unknown,
1484
+ options: HostRequestHandlingOptions = {},
1485
+ ): Promise<unknown> {
1486
+ switch (method) {
1487
+ case "message.show":
1488
+ return showHostMessage(params);
1489
+ case "prompt.text":
1490
+ return await promptHostText(params);
1491
+ case "prompt.confirm":
1492
+ return await promptHostConfirm(params);
1493
+ case "prompt.select":
1494
+ return await promptHostSelect(params);
1495
+ case "open.external":
1496
+ return openHostExternal(params, options);
1497
+ case "host.command.run":
1498
+ return await runHostCommand(params);
1499
+ default:
1500
+ throw new Error(`Unsupported host method ${method}`);
1501
+ }
1502
+ }
1503
+
1504
+ function hostRequestNeedsTerminal(event: HostRequestEvent): boolean {
1505
+ switch (event.method) {
1506
+ case "open.external":
1507
+ return false;
1508
+ case "host.command.run":
1509
+ return !isTrustedCaptureHostCommand(event.params);
1510
+ default:
1511
+ return true;
1512
+ }
1513
+ }
1514
+
1515
+ function isTrustedCaptureHostCommand(params: unknown): boolean {
1516
+ return process.env.RIGKIT_TRUST_HOST_COMMANDS === "1" &&
1517
+ isRecord(params) &&
1518
+ params.mode !== "interactive";
1519
+ }
1520
+
1521
+ type HandledHostCapability = {
1522
+ result: unknown;
1523
+ closed?: Promise<void>;
1524
+ };
1525
+
1526
+ async function handleHostCapabilityRequest(capability: string, params: unknown): Promise<HandledHostCapability> {
1527
+ const handler = CLI_HOST_CAPABILITY_HANDLERS.get(capability);
1528
+ if (!handler) {
1529
+ throw new UnsupportedHostCapabilityError(capability);
1530
+ }
1531
+ return normalizeHostCapabilityResult(await handler.handle(params));
1532
+ }
1533
+
1534
+ function normalizeHostCapabilityResult(value: unknown): HandledHostCapability {
1535
+ if (isRecord(value) && isPromiseLike(value.closed)) {
1536
+ const { closed, ...result } = value as Record<string, unknown> & { closed: PromiseLike<unknown> };
1537
+ return {
1538
+ result,
1539
+ closed: Promise.resolve(closed).then(() => undefined),
1540
+ };
1541
+ }
1542
+ return { result: value };
1543
+ }
1544
+
1545
+ function reportHostCapabilityClosed(
1546
+ send: (message: unknown) => void | Promise<void>,
1547
+ id: string,
1548
+ closed: Promise<void>,
1549
+ ): void {
1550
+ void closed.then(
1551
+ () => send({ type: "host.capability.closed", id }),
1552
+ (error) => send({ type: "host.capability.closed", id, error: hostCapabilityError(error) }),
1553
+ ).catch(() => {});
1554
+ }
1555
+
1556
+ function hostCapabilityError(error: unknown): { code: string; message: string } {
1557
+ return {
1558
+ code: error instanceof UnsupportedHostCapabilityError ? "UNSUPPORTED_CAPABILITY" : "HOST_CAPABILITY_FAILED",
1559
+ message: error instanceof Error ? error.message : String(error),
1560
+ };
1561
+ }
1562
+
1563
+ function showHostMessage(params: unknown): null {
1564
+ const message = stringField(params, "message") ?? "";
1565
+ const level = stringField(params, "level") ?? "info";
1566
+ console.error(`${level}: ${message}`);
1567
+ return null;
1568
+ }
1569
+
1570
+ async function promptHostText(params: unknown): Promise<string> {
1571
+ const message = stringField(params, "message") ?? "Enter value";
1572
+ const defaultValue = stringField(params, "defaultValue");
1573
+ if (!canPrompt()) {
1574
+ if (defaultValue !== undefined) return defaultValue;
1575
+ throw new Error(`Host prompt requires an interactive terminal: ${message}`);
1576
+ }
1577
+ const answers = await inquirer.prompt<{ value: string }>([{
1578
+ type: "input",
1579
+ name: "value",
1580
+ message,
1581
+ default: defaultValue,
1582
+ }]);
1583
+ return answers.value || defaultValue || "";
1584
+ }
1585
+
1586
+ async function promptHostConfirm(params: unknown): Promise<boolean> {
1587
+ const message = stringField(params, "message") ?? "Continue?";
1588
+ const defaultValue = booleanField(params, "defaultValue") ?? false;
1589
+ if (!canPrompt()) return defaultValue;
1590
+ const answers = await inquirer.prompt<{ value: boolean }>([{
1591
+ type: "confirm",
1592
+ name: "value",
1593
+ message,
1594
+ default: defaultValue,
1595
+ }]);
1596
+ return answers.value;
1597
+ }
1598
+
1599
+ async function promptHostSelect(params: unknown): Promise<string> {
1600
+ const message = stringField(params, "message") ?? "Choose";
1601
+ const options = isRecord(params) && Array.isArray(params.options)
1602
+ ? params.options
1603
+ .filter(isRecord)
1604
+ .map((item) => ({
1605
+ value: typeof item.value === "string" ? item.value : "",
1606
+ label: typeof item.label === "string" ? item.label : typeof item.value === "string" ? item.value : "",
1607
+ hint: typeof item.description === "string" ? item.description : undefined,
1608
+ }))
1609
+ .filter((item) => item.value)
1610
+ : [];
1611
+ if (options.length === 0) throw new Error(`Host select prompt has no options`);
1612
+ const defaultValue = stringField(params, "defaultValue") ?? options[0]!.value;
1613
+ if (!canPrompt()) return defaultValue;
1614
+ const answers = await inquirer.prompt<{ value: string }>([{
1615
+ type: "select",
1616
+ name: "value",
1617
+ message,
1618
+ default: defaultValue,
1619
+ choices: options.map((option) => ({
1620
+ name: option.label,
1621
+ value: option.value,
1622
+ description: option.hint,
1623
+ })),
1624
+ }]);
1625
+ return answers.value;
1626
+ }
1627
+
1628
+ function openHostExternal(params: unknown, options: HostRequestHandlingOptions = {}): null {
1629
+ const target = stringField(params, "target");
1630
+ if (!target) throw new Error(`open.external requires target`);
1631
+ if (!options.quietOpen) console.error(`open ${target}`);
1632
+ openExternalTarget(target);
1633
+ return null;
1634
+ }
1635
+
1636
+ async function runHostCommand(params: unknown): Promise<{ exitCode: number; stdout: string | null; stderr: string | null }> {
1637
+ if (!isRecord(params) || !Array.isArray(params.argv) || params.argv.some((item) => typeof item !== "string")) {
1638
+ throw new Error(`host.command.run requires argv`);
1639
+ }
1640
+ const argv = params.argv as string[];
1641
+ if (argv.length === 0) throw new Error(`host.command.run argv must not be empty`);
1642
+ const mode = params.mode === "interactive" ? "interactive" : "capture";
1643
+ const cwd = stringField(params, "cwd");
1644
+ const reason = stringField(params, "reason");
1645
+ const env = isRecord(params.env)
1646
+ ? Object.fromEntries(Object.entries(params.env).filter(([, value]) => value === undefined || typeof value === "string")) as Record<string, string | undefined>
1647
+ : undefined;
1648
+ const stdin = params.stdin === null || typeof params.stdin === "string" ? params.stdin : undefined;
1649
+
1650
+ if (process.env.RIGKIT_TRUST_HOST_COMMANDS !== "1") {
1651
+ const allowed = await confirmHostCommand({ argv, cwd, env, mode, reason });
1652
+ if (!allowed) throw new Error(`Host command denied`);
1653
+ }
1654
+
1655
+ if (mode === "interactive") {
1656
+ const proc = Bun.spawn(argv, {
1657
+ cwd,
1658
+ env: env ? { ...process.env, ...env } : process.env,
1659
+ stdin: stdin === undefined || stdin === null ? "inherit" : "pipe",
1660
+ stdout: "inherit",
1661
+ stderr: "inherit",
1662
+ });
1663
+ if (stdin !== undefined && stdin !== null) {
1664
+ const writer = proc.stdin;
1665
+ if (!writer) throw new Error(`Host command stdin is unavailable`);
1666
+ writer.write(stdin);
1667
+ writer.end();
1668
+ }
1669
+ return { exitCode: await proc.exited, stdout: null, stderr: null };
1670
+ }
1671
+
1672
+ const proc = Bun.spawn(argv, {
1673
+ cwd,
1674
+ env: env ? { ...process.env, ...env } : process.env,
1675
+ stdin: stdin === undefined || stdin === null ? "ignore" : "pipe",
1676
+ stdout: "pipe",
1677
+ stderr: "pipe",
1678
+ });
1679
+ if (stdin !== undefined && stdin !== null) {
1680
+ const writer = proc.stdin;
1681
+ if (!writer) throw new Error(`Host command stdin is unavailable`);
1682
+ writer.write(stdin);
1683
+ writer.end();
1684
+ }
1685
+ const [exitCode, stdout, stderr] = await Promise.all([
1686
+ proc.exited,
1687
+ new Response(proc.stdout).text(),
1688
+ new Response(proc.stderr).text(),
1689
+ ]);
1690
+ return { exitCode, stdout, stderr };
1691
+ }
1692
+
1693
+ async function confirmHostCommand(input: {
1694
+ argv: string[];
1695
+ cwd?: string;
1696
+ env?: Record<string, string | undefined>;
1697
+ mode: "capture" | "interactive";
1698
+ reason?: string;
1699
+ }): Promise<boolean> {
1700
+ if (!canPrompt()) {
1701
+ throw new Error(`Host command requires confirmation in an interactive terminal`);
1702
+ }
1703
+
1704
+ console.error("");
1705
+ console.error(chalk.yellow("This Rigkit config is asking to run a command on this machine."));
1706
+ console.error(`${chalk.bold("Command:")} ${input.argv.map(shellDisplay).join(" ")}`);
1707
+ console.error(`${chalk.bold("Mode:")} ${input.mode}`);
1708
+ if (input.cwd) console.error(`${chalk.bold("cwd:")} ${input.cwd}`);
1709
+ if (input.env && Object.keys(input.env).length > 0) {
1710
+ console.error(`${chalk.bold("env:")} ${Object.keys(input.env).join(", ")}`);
1711
+ }
1712
+ if (input.reason) console.error(`${chalk.bold("Reason:")} ${input.reason}`);
1713
+ return await promptHostConfirm({ message: "Allow?", defaultValue: false });
1714
+ }
1715
+
1716
+ function isHostRequestEvent(value: unknown): value is HostRequestEvent {
1717
+ return isRecord(value) &&
1718
+ value.type === "host.request" &&
1719
+ (typeof value.requestId === "string" || typeof value.id === "string") &&
1720
+ typeof value.method === "string";
1721
+ }
1722
+
1723
+ function isHostCapabilityRequestEvent(value: unknown): value is HostCapabilityRequestEvent {
1724
+ return isRecord(value) &&
1725
+ value.type === "host.capability.request" &&
1726
+ (typeof value.requestId === "string" || typeof value.id === "string") &&
1727
+ typeof value.capability === "string";
1728
+ }
1729
+
1730
+ function isWorkflowPlan(value: unknown): value is WorkflowPlan {
1731
+ return isRecord(value) &&
1732
+ typeof value.workflow === "string" &&
1733
+ typeof value.cachedNodeCount === "number" &&
1734
+ typeof value.nodeCount === "number" &&
1735
+ Array.isArray(value.nodes);
1736
+ }
1737
+
1738
+ function isWorkspaceRecord(value: unknown): value is WorkspaceRecord {
1739
+ return isRecord(value) &&
1740
+ typeof value.name === "string" &&
1741
+ typeof value.resourceId === "string" &&
1742
+ typeof value.workflow === "string";
1743
+ }
1744
+
1745
+ function isDevMachineEvent(value: unknown): value is DevMachineEvent {
1746
+ return isRecord(value) &&
1747
+ typeof value.type === "string" &&
1748
+ !value.type.startsWith("run.") &&
1749
+ value.type !== "host.request" &&
1750
+ value.type !== "host.capability.request";
1751
+ }
1752
+
1753
+ function stringField(value: unknown, key: string): string | undefined {
1754
+ return isRecord(value) && typeof value[key] === "string" ? value[key] : undefined;
1755
+ }
1756
+
1757
+ function booleanField(value: unknown, key: string): boolean | undefined {
1758
+ return isRecord(value) && typeof value[key] === "boolean" ? value[key] : undefined;
1759
+ }
1760
+
1761
+ function isRecord(value: unknown): value is Record<string, unknown> {
1762
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
1763
+ }
1764
+
1765
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
1766
+ return Boolean(value && typeof value === "object" && "then" in value && typeof value.then === "function");
1767
+ }
1768
+
1769
+ function shellDisplay(value: string): string {
1770
+ return /^[A-Za-z0-9_./:=@+-]+$/.test(value) ? value : JSON.stringify(value);
1771
+ }
1772
+
1773
+ function wantsJson(invocation: CliInvocation): boolean {
1774
+ return invocation.json;
1775
+ }
1776
+
1777
+ function printJson(value: unknown): void {
1778
+ console.log(JSON.stringify(value, null, 2));
1779
+ }
1780
+
1781
+ function printPlan(plan: WorkflowPlan): void {
1782
+ console.log(`${plan.workflow}: ${plan.cachedNodeCount}/${plan.nodeCount} nodes cached`);
1783
+
1784
+ const rows = plan.nodes.map((node) => [
1785
+ String(node.index + 1),
1786
+ node.status,
1787
+ node.path,
1788
+ node.reason ?? "",
1789
+ ]);
1790
+ printTable(["#", "status", "node", "reason"], rows);
1791
+ }
1792
+
1793
+ function printWorkspaces(
1794
+ workspaces: ReadonlyArray<Pick<WorkspaceRecord, "name" | "workflow" | "snapshotId" | "createdAt"> & { resourceId?: string }>,
1795
+ ): void {
1796
+ if (workspaces.length === 0) {
1797
+ console.log("No workspaces.");
1798
+ return;
1799
+ }
1800
+
1801
+ printTable(
1802
+ ["name", "resource", "snapshot", "workflow", "created"],
1803
+ workspaces.map((workspace) => [
1804
+ workspace.name,
1805
+ workspace.resourceId ?? "",
1806
+ workspace.snapshotId ?? "",
1807
+ workspace.workflow,
1808
+ workspace.createdAt,
1809
+ ]),
1810
+ );
1811
+ }
1812
+
1813
+ function printSnapshots(snapshots: SnapshotRecord[]): void {
1814
+ if (snapshots.length === 0) {
1815
+ console.log("No snapshots.");
1816
+ return;
1817
+ }
1818
+
1819
+ printTable(
1820
+ ["run", "workflow", "node", "snapshot", "created"],
1821
+ snapshots.map((snapshot) => [
1822
+ snapshot.id,
1823
+ snapshot.workflow,
1824
+ snapshot.nodePath,
1825
+ typeof snapshot.metadata.snapshotId === "string" ? snapshot.metadata.snapshotId : "",
1826
+ snapshot.createdAt,
1827
+ ]),
1828
+ );
1829
+ }
1830
+
1831
+ function printConfig(info: EngineProjectInfo): void {
1832
+ const rows = [
1833
+ ["config", info.configPath],
1834
+ ["project", info.projectDir],
1835
+ ["state", info.statePath],
1836
+ ["workflow", info.workflow?.name ?? "(not loaded)"],
1837
+ ["providers", info.workflow?.providers.join(", ") ?? ""],
1838
+ ];
1839
+ printTable(["key", "value"], rows);
1840
+ }
1841
+
1842
+ function normalizeListTarget(target: string | undefined): "workspaces" | "snapshots" | "config" {
1843
+ if (!target || target === "workspaces" || target === "workspace" || target === "vms" || target === "vm") {
1844
+ return "workspaces";
1845
+ }
1846
+ if (target === "snapshots" || target === "snapshot") return "snapshots";
1847
+ if (target === "config" || target === "machine" || target === "machines") return "config";
1848
+ throw new Error(`Unknown ls target ${target}. Expected workspaces, snapshots, or config.`);
1849
+ }
1850
+
1851
+ function printTable(headers: string[], rows: string[][]): void {
1852
+ const widths = headers.map((header, index) =>
1853
+ Math.max(header.length, ...rows.map((row) => String(row[index] ?? "").length)),
1854
+ );
1855
+ const format = (row: string[]) =>
1856
+ row.map((value, index) => String(value ?? "").padEnd(widths[index] ?? 0)).join(" ").trimEnd();
1857
+
1858
+ console.log(format(headers));
1859
+ console.log(format(widths.map((width) => "-".repeat(width))));
1860
+ for (const row of rows) console.log(format(row));
1861
+ }
1862
+
1863
+ function renderEvent(event: DevMachineEvent): void {
1864
+ switch (event.type) {
1865
+ case "definition.loaded":
1866
+ console.error(`loaded ${event.workflow}`);
1867
+ return;
1868
+ case "plan.created":
1869
+ console.error(`plan ${event.workflow}: ${event.cachedNodeCount}/${event.nodeCount} cached`);
1870
+ return;
1871
+ case "node.cached":
1872
+ console.error(`node ${event.nodePath} cached`);
1873
+ return;
1874
+ case "vm.created":
1875
+ console.error(event.fromSnapshotId ? `vm ${event.vmId} from ${event.fromSnapshotId}` : `vm ${event.vmId} created`);
1876
+ return;
1877
+ case "node.started":
1878
+ console.error(`node ${event.nodePath}`);
1879
+ return;
1880
+ case "node.completed":
1881
+ console.error(`node ${event.nodePath} completed`);
1882
+ return;
1883
+ case "command.started":
1884
+ console.error(`command ${event.commandName}`);
1885
+ return;
1886
+ case "command.output":
1887
+ process.stderr.write(event.data);
1888
+ return;
1889
+ case "command.completed":
1890
+ console.error(`command ${event.commandName} exited ${event.exitCode}`);
1891
+ return;
1892
+ case "log.output":
1893
+ process.stderr.write(event.data);
1894
+ return;
1895
+ case "interaction.awaiting_user":
1896
+ console.error(`interaction ${event.label}`);
1897
+ console.error(`open ${event.url}`);
1898
+ return;
1899
+ case "interaction.completed":
1900
+ console.error(`interaction ${event.label} completed`);
1901
+ return;
1902
+ case "artifact.created":
1903
+ console.error(`artifact ${event.providerId}:${event.kind}`);
1904
+ return;
1905
+ case "workspace.ready":
1906
+ console.error(`workspace ${event.workspaceId} -> ${event.resourceId}`);
1907
+ return;
1908
+ default:
1909
+ return;
1910
+ }
1911
+ }