@rigkit/cli 0.2.6 → 0.2.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 CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env bun
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, rmSync } from "node:fs";
3
3
  import { dirname, join, relative, resolve } from "node:path";
4
- import chalk from "chalk";
5
- import { Command, CommanderError } from "commander";
4
+ import { Command, CommanderError, Option } from "commander";
6
5
  import inquirer from "inquirer";
7
- import ora from "ora";
6
+ import * as ui from "./ui.ts";
8
7
  import {
9
8
  getOrStartRuntime,
9
+ defaultRigkitHome,
10
10
  type RuntimeClient,
11
11
  } from "@rigkit/runtime-client";
12
12
  import {
@@ -20,15 +20,11 @@ import {
20
20
  type CmuxHostCapabilityHandler,
21
21
  } from "@rigkit/provider-cmux/host";
22
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
23
  import { RIGKIT_CLI_VERSION } from "./version.ts";
29
24
  import { initProject, normalizeMachineName, type InitProjectResult } from "./init.ts";
30
25
  import { openExternalTarget } from "./interaction.ts";
31
26
  import { createRunPresenter, type RunPresenter } from "./run-presenter.ts";
27
+ import { createRunLogger, type RunLogger } from "./run-logger.ts";
32
28
  import {
33
29
  completeRig,
34
30
  formatCompletionItems,
@@ -39,7 +35,7 @@ import {
39
35
  import { generateWorkspaceName } from "./workspace-name.ts";
40
36
 
41
37
  type GlobalOptions = {
42
- project?: string;
38
+ chdir?: string;
43
39
  config?: string;
44
40
  state?: string;
45
41
  json: boolean;
@@ -75,6 +71,12 @@ type ListOptions = {
75
71
  target?: string;
76
72
  };
77
73
 
74
+ type CacheClearOptions = {
75
+ local: boolean;
76
+ global: boolean;
77
+ all: boolean;
78
+ };
79
+
78
80
  type PackageManager = "npm" | "bun" | "pnpm" | "skip";
79
81
 
80
82
  type InitInstallResult = {
@@ -149,10 +151,46 @@ const CLI_HOST_CAPABILITIES: Array<{ id: string; schemaHash?: string }> = [
149
151
  ...(capability.schemaHash ? { schemaHash: capability.schemaHash } : {}),
150
152
  }));
151
153
 
154
+ const TERRAFORM_STYLE_GLOBAL_OPTIONS = new Set(["chdir", "config", "state", "json", "help", "version"]);
155
+ const STATIC_COMMANDS = new Set([
156
+ "init",
157
+ "plan",
158
+ "apply",
159
+ "create",
160
+ "rm",
161
+ "run",
162
+ "ls",
163
+ "cache",
164
+ "projects",
165
+ "doctor",
166
+ "version",
167
+ "help",
168
+ "completion",
169
+ ]);
170
+
152
171
  if (process.argv[2] === "__complete") {
153
172
  runCompletionEndpoint(process.argv.slice(3)).catch(handleCliError);
154
173
  } else {
155
- runCli(process.argv).catch(handleCliError);
174
+ runCli(normalizeCliArgv(process.argv)).catch(handleCliError);
175
+ }
176
+
177
+ function normalizeCliArgv(argv: string[]): string[] {
178
+ const normalized = argv.slice();
179
+ for (let index = 2; index < normalized.length; index += 1) {
180
+ const arg = normalized[index]!;
181
+ if (arg === "--") break;
182
+ if (!arg.startsWith("-")) {
183
+ if (STATIC_COMMANDS.has(arg)) break;
184
+ continue;
185
+ }
186
+
187
+ const match = /^-([A-Za-z][A-Za-z0-9-]*)(=.*)?$/.exec(arg);
188
+ if (!match) continue;
189
+ const name = match[1]!;
190
+ if (!TERRAFORM_STYLE_GLOBAL_OPTIONS.has(name)) continue;
191
+ normalized[index] = `--${name}${match[2] ?? ""}`;
192
+ }
193
+ return normalized;
156
194
  }
157
195
 
158
196
  async function runCli(argv: string[]): Promise<void> {
@@ -160,15 +198,23 @@ async function runCli(argv: string[]): Promise<void> {
160
198
  program
161
199
  .name("rig")
162
200
  .description("Rigkit workflow CLI")
163
- .usage("[options] <command>")
201
+ .usage("[global options] <command> [args]")
164
202
  .version(RIGKIT_CLI_VERSION, "-v, --version", "Show Rigkit CLI version")
165
203
  .showHelpAfterError()
166
204
  .exitOverride()
167
205
  .argument("[command]")
168
- .option("-C, --project <project>", `Project directory containing ${DEFAULT_CONFIG_FILE}`)
169
- .option("--config <config>", "Exact config file to load")
170
- .option("--state <state>", "Local runtime state database path")
171
- .option("--json", "Print machine-readable JSON where supported")
206
+ .addOption(new Option("--chdir <dir>", `Switch to a directory containing ${DEFAULT_CONFIG_FILE} before running the command`).hideHelp())
207
+ .addOption(new Option("--config <file>", "Config file to load, relative to -chdir when set").hideHelp())
208
+ .addOption(new Option("--state <file>", "Local runtime state database path").hideHelp())
209
+ .addOption(new Option("--json", "Print machine-readable JSON where supported").hideHelp())
210
+ .addHelpText("after", [
211
+ "",
212
+ "Global Options:",
213
+ " -chdir=DIR Switch to a directory containing rig.config.ts before running the command",
214
+ " -config=FILE Config file to load, relative to -chdir when set",
215
+ " -state=FILE Local runtime state database path",
216
+ " -json Print machine-readable JSON where supported",
217
+ ].join("\n"))
172
218
  .action(async (command?: string) => {
173
219
  if (command) program.error(`unknown command '${command}'`);
174
220
  await runHelp(makeInvocation(rootOptions(program)));
@@ -225,6 +271,23 @@ async function runCli(argv: string[]): Promise<void> {
225
271
  });
226
272
  });
227
273
 
274
+ program
275
+ .command("rm [workspace]")
276
+ .description("Remove one workspace, several via multi-select, or every workspace")
277
+ .option("-y, --yes", "Remove without confirmation")
278
+ .option("--all", "Remove every workspace in this project")
279
+ .option("--json", "Print machine-readable JSON")
280
+ .action(async (workspace: string | undefined, options: { yes?: boolean; all?: boolean; json?: boolean }) => {
281
+ await runRemove(
282
+ makeInvocation(rootOptions(program), options.json),
283
+ {
284
+ workspace,
285
+ yes: Boolean(options.yes),
286
+ all: Boolean(options.all),
287
+ },
288
+ );
289
+ });
290
+
228
291
  program
229
292
  .command("run <workspace> <operation> [args...]")
230
293
  .description("Run a workspace operation")
@@ -247,6 +310,47 @@ async function runCli(argv: string[]): Promise<void> {
247
310
  await runList(makeInvocation(rootOptions(program), options.json), { target });
248
311
  });
249
312
 
313
+ const cache = program
314
+ .command("cache")
315
+ .description("Inspect and clear Rigkit cache");
316
+
317
+ cache
318
+ .command("ls")
319
+ .description("List cache entries for the selected project config")
320
+ .option("--json", "Print machine-readable JSON")
321
+ .action(async (options: { json?: boolean }) => {
322
+ await runCacheList(makeInvocation(rootOptions(program), options.json));
323
+ });
324
+
325
+ cache
326
+ .command("clear")
327
+ .description("Clear cache entries")
328
+ .option("--local", "Clear local cache entries for the selected config")
329
+ .option("--global", "Clear global cache fragments")
330
+ .option("--all", "With --global, clear every global fragment without loading a config")
331
+ .option("--json", "Print machine-readable JSON")
332
+ .action(async (options: { local?: boolean; global?: boolean; all?: boolean; json?: boolean }) => {
333
+ await runCacheClear(makeInvocation(rootOptions(program), options.json), {
334
+ local: Boolean(options.local),
335
+ global: Boolean(options.global),
336
+ all: Boolean(options.all),
337
+ });
338
+ });
339
+
340
+ cache
341
+ .command("invalidate [step]")
342
+ .description("Invalidate cached task outputs so they re-run on next plan/apply")
343
+ .option("--all", "Invalidate every cached task in this project's workflow")
344
+ .option("-y, --yes", "Skip confirmation when invalidating --all")
345
+ .option("--json", "Print machine-readable JSON")
346
+ .action(async (step: string | undefined, options: { all?: boolean; yes?: boolean; json?: boolean }) => {
347
+ await runCacheInvalidate(makeInvocation(rootOptions(program), options.json), {
348
+ step,
349
+ all: Boolean(options.all),
350
+ yes: Boolean(options.yes),
351
+ });
352
+ });
353
+
250
354
  program
251
355
  .command("projects")
252
356
  .description("Discover Rigkit projects below the current directory")
@@ -291,13 +395,13 @@ async function runCli(argv: string[]): Promise<void> {
291
395
 
292
396
  function rootOptions(program: Command): GlobalOptions {
293
397
  const options = program.opts<{
294
- project?: string;
398
+ chdir?: string;
295
399
  config?: string;
296
400
  state?: string;
297
401
  json?: boolean;
298
402
  }>();
299
403
  return {
300
- project: options.project,
404
+ chdir: options.chdir,
301
405
  config: options.config,
302
406
  state: options.state,
303
407
  json: Boolean(options.json),
@@ -362,11 +466,25 @@ function parseCompletionEndpointArgs(args: string[]): CompletionOptions & { word
362
466
  return { ...options, words };
363
467
  }
364
468
 
469
+ // Errors already rendered to the user (via printRunFailure or similar) carry
470
+ // this sentinel so handleCliError doesn't re-print the message a second time.
471
+ class DisplayedCliError extends Error {
472
+ readonly displayed = true as const;
473
+ constructor(message: string) {
474
+ super(message);
475
+ this.name = "DisplayedCliError";
476
+ }
477
+ }
478
+
365
479
  function handleCliError(error: unknown): void {
366
480
  if (error instanceof CommanderError) {
367
481
  process.exitCode = error.exitCode;
368
482
  return;
369
483
  }
484
+ if (error instanceof DisplayedCliError) {
485
+ process.exitCode = 1;
486
+ return;
487
+ }
370
488
  console.error(error instanceof Error ? error.message : String(error));
371
489
  process.exitCode = 1;
372
490
  }
@@ -413,8 +531,7 @@ async function resolveInitAnswers(
413
531
  }
414
532
 
415
533
  if (!jsonMode) {
416
- console.log(chalk.bold("Initialize Rigkit"));
417
- console.log(chalk.dim("This creates a project folder with rig.config.ts, package.json, and local ignore rules."));
534
+ console.log(`${ui.bold("rig")} ${ui.dim("· initialize")}`);
418
535
  console.log("");
419
536
  }
420
537
 
@@ -443,10 +560,10 @@ function canPrompt(): boolean {
443
560
  function resolveInitProjectPaths(invocation: CliInvocation, name: string): { projectDir: string; configPath: string } {
444
561
  const options = invocation.global;
445
562
  if (options.config) {
446
- throw new Error(`rig init does not support --config. Use -C/--project to choose the parent directory.`);
563
+ throw new Error(`rig init does not support -config. Use -chdir to choose the parent directory.`);
447
564
  }
448
565
 
449
- const parentDir = resolve(process.cwd(), options.project ?? ".");
566
+ const parentDir = resolve(process.cwd(), options.chdir ?? ".");
450
567
  const projectDir = resolve(parentDir, name);
451
568
  return {
452
569
  projectDir,
@@ -540,35 +657,8 @@ async function runPackageManagerInstall(
540
657
  }
541
658
 
542
659
  const command = packageManagerInstallCommand(packageManager);
543
- if (!jsonMode && process.stderr.isTTY) {
544
- const spinner = ora({
545
- text: `installing ${command.join(" ")}`,
546
- stream: process.stderr,
547
- }).start();
548
- const proc = Bun.spawn(command, {
549
- cwd: projectDir,
550
- stdin: "inherit",
551
- stdout: "pipe",
552
- stderr: "pipe",
553
- });
554
- const [exitCode, stdout, stderr] = await Promise.all([
555
- proc.exited,
556
- new Response(proc.stdout).text(),
557
- new Response(proc.stderr).text(),
558
- ]);
559
- if (exitCode !== 0) {
560
- spinner.fail(`${command.join(" ")} failed`);
561
- if (stdout) process.stdout.write(stdout);
562
- if (stderr) process.stderr.write(stderr);
563
- throw new Error(`${command.join(" ")} failed with exit code ${exitCode}`);
564
- }
565
- spinner.succeed(`installed ${command.join(" ")}`);
566
- return { packageManager, command: command.join(" "), skipped: false, reported: true };
567
- }
568
-
569
660
  if (!jsonMode) {
570
- console.log("");
571
- console.log(`${chalk.cyan("installing")} ${command.join(" ")}`);
661
+ process.stderr.write(`${ui.accent(ui.sym.active)} ${ui.dim(`$ ${command.join(" ")}`)}\n`);
572
662
  }
573
663
 
574
664
  const proc = Bun.spawn(command, {
@@ -587,31 +677,43 @@ async function runPackageManagerInstall(
587
677
  }
588
678
 
589
679
  function printInitResult(result: InitProjectResult, install: InitInstallResult): void {
680
+ console.log(`${ui.ok(ui.sym.ok)} ${ui.bold(result.name)} ${ui.dim("ready")}`);
590
681
  console.log("");
591
- console.log(`${chalk.green("Rigkit initialized")} ${chalk.bold(result.name)}`);
592
- printInitLine(result.created.config ? "created" : "updated", result.configPath);
593
- printInitLine(result.created.env ? "created" : result.updated.envApiKey ? "updated" : "kept", result.envPath);
594
- printInitLine(result.created.envExample ? "created" : "kept", result.envExamplePath);
595
- printInitLine(result.created.packageJson ? "created" : result.updated.packageJson ? "updated" : "kept", result.packageJsonPath);
596
- printInitLine(result.created.gitignore ? "created" : result.updated.gitignore ? "updated" : "kept", result.gitignorePath);
597
682
 
683
+ const fileLines = [
684
+ ui.fileStatus(initStatus(result.created.config, false), shortPath(result.configPath)),
685
+ ui.fileStatus(initStatus(result.created.env, result.updated.envApiKey), shortPath(result.envPath)),
686
+ ui.fileStatus(initStatus(result.created.envExample, false), shortPath(result.envExamplePath)),
687
+ ui.fileStatus(initStatus(result.created.packageJson, result.updated.packageJson), shortPath(result.packageJsonPath)),
688
+ ui.fileStatus(initStatus(result.created.gitignore, result.updated.gitignore), shortPath(result.gitignorePath)),
689
+ ];
598
690
  if (result.updated.sdkDependency) {
599
- console.log(`${chalk.green("pinned")} ${PROJECT_PACKAGE_NAME}@${RIGKIT_CLI_VERSION}`);
691
+ fileLines.push(ui.fileStatus("pinned", `${PROJECT_PACKAGE_NAME}@${RIGKIT_CLI_VERSION}`));
600
692
  }
693
+ for (const line of fileLines) console.log(line);
601
694
 
602
- if (install.skipped) {
603
- console.log(`${chalk.dim("install")} skipped`);
604
- } else if (install.command && !install.reported) {
605
- console.log(`${chalk.green("installed")} ${install.command}`);
695
+ if (!install.skipped && install.command && !install.reported) {
696
+ console.log(ui.fileStatus("created", install.command));
606
697
  }
607
698
 
608
699
  console.log("");
609
- console.log(chalk.bold("Next steps"));
610
- console.log(` cd ${displayProjectDir(result.projectDir)}`);
700
+ console.log(ui.bold("Next"));
701
+ console.log(ui.hint(`cd ${displayProjectDir(result.projectDir)}`));
611
702
  if (install.skipped) {
612
- console.log(` ${detectInstallCommand(result.packageJsonPath)}`);
703
+ console.log(ui.hint(detectInstallCommand(result.packageJsonPath)));
613
704
  }
614
- console.log(" rig plan");
705
+ console.log(ui.hint("rig plan"));
706
+ }
707
+
708
+ function initStatus(created: boolean, updated: boolean): ui.FileStatus {
709
+ if (created) return "created";
710
+ if (updated) return "updated";
711
+ return "kept";
712
+ }
713
+
714
+ function shortPath(path: string): string {
715
+ const rel = relative(process.cwd(), path);
716
+ return rel && !rel.startsWith("..") ? rel : path;
615
717
  }
616
718
 
617
719
  function displayProjectDir(projectDir: string): string {
@@ -619,11 +721,6 @@ function displayProjectDir(projectDir: string): string {
619
721
  return path && !path.startsWith("..") ? path : projectDir;
620
722
  }
621
723
 
622
- function printInitLine(status: "created" | "updated" | "kept", path: string): void {
623
- const color = status === "kept" ? chalk.dim : status === "updated" ? chalk.yellow : chalk.green;
624
- console.log(`${color(status.padEnd(7))} ${path}`);
625
- }
626
-
627
724
  function detectInstallCommand(packageJsonPath: string): string {
628
725
  const projectDir = dirname(packageJsonPath);
629
726
  if (existsSync(join(projectDir, "bun.lock")) || existsSync(join(projectDir, "bun.lockb"))) return "bun install";
@@ -654,24 +751,17 @@ async function runProjectOperation(
654
751
  args: string[],
655
752
  options: RunOptions,
656
753
  ): Promise<void> {
657
- const remote = splitGithubProjectTarget(args);
658
- if ((options.all || options.discover) && remote.target) {
659
- throw new Error(`Remote GitHub project targets cannot be combined with --all or --discover`);
660
- }
661
-
662
754
  if (options.all || options.discover) {
663
- await runDiscoveredProjectOperation(invocation, requestedOperation, remote.args, { all: options.all });
755
+ await runDiscoveredProjectOperation(invocation, requestedOperation, args, { all: options.all });
664
756
  return;
665
757
  }
666
758
 
667
- const runtime = remote.target
668
- ? await loadGithubRuntime(invocation, remote.target)
669
- : await loadRuntime(invocation);
759
+ const runtime = await loadRuntime(invocation);
670
760
  const { operation, parsed, result } = await executeRuntimeOperation(
671
761
  invocation,
672
762
  runtime,
673
763
  requestedOperation,
674
- remote.args,
764
+ args,
675
765
  );
676
766
 
677
767
  if (wantsJson(invocation)) {
@@ -680,6 +770,9 @@ async function runProjectOperation(
680
770
  }
681
771
 
682
772
  await renderOperationResult(operation, result, parsed.hostOptions);
773
+ if (operation.createsWorkspace && isWorkspaceRecord(result)) {
774
+ await printWorkspaceNextSteps(runtime, result.name);
775
+ }
683
776
  printInteractiveOutputGap(invocation);
684
777
  }
685
778
 
@@ -724,6 +817,123 @@ async function runWorkspaceOperation(
724
817
  printInteractiveOutputGap(invocation);
725
818
  }
726
819
 
820
+ async function runRemove(
821
+ invocation: CliInvocation,
822
+ options: { workspace?: string; yes: boolean; all: boolean },
823
+ ): Promise<void> {
824
+ if (options.workspace && options.all) {
825
+ throw new Error(`rig rm accepts either a workspace name or --all, not both`);
826
+ }
827
+
828
+ if (options.workspace) {
829
+ await runRemoveWorkspaceOperation(invocation, options.workspace, { yes: options.yes });
830
+ return;
831
+ }
832
+
833
+ const runtime = await loadRuntime(invocation);
834
+ const workspaces = await runtime.control.workspaces()
835
+ .then((response) => response.workspaces as WorkspaceRecord[])
836
+ .catch(() => []);
837
+ if (workspaces.length === 0) {
838
+ if (wantsJson(invocation)) {
839
+ printJson({ removed: [] });
840
+ return;
841
+ }
842
+ console.log(ui.dim("no workspaces"));
843
+ return;
844
+ }
845
+
846
+ let targets: string[];
847
+ if (options.all) {
848
+ if (!options.yes && !wantsJson(invocation) && canPrompt()) {
849
+ const confirmed = await promptHostConfirm({
850
+ message: `Remove all ${workspaces.length} workspaces?`,
851
+ defaultValue: false,
852
+ });
853
+ if (!confirmed) throw new Error("Remove cancelled");
854
+ } else if (!options.yes && !canPrompt()) {
855
+ throw new Error(`rig rm --all needs --yes when not running in an interactive terminal`);
856
+ }
857
+ targets = workspaces.map((workspace) => workspace.name);
858
+ } else {
859
+ if (wantsJson(invocation) || !canPrompt()) {
860
+ throw new Error(`rig rm needs a workspace name or --all when not running in an interactive terminal`);
861
+ }
862
+ targets = await promptWorkspaceRemoveSelection(workspaces);
863
+ if (targets.length === 0) throw new Error("Nothing selected");
864
+ }
865
+
866
+ const removed: string[] = [];
867
+ for (const name of targets) {
868
+ await runRemoveWorkspaceOperation(invocation, name, { yes: true });
869
+ removed.push(name);
870
+ }
871
+ if (wantsJson(invocation)) printJson({ removed });
872
+ }
873
+
874
+ async function promptWorkspaceRemoveSelection(
875
+ workspaces: ReadonlyArray<Pick<WorkspaceRecord, "name" | "workflow">>,
876
+ ): Promise<string[]> {
877
+ const answers = await inquirer.prompt<{ names: string[] }>([{
878
+ type: "checkbox",
879
+ name: "names",
880
+ message: "Select workspaces to remove",
881
+ choices: workspaces.map((workspace) => ({
882
+ name: workspace.name,
883
+ value: workspace.name,
884
+ description: workspace.workflow ? `workflow ${workspace.workflow}` : undefined,
885
+ })),
886
+ }]);
887
+ return answers.names;
888
+ }
889
+
890
+ async function runRemoveWorkspaceOperation(
891
+ invocation: CliInvocation,
892
+ workspaceName: string,
893
+ options: { yes: boolean },
894
+ ): Promise<void> {
895
+ const runtime = await loadRuntime(invocation);
896
+ const manifest = await readRuntimeOperations(runtime);
897
+ const operation = (manifest.workspaceOperations ?? []).find((item) => item.id === "remove");
898
+ if (!operation) {
899
+ throw new Error(`This project does not define a removable workspace.`);
900
+ }
901
+
902
+ const workspaces = await runtime.control.workspaces()
903
+ .then((response) => response.workspaces as WorkspaceRecord[])
904
+ .catch(() => []);
905
+ if (!workspaces.some((workspace) => workspace.name === workspaceName)) {
906
+ throw new Error(`This project does not have a workspace named "${workspaceName}".`);
907
+ }
908
+
909
+ if (!options.yes && !wantsJson(invocation) && canPrompt()) {
910
+ const confirmed = await promptHostConfirm({
911
+ message: `Remove workspace ${workspaceName}?`,
912
+ defaultValue: false,
913
+ });
914
+ if (!confirmed) throw new Error("Remove cancelled");
915
+ options = { yes: true };
916
+ }
917
+
918
+ const parsed = parseOperationArgs(operation, options.yes ? ["--yes"] : []);
919
+ enforceHostOnlyBooleanGuards(operation, parsed);
920
+
921
+ const result = await runRuntimeOperation<unknown>(
922
+ runtime,
923
+ `${workspaceName}/remove`,
924
+ parsed.input,
925
+ { renderEvents: !wantsJson(invocation) },
926
+ );
927
+
928
+ if (wantsJson(invocation)) {
929
+ printJson(result);
930
+ return;
931
+ }
932
+
933
+ await renderOperationResult(operation, result, parsed.hostOptions);
934
+ printInteractiveOutputGap(invocation);
935
+ }
936
+
727
937
  async function runDiscoveredProjectOperation(
728
938
  invocation: CliInvocation,
729
939
  requestedOperation: string,
@@ -731,7 +941,7 @@ async function runDiscoveredProjectOperation(
731
941
  options: { all: boolean },
732
942
  ): Promise<void> {
733
943
  const projects = discoverProjectConfigs({
734
- project: invocation.global.project,
944
+ chdir: invocation.global.chdir,
735
945
  config: invocation.global.config,
736
946
  });
737
947
  if (projects.length === 0) {
@@ -740,7 +950,7 @@ async function runDiscoveredProjectOperation(
740
950
  if (!options.all && projects.length > 1) {
741
951
  throw new Error([
742
952
  "Multiple Rigkit projects found.",
743
- "Use `rig projects` to list candidates, pass -C/--project or --config to select one, or pass --all to run every discovered project.",
953
+ "Use `rig projects` to list candidates, pass -chdir or -config to select one, or pass --all to run every discovered project.",
744
954
  ...projects.map((project) => `- ${project.configPath}`),
745
955
  ].join("\n"));
746
956
  }
@@ -758,7 +968,7 @@ async function runDiscoveredProjectOperation(
758
968
  const runtime = await getOrStartRuntime({
759
969
  projectDir: project.projectDir,
760
970
  configPath: project.configPath,
761
- statePath: invocation.global.state ? resolve(process.cwd(), invocation.global.state) : undefined,
971
+ statePath: invocation.global.state ? resolveGlobalPath(invocation, invocation.global.state) : undefined,
762
972
  });
763
973
  const { operation, parsed, result } = await executeRuntimeOperation(
764
974
  invocation,
@@ -774,7 +984,7 @@ async function runDiscoveredProjectOperation(
774
984
 
775
985
  if (!wantsJson(invocation)) {
776
986
  if (projects.length > 1) {
777
- console.log(chalk.bold(displayProjectDir(project.projectDir)));
987
+ console.log(ui.bold(displayProjectDir(project.projectDir)));
778
988
  }
779
989
  await renderOperationResult(operation, result, parsed.hostOptions);
780
990
  printInteractiveOutputGap(invocation);
@@ -788,7 +998,7 @@ async function runDiscoveredProjectOperation(
788
998
 
789
999
  async function runProjects(invocation: CliInvocation): Promise<void> {
790
1000
  const projects = discoverProjectConfigs({
791
- project: invocation.global.project,
1001
+ chdir: invocation.global.chdir,
792
1002
  config: invocation.global.config,
793
1003
  });
794
1004
  if (wantsJson(invocation)) {
@@ -796,13 +1006,14 @@ async function runProjects(invocation: CliInvocation): Promise<void> {
796
1006
  return;
797
1007
  }
798
1008
  if (projects.length === 0) {
799
- console.log("No Rigkit projects found.");
1009
+ console.log(ui.dim("no Rigkit projects found"));
800
1010
  return;
801
1011
  }
802
- printTable(["project", "config"], projects.map((project) => [
803
- project.projectDir,
804
- project.configPath,
805
- ]));
1012
+ const rows = projects.map((project) => [
1013
+ { text: project.projectDir, style: ui.bold },
1014
+ { text: project.configPath, style: ui.dim },
1015
+ ]);
1016
+ console.log(ui.columns(["project", "config"], rows));
806
1017
  }
807
1018
 
808
1019
  async function runList(invocation: CliInvocation, options: ListOptions): Promise<void> {
@@ -837,6 +1048,123 @@ async function runList(invocation: CliInvocation, options: ListOptions): Promise
837
1048
  printConfig(project);
838
1049
  }
839
1050
 
1051
+ async function runCacheList(invocation: CliInvocation): Promise<void> {
1052
+ const runtime = await loadRuntime(invocation);
1053
+ const cache = await runtime.control.cache();
1054
+ if (wantsJson(invocation)) {
1055
+ printJson(cache);
1056
+ return;
1057
+ }
1058
+ printCacheEntries(cache.entries);
1059
+ }
1060
+
1061
+ async function runCacheClear(invocation: CliInvocation, options: CacheClearOptions): Promise<void> {
1062
+ if (options.all && !options.global) {
1063
+ throw new Error(`rig cache clear --all must be combined with --global`);
1064
+ }
1065
+ if (options.local && options.global && !options.all) {
1066
+ throw new Error(`Choose --local or --global, not both`);
1067
+ }
1068
+
1069
+ if (options.global && options.all) {
1070
+ if (invocation.global.chdir || invocation.global.config || invocation.global.state) {
1071
+ throw new Error(`rig cache clear --global --all cannot be combined with -chdir, -config, or -state`);
1072
+ }
1073
+ const fragmentRoot = join(defaultRigkitHome(), "fragments");
1074
+ rmSync(fragmentRoot, { recursive: true, force: true });
1075
+ if (wantsJson(invocation)) {
1076
+ printJson({ ok: true, deleted: null, scope: "global-all", fragmentRoot });
1077
+ return;
1078
+ }
1079
+ console.log(`Cleared global fragment cache at ${fragmentRoot}`);
1080
+ return;
1081
+ }
1082
+
1083
+ const scope = options.local ? "local" : options.global ? "global" : "all";
1084
+ const runtime = await loadRuntime(invocation);
1085
+ const result = await runtime.control.clearCache({ scope });
1086
+ if (wantsJson(invocation)) {
1087
+ printJson(result);
1088
+ return;
1089
+ }
1090
+ console.log(`Cleared ${result.deleted} cache ${result.deleted === 1 ? "entry" : "entries"}.`);
1091
+ }
1092
+
1093
+ type CacheInvalidateOptions = {
1094
+ step?: string;
1095
+ all: boolean;
1096
+ yes: boolean;
1097
+ };
1098
+
1099
+ async function runCacheInvalidate(invocation: CliInvocation, options: CacheInvalidateOptions): Promise<void> {
1100
+ if (options.step && options.all) {
1101
+ throw new Error(`rig cache invalidate accepts either a step or --all, not both`);
1102
+ }
1103
+
1104
+ const runtime = await loadRuntime(invocation);
1105
+ let targets: string[] = [];
1106
+
1107
+ if (options.step) {
1108
+ targets = [options.step];
1109
+ } else if (options.all) {
1110
+ if (!options.yes && !wantsJson(invocation) && canPrompt()) {
1111
+ const confirmed = await promptHostConfirm({
1112
+ message: "Invalidate every cached task in this project?",
1113
+ defaultValue: false,
1114
+ });
1115
+ if (!confirmed) throw new Error("Invalidate cancelled");
1116
+ } else if (!options.yes && !canPrompt()) {
1117
+ throw new Error(`rig cache invalidate --all needs --yes when not running in an interactive terminal`);
1118
+ }
1119
+ targets = []; // empty = engine invalidates everything for the workflow
1120
+ } else {
1121
+ // Interactive: multi-select among currently-valid cache entries.
1122
+ const cache = await runtime.control.cache();
1123
+ const candidates = cache.entries
1124
+ .filter((entry) => !entry.invalidated && entry.scope === "local")
1125
+ .map((entry) => ({ path: entry.nodePath || entry.nodeName, workflow: entry.workflow }));
1126
+ if (candidates.length === 0) {
1127
+ if (wantsJson(invocation)) {
1128
+ printJson({ ok: true, invalidated: 0 });
1129
+ return;
1130
+ }
1131
+ console.log(ui.dim("no valid cache entries to invalidate"));
1132
+ return;
1133
+ }
1134
+ if (wantsJson(invocation) || !canPrompt()) {
1135
+ throw new Error(`rig cache invalidate needs a step name or --all when not running in an interactive terminal`);
1136
+ }
1137
+ const picked = await promptCacheInvalidateSelection(candidates);
1138
+ if (picked.length === 0) throw new Error("Nothing selected");
1139
+ targets = picked;
1140
+ }
1141
+
1142
+ const result = await runtime.control.invalidateCache({ nodePaths: targets });
1143
+ if (wantsJson(invocation)) {
1144
+ printJson(result);
1145
+ return;
1146
+ }
1147
+ console.log(
1148
+ `${ui.ok(ui.sym.ok)} invalidated ${result.invalidated} cache ${result.invalidated === 1 ? "entry" : "entries"}`,
1149
+ );
1150
+ }
1151
+
1152
+ async function promptCacheInvalidateSelection(
1153
+ candidates: Array<{ path: string; workflow: string }>,
1154
+ ): Promise<string[]> {
1155
+ const answers = await inquirer.prompt<{ paths: string[] }>([{
1156
+ type: "checkbox",
1157
+ name: "paths",
1158
+ message: "Select tasks to invalidate",
1159
+ choices: candidates.map((c) => ({
1160
+ name: c.path,
1161
+ value: c.path,
1162
+ description: c.workflow ? `workflow ${c.workflow}` : undefined,
1163
+ })),
1164
+ }]);
1165
+ return answers.paths;
1166
+ }
1167
+
840
1168
  async function executeRuntimeOperation(
841
1169
  invocation: CliInvocation,
842
1170
  runtime: RuntimeClient,
@@ -1069,7 +1397,9 @@ async function renderOperationResult(
1069
1397
  }
1070
1398
 
1071
1399
  if (operation.createsWorkspace && isWorkspaceRecord(result)) {
1072
- console.log(result.name);
1400
+ // Only emit the bareword name when stdout is piped, so scripts can do
1401
+ // `name=$(rig create)` while TTY users aren't shown a redundant line.
1402
+ if (!process.stdout.isTTY) console.log(result.name);
1073
1403
  return;
1074
1404
  }
1075
1405
 
@@ -1085,7 +1415,7 @@ async function renderOperationResult(
1085
1415
  }
1086
1416
 
1087
1417
  if (isWorkspaceRecord(result)) {
1088
- console.log(result.name);
1418
+ if (!process.stdout.isTTY) console.log(result.name);
1089
1419
  return;
1090
1420
  }
1091
1421
 
@@ -1104,12 +1434,12 @@ async function runDoctor(invocation: CliInvocation, options: DoctorOptions): Pro
1104
1434
  printJson(diagnostics);
1105
1435
  return;
1106
1436
  }
1107
- printTable(["key", "value"], [
1437
+ console.log(ui.kvList([
1108
1438
  ["cli", diagnostics.cliVersion],
1109
1439
  ["binary", diagnostics.binary ?? ""],
1110
1440
  ["node", diagnostics.node],
1111
1441
  ["bun", diagnostics.bun ?? ""],
1112
- ]);
1442
+ ]));
1113
1443
  return;
1114
1444
  }
1115
1445
 
@@ -1137,7 +1467,7 @@ async function runDoctor(invocation: CliInvocation, options: DoctorOptions): Pro
1137
1467
  return;
1138
1468
  }
1139
1469
 
1140
- printTable(["key", "value"], [
1470
+ console.log(ui.kvList([
1141
1471
  ["cli", RIGKIT_CLI_VERSION],
1142
1472
  ["project", project.projectDir],
1143
1473
  ["config", project.configPath],
@@ -1150,7 +1480,7 @@ async function runDoctor(invocation: CliInvocation, options: DoctorOptions): Pro
1150
1480
  ["protocol", runtimeInfo.protocolHash],
1151
1481
  ["state", project.statePath ?? ""],
1152
1482
  ["expires", health.expiresAt ?? runtime.handle.expiresAt ?? ""],
1153
- ]);
1483
+ ]));
1154
1484
  }
1155
1485
 
1156
1486
  async function runVersion(invocation: CliInvocation): Promise<void> {
@@ -1172,8 +1502,10 @@ async function runHelp(invocation: CliInvocation): Promise<void> {
1172
1502
  { name: "plan", description: "Plan project workflow changes" },
1173
1503
  { name: "apply", description: "Apply project workflow changes" },
1174
1504
  { name: "create", description: "Create a workspace" },
1505
+ { name: "rm", description: "Remove a workspace" },
1175
1506
  { name: "run", description: "Run a workspace operation" },
1176
1507
  { name: "ls", description: "List project workspaces" },
1508
+ { name: "cache", description: "Inspect and clear Rigkit cache" },
1177
1509
  { name: "projects", description: "Discover Rigkit projects below the current directory" },
1178
1510
  { name: "doctor", description: "Show Rigkit runtime diagnostics" },
1179
1511
  { name: "version", description: "Show Rigkit CLI version" },
@@ -1182,30 +1514,37 @@ async function runHelp(invocation: CliInvocation): Promise<void> {
1182
1514
  });
1183
1515
  return;
1184
1516
  }
1517
+ const cmd = (name: string, description: string): string =>
1518
+ ` ${ui.bold(name.padEnd(10))} ${description}`;
1519
+ const opt = (flag: string, description: string): string =>
1520
+ ` ${ui.bold(flag.padEnd(12))} ${description}`;
1521
+
1185
1522
  console.log([
1186
- `rig ${RIGKIT_CLI_VERSION}`,
1523
+ `${ui.bold("rig")} ${ui.dim(RIGKIT_CLI_VERSION)}`,
1187
1524
  "",
1188
- "Usage:",
1189
- " rig [options] <command>",
1525
+ ui.dim("Usage:"),
1526
+ ` ${ui.accent(ui.sym.prompt)} rig [global options] <command> [args]`,
1190
1527
  "",
1191
- "Commands:",
1192
- " help Show Rigkit CLI help",
1193
- " init Initialize a Rigkit project",
1194
- " plan Plan project workflow changes",
1195
- " apply Apply project workflow changes",
1196
- " create Create a workspace",
1197
- " run Run a workspace operation",
1198
- " ls List project workspaces",
1199
- " projects Discover Rigkit projects below the current directory",
1200
- " doctor Show Rigkit runtime diagnostics",
1201
- " version Show Rigkit CLI version",
1202
- " completion Generate shell completion script",
1528
+ ui.dim("Commands:"),
1529
+ cmd("help", "Show Rigkit CLI help"),
1530
+ cmd("init", "Initialize a Rigkit project"),
1531
+ cmd("plan", "Plan project workflow changes"),
1532
+ cmd("apply", "Apply project workflow changes"),
1533
+ cmd("create", "Create a workspace"),
1534
+ cmd("rm", "Remove a workspace"),
1535
+ cmd("run", "Run a workspace operation"),
1536
+ cmd("ls", "List project workspaces"),
1537
+ cmd("cache", "Inspect and clear Rigkit cache"),
1538
+ cmd("projects", "Discover Rigkit projects below the current directory"),
1539
+ cmd("doctor", "Show Rigkit runtime diagnostics"),
1540
+ cmd("version", "Show Rigkit CLI version"),
1541
+ cmd("completion", "Generate shell completion script"),
1203
1542
  "",
1204
- "Options:",
1205
- " -C, --project <dir> Project directory containing rig.config.ts",
1206
- " --config <file> Exact config file to load",
1207
- " --state <file> Local runtime state database path",
1208
- " --json Print machine-readable JSON where supported",
1543
+ ui.dim("Options:"),
1544
+ opt("-chdir=DIR", "Switch to a directory containing rig.config.ts before running the command"),
1545
+ opt("-config=FILE", "Config file to load, relative to -chdir when set"),
1546
+ opt("-state=FILE", "Local runtime state database path"),
1547
+ opt("-json", "Print machine-readable JSON where supported"),
1209
1548
  ].join("\n"));
1210
1549
  }
1211
1550
 
@@ -1214,50 +1553,6 @@ async function loadRuntime(invocation: CliInvocation): Promise<RuntimeClient> {
1214
1553
  return await getOrStartRuntime(engineOptions);
1215
1554
  }
1216
1555
 
1217
- async function loadGithubRuntime(invocation: CliInvocation, target: GithubProjectTarget): Promise<RuntimeClient> {
1218
- if (invocation.global.project || invocation.global.config || invocation.global.state) {
1219
- throw new Error(`Remote GitHub project targets cannot be combined with -C/--project, --config, or --state`);
1220
- }
1221
-
1222
- await confirmGithubProjectTarget(target);
1223
- const project = await materializeGithubProject(target);
1224
- if (!wantsJson(invocation)) {
1225
- console.error(`github ${target.owner}/${target.repo}@${project.commitSha.slice(0, 12)}`);
1226
- }
1227
- return await getOrStartRuntime({
1228
- projectDir: project.projectDir,
1229
- configPath: project.configPath,
1230
- statePath: project.statePath,
1231
- source: {
1232
- kind: "github",
1233
- target: target.raw,
1234
- repoUrl: project.repoUrl,
1235
- ref: project.ref,
1236
- commitSha: project.commitSha,
1237
- },
1238
- });
1239
- }
1240
-
1241
- async function confirmGithubProjectTarget(target: GithubProjectTarget): Promise<void> {
1242
- if (process.env.RIGKIT_TRUST_REMOTE_CONFIGS === "1") return;
1243
- const repo = `https://github.com/${target.owner}/${target.repo}`;
1244
- const ref = target.ref ? ` at ${target.ref}` : "";
1245
- const message = `Run and install dependencies for ${repo}${ref}?`;
1246
-
1247
- if (!canPrompt()) {
1248
- throw new Error(
1249
- `Remote GitHub project ${target.raw} executes code on this machine. ` +
1250
- `Run from an interactive terminal or set RIGKIT_TRUST_REMOTE_CONFIGS=1 to allow it explicitly.`,
1251
- );
1252
- }
1253
-
1254
- console.error("");
1255
- console.error(chalk.yellow("Remote Rigkit configs execute code on this machine."));
1256
- console.error(`Project: ${repo}${ref}`);
1257
- const allowed = await promptHostConfirm({ message, defaultValue: false });
1258
- if (!allowed) throw new Error(`Remote GitHub project denied`);
1259
- }
1260
-
1261
1556
  async function readRuntimeProject(runtime: RuntimeClient): Promise<EngineProjectInfo> {
1262
1557
  return await runtime.control.project() as EngineProjectInfo;
1263
1558
  }
@@ -1276,14 +1571,33 @@ async function runRuntimeOperation<T>(
1276
1571
  let presenter: RunPresenter | undefined = options.renderEvents
1277
1572
  ? createRunPresenter(operation)
1278
1573
  : undefined;
1574
+ const logger: RunLogger | undefined = createRunLogger({
1575
+ projectDir: runtime.handle.projectDir,
1576
+ operation,
1577
+ daemonStderrPath: runtime.paths.runtimeLogPath,
1578
+ });
1579
+ if (logger) {
1580
+ logger.append({ type: "run.started", runId: started.runId, operation, input });
1581
+ }
1279
1582
  let result: T | undefined;
1280
1583
  let failure: Error | undefined;
1584
+ let failureCode: string | undefined;
1585
+ let activeNodePath: string | undefined;
1281
1586
 
1282
1587
  const handleEvent = async (
1283
1588
  event: unknown,
1284
1589
  respond?: (id: string, response: unknown) => void | Promise<void>,
1285
1590
  sendSession?: (message: unknown) => void | Promise<void>,
1286
1591
  ) => {
1592
+ logger?.append(event);
1593
+ if (isRecord(event)) {
1594
+ if (event.type === "node.started" && typeof event.nodePath === "string") {
1595
+ activeNodePath = event.nodePath;
1596
+ }
1597
+ if (event.type === "node.completed" && event.nodePath === activeNodePath) {
1598
+ activeNodePath = undefined;
1599
+ }
1600
+ }
1287
1601
  if (isHostRequestEvent(event)) {
1288
1602
  const suspendPresenter = hostRequestNeedsTerminal(event);
1289
1603
  if (suspendPresenter) presenter?.pause();
@@ -1331,6 +1645,9 @@ async function runRuntimeOperation<T>(
1331
1645
  ? event.error.message
1332
1646
  : "Runtime operation failed";
1333
1647
  failure = new Error(message);
1648
+ failureCode = isRecord(event.error) && typeof event.error.code === "string"
1649
+ ? event.error.code
1650
+ : undefined;
1334
1651
  presenter?.render({ ...event, type: "run.failed" });
1335
1652
  return;
1336
1653
  }
@@ -1379,14 +1696,85 @@ async function runRuntimeOperation<T>(
1379
1696
  }
1380
1697
  } finally {
1381
1698
  presenter?.close();
1699
+ if (logger) {
1700
+ logger.finish({
1701
+ status: failure ? "failed" : "completed",
1702
+ error: failure,
1703
+ result,
1704
+ });
1705
+ logger.close();
1706
+ }
1382
1707
  uninstallRunCancelHandler();
1383
1708
  }
1384
1709
 
1385
- if (failure) throw failure;
1710
+ if (failure) {
1711
+ printRunFailure({
1712
+ operation,
1713
+ node: activeNodePath,
1714
+ code: failureCode,
1715
+ message: failure.message,
1716
+ logPath: logger?.path,
1717
+ });
1718
+ throw new DisplayedCliError(failure.message);
1719
+ }
1386
1720
  if (result === undefined) throw new Error(`Runtime operation ${operation} finished without a result`);
1387
1721
  return result;
1388
1722
  }
1389
1723
 
1724
+ function printRunFailure(input: {
1725
+ operation: string;
1726
+ node: string | undefined;
1727
+ code: string | undefined;
1728
+ message: string;
1729
+ logPath: string | undefined;
1730
+ }): void {
1731
+ const pairs: Array<[string, string]> = [];
1732
+ if (input.node) pairs.push(["node", input.node]);
1733
+ if (input.code) pairs.push(["code", ui.bold(input.code)]);
1734
+ pairs.push(["reason", input.message]);
1735
+
1736
+ process.stderr.write("\n");
1737
+ process.stderr.write(`${ui.err(ui.sym.err)} ${ui.bold(`${input.operation} failed`)}\n`);
1738
+ process.stderr.write(`${ui.kvList(pairs)}\n`);
1739
+ if (input.logPath) {
1740
+ process.stderr.write("\n");
1741
+ process.stderr.write(`${ui.dim("full log")} ${shortPath(input.logPath)}\n`);
1742
+ process.stderr.write(`${ui.dim(" ")} ${ui.dim("daemon stderr appended on failure")}\n`);
1743
+ }
1744
+ }
1745
+
1746
+ // After a successful `rig create`, list the workspace's available operations so
1747
+ // the user doesn't have to guess what to do next. TTY-only — JSON consumers and
1748
+ // pipes get clean output.
1749
+ async function printWorkspaceNextSteps(runtime: RuntimeClient, workspaceName: string): Promise<void> {
1750
+ if (!process.stderr.isTTY) return;
1751
+
1752
+ let manifest: RuntimeOperationManifest;
1753
+ try {
1754
+ manifest = await readRuntimeOperations(runtime);
1755
+ } catch {
1756
+ return;
1757
+ }
1758
+
1759
+ const ops = (manifest.workspaceOperations ?? []).filter((op) => op.id !== "remove");
1760
+ const invocations: Array<{ command: string; description: string }> = ops.map((op) => ({
1761
+ command: `rig run ${workspaceName} ${op.id}`,
1762
+ description: op.description ?? op.title ?? "",
1763
+ }));
1764
+ invocations.push({ command: `rig rm ${workspaceName}`, description: "Remove this workspace" });
1765
+
1766
+ const commandWidth = invocations.reduce((max, item) => Math.max(max, item.command.length), 0);
1767
+
1768
+ process.stderr.write("\n");
1769
+ process.stderr.write(`${ui.bold("Next")}\n`);
1770
+ for (const item of invocations) {
1771
+ const command = `${ui.bold("rig")}${item.command.slice("rig".length)}`;
1772
+ const padding = " ".repeat(Math.max(0, commandWidth - item.command.length));
1773
+ const tail = item.description ? ` ${ui.dim(item.description)}` : "";
1774
+ process.stderr.write(`${ui.dim(ui.sym.arrow)} ${command}${padding}${tail}\n`);
1775
+ }
1776
+ }
1777
+
1390
1778
  let uninstallActiveRunCancelHandler: (() => void) | undefined;
1391
1779
 
1392
1780
  function installRunCancelHandler(session: { send(message: unknown): void; close(code?: number, reason?: string): void }): void {
@@ -1418,13 +1806,17 @@ function resolveEngineOptions(invocation: CliInvocation): { projectDir: string;
1418
1806
  return {
1419
1807
  projectDir: paths.projectDir,
1420
1808
  configPath: paths.configPath,
1421
- statePath: options.state ? resolve(process.cwd(), options.state) : undefined,
1809
+ statePath: options.state ? resolveGlobalPath(invocation, options.state) : undefined,
1422
1810
  };
1423
1811
  }
1424
1812
 
1425
1813
  function resolveCommandConfigPaths(invocation: CliInvocation): { projectDir: string; configPath: string } {
1426
1814
  const options = invocation.global;
1427
- return resolveConfigPaths({ project: options.project, config: options.config });
1815
+ return resolveConfigPaths({ chdir: options.chdir, config: options.config });
1816
+ }
1817
+
1818
+ function resolveGlobalPath(invocation: CliInvocation, path: string): string {
1819
+ return resolve(process.cwd(), invocation.global.chdir ?? ".", path);
1428
1820
  }
1429
1821
 
1430
1822
  type HostRequestEvent = {
@@ -1798,15 +2190,21 @@ async function confirmHostCommand(input: {
1798
2190
  throw new Error(`Host command requires confirmation in an interactive terminal`);
1799
2191
  }
1800
2192
 
1801
- console.error("");
1802
- console.error(chalk.yellow("This Rigkit config is asking to run a command on this machine."));
1803
- console.error(`${chalk.bold("Command:")} ${input.argv.map(shellDisplay).join(" ")}`);
1804
- console.error(`${chalk.bold("Mode:")} ${input.mode}`);
1805
- if (input.cwd) console.error(`${chalk.bold("cwd:")} ${input.cwd}`);
2193
+ const pairs: Array<[string, string]> = [
2194
+ ["command", input.argv.map(shellDisplay).join(" ")],
2195
+ ["mode", input.mode],
2196
+ ];
2197
+ if (input.cwd) pairs.push(["cwd", input.cwd]);
1806
2198
  if (input.env && Object.keys(input.env).length > 0) {
1807
- console.error(`${chalk.bold("env:")} ${Object.keys(input.env).join(", ")}`);
2199
+ pairs.push(["env", Object.keys(input.env).join(", ")]);
1808
2200
  }
1809
- if (input.reason) console.error(`${chalk.bold("Reason:")} ${input.reason}`);
2201
+ if (input.reason) pairs.push(["reason", input.reason]);
2202
+
2203
+ console.error("");
2204
+ console.error(`${ui.warn("!")} ${ui.bold("this config wants to run a command on your machine")}`);
2205
+ console.error("");
2206
+ console.error(ui.kvList(pairs));
2207
+ console.error("");
1810
2208
  return await promptHostConfirm({ message: "Allow?", defaultValue: false });
1811
2209
  }
1812
2210
 
@@ -1882,82 +2280,125 @@ function printInteractiveOutputGap(invocation: CliInvocation): void {
1882
2280
  }
1883
2281
 
1884
2282
  function printPlan(plan: WorkflowPlan): void {
1885
- console.log(`${plan.workflow}: ${plan.cachedNodeCount}/${plan.nodeCount} nodes cached`);
2283
+ console.log(`${ui.bold(plan.workflow)} ${ui.dim(`${plan.cachedNodeCount}/${plan.nodeCount} cached`)}`);
2284
+ console.log("");
1886
2285
 
1887
2286
  const rows = plan.nodes.map((node) => [
1888
- String(node.index + 1),
1889
- node.status,
1890
- node.path,
1891
- node.reason ?? "",
2287
+ { text: String(node.index + 1), style: ui.dim },
2288
+ { text: node.status, style: planStatusStyle(node.status) },
2289
+ { text: node.path },
2290
+ { text: node.reason ?? "", style: ui.dim },
1892
2291
  ]);
1893
- printTable(["#", "status", "node", "reason"], rows);
2292
+ console.log(ui.columns(["#", "status", "node", "reason"], rows));
2293
+ }
2294
+
2295
+ function planStatusStyle(status: string): (text: string) => string {
2296
+ switch (status) {
2297
+ case "cached":
2298
+ case "skipped":
2299
+ return ui.dim;
2300
+ case "pending":
2301
+ return ui.warn;
2302
+ case "completed":
2303
+ case "ready":
2304
+ case "applied":
2305
+ return ui.ok;
2306
+ case "failed":
2307
+ case "error":
2308
+ return ui.err;
2309
+ default:
2310
+ return ui.accent;
2311
+ }
1894
2312
  }
1895
2313
 
1896
2314
  function printWorkspaces(
1897
2315
  workspaces: ReadonlyArray<Pick<WorkspaceRecord, "name" | "workflow" | "createdAt">>,
1898
2316
  ): void {
1899
2317
  if (workspaces.length === 0) {
1900
- console.log("No workspaces.");
2318
+ console.log(ui.dim("no workspaces"));
1901
2319
  return;
1902
2320
  }
1903
2321
 
1904
- printTable(
1905
- ["name", "workflow", "created", "age"],
1906
- workspaces.map((workspace) => [
1907
- workspace.name,
1908
- workspace.workflow,
1909
- workspace.createdAt,
1910
- formatWorkspaceAge(workspace.createdAt),
1911
- ]),
1912
- );
2322
+ const rows = workspaces.map((workspace) => [
2323
+ { text: workspace.name, style: ui.bold },
2324
+ { text: workspace.workflow },
2325
+ { text: workspace.createdAt, style: ui.dim },
2326
+ formatWorkspaceAge(workspace.createdAt),
2327
+ ]);
2328
+ console.log(ui.columns(["name", "workflow", "created", "age"], rows));
1913
2329
  }
1914
2330
 
1915
- function formatWorkspaceAge(createdAt: string): string {
2331
+ function formatWorkspaceAge(createdAt: string): { text: string; style: (text: string) => string } {
1916
2332
  const createdTime = Date.parse(createdAt);
1917
- if (Number.isNaN(createdTime)) return chalk.dim("unknown");
2333
+ if (Number.isNaN(createdTime)) return { text: "unknown", style: ui.dim };
1918
2334
 
1919
2335
  const ageMs = Math.max(0, Date.now() - createdTime);
1920
2336
  const minute = 60 * 1000;
1921
2337
  const hour = 60 * minute;
1922
2338
  const day = 24 * hour;
1923
- const label = ageMs < hour
2339
+ const text = ageMs < hour
1924
2340
  ? `${Math.max(1, Math.floor(ageMs / minute))}m`
1925
2341
  : ageMs < day
1926
2342
  ? `${Math.floor(ageMs / hour)}h`
1927
2343
  : `${Math.floor(ageMs / day)}d`;
1928
2344
 
1929
- if (ageMs < day) return chalk.green(label);
1930
- if (ageMs <= 3 * day) return chalk.yellow(label);
1931
- return chalk.red(label);
2345
+ if (ageMs < day) return { text, style: ui.ok };
2346
+ if (ageMs <= 3 * day) return { text, style: ui.warn };
2347
+ return { text, style: ui.dim };
1932
2348
  }
1933
2349
 
1934
2350
  function printSnapshots(snapshots: SnapshotRecord[]): void {
1935
2351
  if (snapshots.length === 0) {
1936
- console.log("No snapshots.");
2352
+ console.log(ui.dim("no snapshots"));
1937
2353
  return;
1938
2354
  }
1939
2355
 
1940
- printTable(
1941
- ["run", "workflow", "node", "snapshot", "created"],
1942
- snapshots.map((snapshot) => [
1943
- snapshot.id,
1944
- snapshot.workflow,
1945
- snapshot.nodePath,
1946
- typeof snapshot.metadata.snapshotId === "string" ? snapshot.metadata.snapshotId : "",
1947
- snapshot.createdAt,
1948
- ]),
1949
- );
2356
+ const rows = snapshots.map((snapshot) => [
2357
+ { text: snapshot.id, style: ui.dim },
2358
+ { text: snapshot.workflow },
2359
+ { text: snapshot.nodePath, style: ui.bold },
2360
+ { text: typeof snapshot.metadata.snapshotId === "string" ? snapshot.metadata.snapshotId : "" },
2361
+ { text: snapshot.createdAt, style: ui.dim },
2362
+ ]);
2363
+ console.log(ui.columns(["run", "workflow", "node", "snapshot", "created"], rows));
2364
+ }
2365
+
2366
+ function printCacheEntries(entries: ReadonlyArray<{
2367
+ scope: "local" | "global";
2368
+ workflow: string;
2369
+ nodePath: string;
2370
+ nodeName: string;
2371
+ createdAt: string;
2372
+ invalidated: boolean;
2373
+ fragmentHash?: string;
2374
+ }>): void {
2375
+ if (entries.length === 0) {
2376
+ console.log(ui.dim("no cache entries"));
2377
+ return;
2378
+ }
2379
+
2380
+ const rows = entries.map((entry) => [
2381
+ { text: entry.scope, style: ui.dim },
2382
+ { text: entry.workflow },
2383
+ { text: entry.nodePath || entry.nodeName, style: ui.bold },
2384
+ {
2385
+ text: entry.invalidated ? "invalidated" : "valid",
2386
+ style: entry.invalidated ? ui.warn : ui.ok,
2387
+ },
2388
+ { text: entry.fragmentHash ? entry.fragmentHash.slice(0, 19) : "", style: ui.dim },
2389
+ { text: entry.createdAt, style: ui.dim },
2390
+ ]);
2391
+ console.log(ui.columns(["scope", "workflow", "node", "status", "fragment", "created"], rows));
1950
2392
  }
1951
2393
 
1952
2394
  function printConfig(info: EngineProjectInfo): void {
1953
- const rows = [
2395
+ console.log(ui.kvList([
1954
2396
  ["config", info.configPath],
1955
2397
  ["project", info.projectDir],
1956
- ["state", info.statePath],
1957
- ["workflow", info.workflow?.name ?? "(not loaded)"],
2398
+ ["state", info.statePath ?? ""],
2399
+ ["workflow", info.workflow?.name ?? ui.dim("(not loaded)")],
1958
2400
  ["providers", info.workflow?.providers.join(", ") ?? ""],
1959
- ];
1960
- printTable(["key", "value"], rows);
2401
+ ]));
1961
2402
  }
1962
2403
 
1963
2404
  function normalizeListTarget(target: string | undefined): "workspaces" | "snapshots" | "config" {
@@ -1969,62 +2410,85 @@ function normalizeListTarget(target: string | undefined): "workspaces" | "snapsh
1969
2410
  throw new Error(`Unknown ls target ${target}. Expected workspaces, snapshots, or config.`);
1970
2411
  }
1971
2412
 
1972
- function printTable(headers: string[], rows: string[][]): void {
1973
- const widths = headers.map((header, index) =>
1974
- Math.max(header.length, ...rows.map((row) => String(row[index] ?? "").length)),
1975
- );
1976
- const format = (row: string[]) =>
1977
- row.map((value, index) => String(value ?? "").padEnd(widths[index] ?? 0)).join(" ").trimEnd();
1978
-
1979
- console.log(format(headers));
1980
- console.log(format(widths.map((width) => "-".repeat(width))));
1981
- for (const row of rows) console.log(format(row));
1982
- }
1983
-
1984
2413
  function renderEvent(event: DevMachineEvent): void {
2414
+ const write = (line: string) => process.stderr.write(`${line}\n`);
1985
2415
  switch (event.type) {
1986
2416
  case "definition.loaded":
1987
- console.error(`loaded ${event.workflow}`);
2417
+ write(`${ui.dim(ui.sym.dot)} ${ui.dim(`loaded ${event.workflow}`)}`);
1988
2418
  return;
1989
2419
  case "plan.created":
1990
- console.error(`plan ${event.workflow}: ${event.cachedNodeCount}/${event.nodeCount} cached`);
2420
+ write(`${ui.accent(ui.sym.active)} ${ui.bold(event.workflow)} ${ui.dim(`${event.cachedNodeCount}/${event.nodeCount} cached`)}`);
2421
+ return;
2422
+ case "workflow.apply.started":
2423
+ write(`${ui.accent(ui.sym.active)} workflow ${ui.bold(event.workflow)}`);
1991
2424
  return;
2425
+ case "workflow.apply.completed": {
2426
+ const summary = event.nodeCount > 0
2427
+ ? ` ${ui.dim(`${event.cachedNodeCount}/${event.nodeCount} cached`)}`
2428
+ : "";
2429
+ write(`${ui.ok(ui.sym.ok)} ${ui.bold(event.workflow)} ${ui.dim("prepared")}${summary}`);
2430
+ return;
2431
+ }
1992
2432
  case "node.cached":
1993
- console.error(`node ${event.nodePath} cached`);
2433
+ write(` ${ui.dim(ui.sym.ok)} ${ui.dim(`${event.nodePath} cached`)}`);
1994
2434
  return;
1995
2435
  case "vm.created":
1996
- console.error(event.fromSnapshotId ? `vm ${event.vmId} from ${event.fromSnapshotId}` : `vm ${event.vmId} created`);
2436
+ write(` ${ui.dim(event.fromSnapshotId ? `vm ${event.vmId} from ${event.fromSnapshotId}` : `vm ${event.vmId} created`)}`);
1997
2437
  return;
1998
2438
  case "node.started":
1999
- console.error(`node ${event.nodePath}`);
2439
+ write(` ${ui.accent(ui.sym.active)} ${ui.bold(String(event.nodePath))}`);
2000
2440
  return;
2001
2441
  case "node.completed":
2002
- console.error(`node ${event.nodePath} completed`);
2442
+ write(` ${ui.ok(ui.sym.ok)} ${event.nodePath}`);
2003
2443
  return;
2004
2444
  case "command.started":
2005
- console.error(`command ${event.commandName}`);
2445
+ write(` ${ui.dim(`$ ${event.commandName}`)}`);
2006
2446
  return;
2007
2447
  case "command.output":
2008
2448
  process.stderr.write(event.data);
2009
2449
  return;
2010
2450
  case "command.completed":
2011
- console.error(`command ${event.commandName} exited ${event.exitCode}`);
2451
+ if (event.exitCode !== 0) {
2452
+ write(` ${ui.err(`${event.commandName} exited ${event.exitCode}`)}`);
2453
+ }
2012
2454
  return;
2013
- case "log.output":
2014
- process.stderr.write(event.data);
2455
+ case "log.output": {
2456
+ const prefix = event.stream && event.stream !== "info" && event.stream !== "stdout"
2457
+ ? `[${event.stream}] `
2458
+ : "";
2459
+ for (const line of event.data.replace(/\r/g, "").split("\n")) {
2460
+ if (!line) continue;
2461
+ process.stderr.write(`${prefix}${line}\n`);
2462
+ }
2015
2463
  return;
2464
+ }
2016
2465
  case "interaction.awaiting_user":
2017
- console.error(`interaction ${event.label}`);
2018
- console.error(`open ${event.url}`);
2466
+ write(` ${ui.accent(ui.sym.arrow)} waiting on ${ui.bold(event.label)}`);
2467
+ write(` ${ui.dim(event.url)}`);
2019
2468
  return;
2020
2469
  case "interaction.completed":
2021
- console.error(`interaction ${event.label} completed`);
2470
+ write(` ${ui.ok(ui.sym.ok)} ${ui.dim(`${event.label} completed`)}`);
2022
2471
  return;
2023
2472
  case "artifact.created":
2024
- console.error(`artifact ${event.providerId}:${event.kind}`);
2473
+ write(` ${ui.dim(`+ ${event.providerId}:${event.kind}`)}`);
2474
+ return;
2475
+ case "workspace.create.started":
2476
+ write(`${ui.accent(ui.sym.active)} creating workspace ${ui.bold(String(event.workspaceName))}`);
2025
2477
  return;
2026
2478
  case "workspace.ready":
2027
- console.error(`workspace ${event.workspaceId} ready`);
2479
+ write(`${ui.ok(ui.sym.ok)} ${ui.bold(String(event.workspaceId))} ${ui.dim("ready")}`);
2480
+ return;
2481
+ case "workspace.remove.started":
2482
+ write(`${ui.accent(ui.sym.active)} removing workspace ${ui.bold(String(event.workspaceName))}`);
2483
+ return;
2484
+ case "workspace.remove.completed":
2485
+ write(`${ui.ok(ui.sym.ok)} removed ${ui.bold(String(event.workspaceName))}`);
2486
+ return;
2487
+ case "workspace.operation.started":
2488
+ write(`${ui.accent(ui.sym.active)} running ${ui.bold(String(event.operationId))} on ${ui.bold(String(event.workspaceName))}`);
2489
+ return;
2490
+ case "workspace.operation.completed":
2491
+ write(`${ui.ok(ui.sym.ok)} ran ${ui.bold(String(event.operationId))} on ${ui.bold(String(event.workspaceName))}`);
2028
2492
  return;
2029
2493
  default:
2030
2494
  return;