@rigkit/cli 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -16,7 +16,7 @@ Interactive terminals use Inquirer prompts and a chalk/log-update run timeline.
16
16
 
17
17
  Interactive providers can ask the CLI to open provider-owned URLs. For example, Freestyle terminal sessions are served by the Freestyle provider, while the CLI only opens the presented URL in a browser.
18
18
 
19
- Workspace-specific operations run as `rig <workspace>/<operation>`, for example `rig website-workspace/open-cmux` or `rig website-workspace/remove -y`.
19
+ Workspace-specific operations run as `rig run <workspace> <operation>`, for example `rig run website-workspace open-cmux` or `rig run website-workspace remove -y`.
20
20
 
21
21
  `rig ls` lists workspaces for the selected project. `rig ls snapshots` lists cached snapshot runs, and `rig ls config` shows the resolved project paths.
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigkit/cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,9 +25,9 @@
25
25
  "log-symbols": "^7.0.1",
26
26
  "log-update": "^8.0.0",
27
27
  "ora": "^9.4.0",
28
- "@rigkit/provider-cmux": "0.2.4",
29
- "@rigkit/engine": "0.2.4",
30
- "@rigkit/runtime-client": "0.2.4"
28
+ "@rigkit/provider-cmux": "0.2.6",
29
+ "@rigkit/engine": "0.2.6",
30
+ "@rigkit/runtime-client": "0.2.6"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/bun": "latest",
package/src/cli.test.ts CHANGED
@@ -23,7 +23,8 @@ describe("CLI entrypoint", () => {
23
23
  expect(rootHelp.exitCode).toBe(0);
24
24
  expect(rootHelp.stderr).toBe("");
25
25
  expect(rootHelp.stdout).toContain("rig ");
26
- expect(rootHelp.stdout).toContain("<operation> Run a project operation exposed by the runtime");
26
+ expect(rootHelp.stdout).toContain("plan Plan project workflow changes");
27
+ expect(rootHelp.stdout).toContain("run Run a workspace operation");
27
28
 
28
29
  const version = await runCli(["version"]);
29
30
  expect(version.exitCode).toBe(0);
@@ -34,15 +35,16 @@ describe("CLI entrypoint", () => {
34
35
  expect(help.exitCode).toBe(0);
35
36
  expect(help.stderr).toBe("");
36
37
  expect(help.stdout).toContain("rig ");
37
- expect(help.stdout).toContain("<operation> Run a project operation exposed by the runtime");
38
+ expect(help.stdout).toContain("plan Plan project workflow changes");
39
+ expect(help.stdout).toContain("run Run a workspace operation");
38
40
  });
39
41
 
40
- test("treats unknown root tokens as project operations", async () => {
42
+ test("rejects operation shorthand at the root", async () => {
41
43
  const result = await runCli(["unknown"]);
42
44
 
43
45
  expect(result.exitCode).toBe(1);
44
46
  expect(result.stdout).toBe("");
45
- expect(result.stderr).toContain("No Rigkit config found");
47
+ expect(result.stderr).toContain("unknown command 'unknown'");
46
48
  });
47
49
 
48
50
  test("serves dynamic shell completion endpoint", async () => {
@@ -106,6 +108,18 @@ describe("CLI entrypoint", () => {
106
108
  });
107
109
  });
108
110
 
111
+ test("rejects workspace create names that are not shell-safe", async () => {
112
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-cli-create-name-"));
113
+
114
+ await withWorkspaceRuntime({ projectDir }, async ({ env }) => {
115
+ const result = await runCli(["-C", projectDir, "create", "--name", "some workspace", "--json"], { env });
116
+
117
+ expect(result.exitCode).toBe(1);
118
+ expect(result.stdout).toBe("");
119
+ expect(result.stderr).toContain('Invalid workspace name "some workspace"');
120
+ });
121
+ });
122
+
109
123
  test("requires discovered projects for operation --all", async () => {
110
124
  const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-run-all-"));
111
125
 
@@ -216,6 +230,30 @@ async function withWorkspaceRuntime(
216
230
  }],
217
231
  });
218
232
  }
233
+ if (pathname === "/operations") {
234
+ return runtimeJson({
235
+ operations: [{
236
+ id: "create",
237
+ kind: "command",
238
+ source: "core",
239
+ title: "Create",
240
+ description: "Create a workspace",
241
+ createsWorkspace: true,
242
+ cli: {
243
+ options: [{ name: "name", flag: "--name", required: true, type: "string" }],
244
+ },
245
+ inputSchema: {
246
+ type: "object",
247
+ additionalProperties: false,
248
+ properties: {
249
+ name: { type: "string", minLength: 1 },
250
+ },
251
+ required: ["name"],
252
+ },
253
+ }],
254
+ workspaceOperations: [],
255
+ });
256
+ }
219
257
  return runtimeJson({ error: { message: "Not found" } }, { status: 404 });
220
258
  },
221
259
  });
package/src/cli.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  resolveCompletionShell,
37
37
  type CompletionShell,
38
38
  } from "./completion.ts";
39
+ import { generateWorkspaceName } from "./workspace-name.ts";
39
40
 
40
41
  type GlobalOptions = {
41
42
  project?: string;
@@ -148,41 +149,10 @@ const CLI_HOST_CAPABILITIES: Array<{ id: string; schemaHash?: string }> = [
148
149
  ...(capability.schemaHash ? { schemaHash: capability.schemaHash } : {}),
149
150
  }));
150
151
 
151
- const MANAGEMENT_COMMANDS = new Set([
152
- "completion",
153
- "doctor",
154
- "help",
155
- "init",
156
- "ls",
157
- "projects",
158
- "run",
159
- "version",
160
- ]);
161
-
162
- const GLOBAL_OPTIONS_WITH_VALUES = new Set(["-C", "--project", "--config", "--state"]);
163
-
164
152
  if (process.argv[2] === "__complete") {
165
153
  runCompletionEndpoint(process.argv.slice(3)).catch(handleCliError);
166
154
  } else {
167
- runCli(normalizeOperationArgv(process.argv)).catch(handleCliError);
168
- }
169
-
170
- function normalizeOperationArgv(argv: string[]): string[] {
171
- const args = argv.slice(2);
172
- for (let index = 0; index < args.length; index += 1) {
173
- const arg = args[index]!;
174
- if (arg === "--") return argv;
175
- if (GLOBAL_OPTIONS_WITH_VALUES.has(arg)) {
176
- index += 1;
177
- continue;
178
- }
179
- if ([...GLOBAL_OPTIONS_WITH_VALUES].some((option) => arg.startsWith(`${option}=`))) continue;
180
- if (arg === "--json") continue;
181
- if (arg.startsWith("-")) return argv;
182
- if (MANAGEMENT_COMMANDS.has(arg)) return argv;
183
- return [...argv.slice(0, 2), ...args.slice(0, index), "run", ...args.slice(index)];
184
- }
185
- return argv;
155
+ runCli(process.argv).catch(handleCliError);
186
156
  }
187
157
 
188
158
  async function runCli(argv: string[]): Promise<void> {
@@ -190,7 +160,7 @@ async function runCli(argv: string[]): Promise<void> {
190
160
  program
191
161
  .name("rig")
192
162
  .description("Rigkit workflow CLI")
193
- .usage("[options] <command|operation>")
163
+ .usage("[options] <command>")
194
164
  .version(RIGKIT_CLI_VERSION, "-v, --version", "Show Rigkit CLI version")
195
165
  .showHelpAfterError()
196
166
  .exitOverride()
@@ -227,22 +197,46 @@ async function runCli(argv: string[]): Promise<void> {
227
197
  });
228
198
  });
229
199
 
200
+ for (const operation of ["plan", "apply"] as const) {
201
+ program
202
+ .command(`${operation} [args...]`)
203
+ .description(operation === "plan" ? "Plan project workflow changes" : "Apply project workflow changes")
204
+ .allowUnknownOption(true)
205
+ .option("--all", "Run against every discovered project")
206
+ .option("--discover", "Discover projects below the selected directory")
207
+ .option("--json", "Print machine-readable JSON")
208
+ .action(async (args: string[], options: { all?: boolean; discover?: boolean; json?: boolean }) => {
209
+ await runProjectOperation(makeInvocation(rootOptions(program), options.json), operation, args ?? [], {
210
+ all: Boolean(options.all),
211
+ discover: Boolean(options.discover),
212
+ });
213
+ });
214
+ }
215
+
216
+ program
217
+ .command("create [args...]")
218
+ .description("Create a workspace")
219
+ .allowUnknownOption(true)
220
+ .option("--json", "Print machine-readable JSON")
221
+ .action(async (args: string[], options: { json?: boolean }) => {
222
+ await runProjectOperation(makeInvocation(rootOptions(program), options.json), "create", args ?? [], {
223
+ all: false,
224
+ discover: false,
225
+ });
226
+ });
227
+
230
228
  program
231
- .command("run <operation> [args...]", { hidden: true })
232
- .description("Run a project operation exposed by the runtime")
229
+ .command("run <workspace> <operation> [args...]")
230
+ .description("Run a workspace operation")
233
231
  .allowUnknownOption(true)
234
- .option("--all", "Run against every discovered project")
235
- .option("--discover", "Discover projects below the selected directory")
236
232
  .option("--json", "Print machine-readable JSON")
237
233
  .action(async (
234
+ workspace: string,
238
235
  operation: string,
239
236
  args: string[],
240
- options: { all?: boolean; discover?: boolean; json?: boolean },
237
+ options: { json?: boolean },
241
238
  ) => {
242
- await runProjectOperation(makeInvocation(rootOptions(program), options.json), operation, args ?? [], {
243
- all: Boolean(options.all),
244
- discover: Boolean(options.discover),
245
- });
239
+ await runWorkspaceOperation(makeInvocation(rootOptions(program), options.json), workspace, operation, args ?? []);
246
240
  });
247
241
 
248
242
  program
@@ -499,6 +493,43 @@ async function promptPackageManager(defaultValue: PackageManager): Promise<Packa
499
493
  return answers.packageManager;
500
494
  }
501
495
 
496
+ async function promptWorkspaceName(defaultValue: string): Promise<string> {
497
+ const answers = await inquirer.prompt<{ name: string }>([{
498
+ type: "input",
499
+ name: "name",
500
+ message: "Workspace name:",
501
+ default: defaultValue,
502
+ validate(value: string) {
503
+ return validateWorkspaceName(value.trim());
504
+ },
505
+ filter: (value: string) => value.trim(),
506
+ }]);
507
+ return answers.name;
508
+ }
509
+
510
+ const workspaceNamePattern = /^(?!-)[A-Za-z0-9._-]+$/;
511
+
512
+ function validateWorkspaceName(value: string): true | string {
513
+ if (!value) return "Workspace name is required.";
514
+ if (!workspaceNamePattern.test(value)) {
515
+ return 'Use only letters, numbers, ".", "_", and "-", and do not start with "-".';
516
+ }
517
+ return true;
518
+ }
519
+
520
+ function assertValidWorkspaceName(value: unknown): void {
521
+ if (typeof value !== "string") throw new Error(`Workspace name must be a string`);
522
+ const valid = validateWorkspaceName(value);
523
+ if (valid !== true) throw new Error(`Invalid workspace name "${value}". ${valid}`);
524
+ }
525
+
526
+ async function defaultWorkspaceName(runtime: RuntimeClient): Promise<string> {
527
+ const existingNames = await runtime.control.workspaces()
528
+ .then((response) => response.workspaces.map((workspace) => workspace.name))
529
+ .catch(() => []);
530
+ return generateWorkspaceName(existingNames);
531
+ }
532
+
502
533
  async function runPackageManagerInstall(
503
534
  projectDir: string,
504
535
  packageManager: PackageManager,
@@ -649,6 +680,48 @@ async function runProjectOperation(
649
680
  }
650
681
 
651
682
  await renderOperationResult(operation, result, parsed.hostOptions);
683
+ printInteractiveOutputGap(invocation);
684
+ }
685
+
686
+ async function runWorkspaceOperation(
687
+ invocation: CliInvocation,
688
+ workspaceName: string,
689
+ requestedOperation: string,
690
+ args: string[],
691
+ ): Promise<void> {
692
+ const runtime = await loadRuntime(invocation);
693
+ const manifest = await readRuntimeOperations(runtime);
694
+ const operation = (manifest.workspaceOperations ?? []).find((item) =>
695
+ item.id === requestedOperation || item.aliases?.includes(requestedOperation)
696
+ );
697
+ if (!operation) {
698
+ throw new Error(`This project does not define a workspace operation named "${requestedOperation}".`);
699
+ }
700
+
701
+ const workspaces = await runtime.control.workspaces()
702
+ .then((response) => response.workspaces as WorkspaceRecord[])
703
+ .catch(() => []);
704
+ if (!workspaces.some((workspace) => workspace.name === workspaceName)) {
705
+ throw new Error(`This project does not have a workspace named "${workspaceName}".`);
706
+ }
707
+
708
+ const parsed = parseOperationArgs(operation, args);
709
+ enforceHostOnlyBooleanGuards(operation, parsed);
710
+
711
+ const result = await runRuntimeOperation<unknown>(
712
+ runtime,
713
+ `${workspaceName}/${operation.id}`,
714
+ parsed.input,
715
+ { renderEvents: !wantsJson(invocation) },
716
+ );
717
+
718
+ if (wantsJson(invocation)) {
719
+ printJson(result);
720
+ return;
721
+ }
722
+
723
+ await renderOperationResult(operation, result, parsed.hostOptions);
724
+ printInteractiveOutputGap(invocation);
652
725
  }
653
726
 
654
727
  async function runDiscoveredProjectOperation(
@@ -704,6 +777,7 @@ async function runDiscoveredProjectOperation(
704
777
  console.log(chalk.bold(displayProjectDir(project.projectDir)));
705
778
  }
706
779
  await renderOperationResult(operation, result, parsed.hostOptions);
780
+ printInteractiveOutputGap(invocation);
707
781
  }
708
782
  }
709
783
 
@@ -780,7 +854,7 @@ async function executeRuntimeOperation(
780
854
  }
781
855
  const { operation, runOperation } = resolved;
782
856
 
783
- const parsed = parseOperationArgs(operation, args);
857
+ const parsed = await parseOperationArgsWithPrompts(invocation, runtime, operation, args);
784
858
  enforceHostOnlyBooleanGuards(operation, parsed);
785
859
 
786
860
  const result = await runRuntimeOperation<unknown>(
@@ -797,28 +871,43 @@ function findRuntimeOperation(
797
871
  manifest: RuntimeOperationManifest,
798
872
  requestedOperation: string,
799
873
  ): { operation: RuntimeOperationDefinition; runOperation: string } | undefined {
800
- const workspaceOperation = parseWorkspaceOperationId(requestedOperation);
801
- if (workspaceOperation) {
802
- const operation = (manifest.workspaceOperations ?? []).find((item) => item.id === workspaceOperation.operation);
803
- return operation ? { operation, runOperation: requestedOperation } : undefined;
804
- }
805
-
806
874
  const operation = manifest.operations.find((operation) =>
807
875
  operation.id === requestedOperation || operation.aliases?.includes(requestedOperation)
808
876
  );
809
877
  return operation ? { operation, runOperation: operation.id } : undefined;
810
878
  }
811
879
 
812
- function parseWorkspaceOperationId(value: string): { workspace: string; operation: string } | undefined {
813
- const slash = value.indexOf("/");
814
- if (slash <= 0 || slash === value.length - 1) return undefined;
815
- return {
816
- workspace: value.slice(0, slash),
817
- operation: value.slice(slash + 1),
818
- };
880
+ async function parseOperationArgsWithPrompts(
881
+ invocation: CliInvocation,
882
+ runtime: RuntimeClient,
883
+ operation: RuntimeOperationDefinition,
884
+ args: string[],
885
+ ): Promise<ParsedOperationInput> {
886
+ const parsed = parseOperationArgs(operation, args, {
887
+ allowMissingRequired: !wantsJson(invocation) && canPrompt(),
888
+ });
889
+
890
+ if (
891
+ operation.createsWorkspace &&
892
+ parsed.input.name === undefined &&
893
+ !wantsJson(invocation) &&
894
+ canPrompt()
895
+ ) {
896
+ parsed.input.name = await promptWorkspaceName(await defaultWorkspaceName(runtime));
897
+ }
898
+ if (operation.createsWorkspace && parsed.input.name !== undefined) {
899
+ assertValidWorkspaceName(parsed.input.name);
900
+ }
901
+
902
+ enforceRequiredOperationInputs(operation, parsed);
903
+ return parsed;
819
904
  }
820
905
 
821
- function parseOperationArgs(operation: RuntimeOperationDefinition, args: string[]): ParsedOperationInput {
906
+ function parseOperationArgs(
907
+ operation: RuntimeOperationDefinition,
908
+ args: string[],
909
+ options: { allowMissingRequired?: boolean } = {},
910
+ ): ParsedOperationInput {
822
911
  const cli = inferCliMetadata(operation);
823
912
  const input: Record<string, unknown> = {};
824
913
  const hostOptions: Record<string, unknown> = {};
@@ -857,19 +946,27 @@ function parseOperationArgs(operation: RuntimeOperationDefinition, args: string[
857
946
  assignPositional(positionals, positionalIndex++, arg, input);
858
947
  }
859
948
 
949
+ if (!options.allowMissingRequired) enforceRequiredOperationInputs(operation, { input, hostOptions });
950
+
951
+ return { input, hostOptions };
952
+ }
953
+
954
+ function enforceRequiredOperationInputs(
955
+ operation: RuntimeOperationDefinition,
956
+ parsed: ParsedOperationInput,
957
+ ): void {
958
+ const cli = inferCliMetadata(operation);
860
959
  for (const option of cli.options ?? []) {
861
- if (option.required && input[option.name] === undefined && hostOptions[option.name] === undefined) {
960
+ if (option.required && parsed.input[option.name] === undefined && parsed.hostOptions[option.name] === undefined) {
862
961
  throw new Error(`Operation ${operation.id} requires ${option.flag}`);
863
962
  }
864
963
  }
865
964
 
866
965
  for (const name of operation.inputSchema?.required ?? []) {
867
- if (input[name] === undefined) {
966
+ if (parsed.input[name] === undefined) {
868
967
  throw new Error(`Operation ${operation.id} requires ${name}`);
869
968
  }
870
969
  }
871
-
872
- return { input, hostOptions };
873
970
  }
874
971
 
875
972
  function enforceHostOnlyBooleanGuards(
@@ -1072,7 +1169,10 @@ async function runHelp(invocation: CliInvocation): Promise<void> {
1072
1169
  commands: [
1073
1170
  { name: "help", description: "Show Rigkit CLI help" },
1074
1171
  { name: "init", description: "Initialize a Rigkit project" },
1075
- { name: "<operation>", description: "Run a project operation exposed by the runtime" },
1172
+ { name: "plan", description: "Plan project workflow changes" },
1173
+ { name: "apply", description: "Apply project workflow changes" },
1174
+ { name: "create", description: "Create a workspace" },
1175
+ { name: "run", description: "Run a workspace operation" },
1076
1176
  { name: "ls", description: "List project workspaces" },
1077
1177
  { name: "projects", description: "Discover Rigkit projects below the current directory" },
1078
1178
  { name: "doctor", description: "Show Rigkit runtime diagnostics" },
@@ -1086,12 +1186,15 @@ async function runHelp(invocation: CliInvocation): Promise<void> {
1086
1186
  `rig ${RIGKIT_CLI_VERSION}`,
1087
1187
  "",
1088
1188
  "Usage:",
1089
- " rig [options] <command|operation>",
1189
+ " rig [options] <command>",
1090
1190
  "",
1091
1191
  "Commands:",
1092
1192
  " help Show Rigkit CLI help",
1093
1193
  " init Initialize a Rigkit project",
1094
- " <operation> Run a project operation exposed by the runtime",
1194
+ " plan Plan project workflow changes",
1195
+ " apply Apply project workflow changes",
1196
+ " create Create a workspace",
1197
+ " run Run a workspace operation",
1095
1198
  " ls List project workspaces",
1096
1199
  " projects Discover Rigkit projects below the current directory",
1097
1200
  " doctor Show Rigkit runtime diagnostics",
@@ -1773,6 +1876,11 @@ function printJson(value: unknown): void {
1773
1876
  console.log(JSON.stringify(value, null, 2));
1774
1877
  }
1775
1878
 
1879
+ function printInteractiveOutputGap(invocation: CliInvocation): void {
1880
+ if (wantsJson(invocation) || !process.stdout.isTTY) return;
1881
+ console.log("");
1882
+ }
1883
+
1776
1884
  function printPlan(plan: WorkflowPlan): void {
1777
1885
  console.log(`${plan.workflow}: ${plan.cachedNodeCount}/${plan.nodeCount} nodes cached`);
1778
1886
 
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { projectIdFor, runtimeFingerprintFor, runtimePaths, SUPPORTED_RUNTIME_API_VERSION } from "@rigkit/runtime-client";
6
- import { completeRig, formatCompletionItems, renderCompletionScript } from "./completion.ts";
6
+ import { completeRig, formatCompletionItems, formatWorkspaceAge, renderCompletionScript } from "./completion.ts";
7
7
 
8
8
  describe("CLI completion", () => {
9
9
  test("completes workspace targets from the runtime", async () => {
@@ -11,12 +11,12 @@ describe("CLI completion", () => {
11
11
  await withWorkspaceRuntime({ projectDir }, async () => {
12
12
  const items = await completeRig({
13
13
  cwd: projectDir,
14
- words: ["rig", "ssh", ""],
14
+ words: ["rig", "run", ""],
15
15
  currentIndex: 2,
16
16
  });
17
17
 
18
18
  expect(items.map((item) => item.value)).toEqual(["api", "web"]);
19
- expect(items[0]?.description).toBe("smoke");
19
+ expect(items[0]?.description).toBe("smoke - 2h old");
20
20
  });
21
21
  });
22
22
 
@@ -25,7 +25,7 @@ describe("CLI completion", () => {
25
25
  await withWorkspaceRuntime({ projectDir }, async () => {
26
26
  const items = await completeRig({
27
27
  cwd: projectDir,
28
- words: ["rig", "ssh", "vm-"],
28
+ words: ["rig", "run", "vm-"],
29
29
  currentIndex: 2,
30
30
  });
31
31
 
@@ -39,7 +39,7 @@ describe("CLI completion", () => {
39
39
  await withWorkspaceRuntime({ projectDir, cleanupDir: parentDir }, async () => {
40
40
  const items = await completeRig({
41
41
  cwd: parentDir,
42
- words: ["rig", "-C", "project", "ssh", ""],
42
+ words: ["rig", "-C", "project", "run", ""],
43
43
  currentIndex: 4,
44
44
  });
45
45
 
@@ -55,15 +55,42 @@ describe("CLI completion", () => {
55
55
  words: ["rig", "run", ""],
56
56
  currentIndex: 2,
57
57
  });
58
- expect(roots.map((item) => item.value)).toContain("api/");
59
- expect(roots.map((item) => item.value)).toContain("ssh");
58
+ expect(roots.map((item) => item.value)).toEqual(["api", "web"]);
59
+ expect(roots[0]).toMatchObject({ description: "smoke - 2h old" });
60
60
 
61
- const operations = await completeRig({
61
+ const exactWorkspace = await completeRig({
62
62
  cwd: projectDir,
63
- words: ["rig", "run", "api/"],
63
+ words: ["rig", "run", "api"],
64
64
  currentIndex: 2,
65
65
  });
66
- expect(operations.map((item) => item.value)).toEqual(["api/remove", "api/open-cmux"]);
66
+ expect(exactWorkspace.map((item) => item.value)).toEqual(["api"]);
67
+
68
+ const workspaceAfterSpace = await completeRig({
69
+ cwd: projectDir,
70
+ words: ["rig", "run", "api", ""],
71
+ currentIndex: 3,
72
+ });
73
+ expect(workspaceAfterSpace.map((item) => item.value)).toEqual(["remove", "open-cmux"]);
74
+
75
+ const operationPrefix = await completeRig({
76
+ cwd: projectDir,
77
+ words: ["rig", "run", "api", "open"],
78
+ currentIndex: 3,
79
+ });
80
+ expect(operationPrefix.map((item) => item.value)).toEqual(["open-cmux"]);
81
+ });
82
+ });
83
+
84
+ test("completes top-level project commands at the root command position", async () => {
85
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-completion-"));
86
+ await withWorkspaceRuntime({ projectDir }, async () => {
87
+ const items = await completeRig({
88
+ cwd: projectDir,
89
+ words: ["rig", "p"],
90
+ currentIndex: 1,
91
+ });
92
+
93
+ expect(items.map((item) => item.value)).toEqual(["plan", "projects"]);
67
94
  });
68
95
  });
69
96
 
@@ -72,7 +99,21 @@ describe("CLI completion", () => {
72
99
 
73
100
  expect(formatCompletionItems(items, "bash")).toBe("api");
74
101
  expect(formatCompletionItems(items, "zsh")).toBe("api\tvm-api");
102
+ expect(formatCompletionItems([{ value: "api", description: "workspace smoke", noSpace: true }], "zsh"))
103
+ .toBe("api\tworkspace smoke\tnospace");
75
104
  expect(renderCompletionScript("zsh")).toContain("rig __complete");
105
+ expect(renderCompletionScript("zsh")).toContain("compadd -S ''");
106
+ expect(renderCompletionScript("zsh")).toContain('display="${value} -- ${description}"');
107
+ });
108
+
109
+ test("formats workspace ages", () => {
110
+ const now = Date.parse("2026-05-14T12:00:00.000Z");
111
+
112
+ expect(formatWorkspaceAge("2026-05-14T11:59:45.000Z", now)).toBe("just now");
113
+ expect(formatWorkspaceAge("2026-05-14T11:30:00.000Z", now)).toBe("30m old");
114
+ expect(formatWorkspaceAge("2026-05-14T09:00:00.000Z", now)).toBe("3h old");
115
+ expect(formatWorkspaceAge("2026-05-11T12:00:00.000Z", now)).toBe("3d old");
116
+ expect(formatWorkspaceAge("not-a-date", now)).toBeUndefined();
76
117
  });
77
118
 
78
119
  test("completes ls targets", async () => {
@@ -124,7 +165,10 @@ async function withWorkspaceRuntime(
124
165
  });
125
166
  }
126
167
  if (pathname === "/workspaces") {
127
- const now = new Date(0).toISOString();
168
+ const nowMs = Date.now();
169
+ const apiCreatedAt = new Date(nowMs - 2 * 60 * 60 * 1000).toISOString();
170
+ const webCreatedAt = new Date(nowMs - 5 * 60 * 1000).toISOString();
171
+ const updatedAt = new Date(nowMs).toISOString();
128
172
  return runtimeJson({
129
173
  workspaces: [
130
174
  {
@@ -132,16 +176,16 @@ async function withWorkspaceRuntime(
132
176
  name: "api",
133
177
  workflow: "smoke",
134
178
  ctx: {},
135
- createdAt: now,
136
- updatedAt: now,
179
+ createdAt: apiCreatedAt,
180
+ updatedAt,
137
181
  },
138
182
  {
139
183
  id: "workspace-web",
140
184
  name: "web",
141
185
  workflow: "smoke",
142
186
  ctx: {},
143
- createdAt: now,
144
- updatedAt: now,
187
+ createdAt: webCreatedAt,
188
+ updatedAt,
145
189
  },
146
190
  ],
147
191
  });
package/src/completion.ts CHANGED
@@ -6,6 +6,7 @@ export type CompletionShell = "bash" | "fish" | "zsh";
6
6
  export type CompletionItem = {
7
7
  value: string;
8
8
  description?: string;
9
+ noSpace?: boolean;
9
10
  };
10
11
 
11
12
  type CompleteRigInput = {
@@ -17,7 +18,10 @@ type CompleteRigInput = {
17
18
  const COMMANDS: CompletionItem[] = [
18
19
  { value: "help", description: "show CLI help" },
19
20
  { value: "init", description: "initialize a Rigkit project" },
20
- { value: "run", description: "run a project operation" },
21
+ { value: "plan", description: "plan project workflow changes" },
22
+ { value: "apply", description: "apply project workflow changes" },
23
+ { value: "create", description: "create a workspace" },
24
+ { value: "run", description: "run a workspace operation" },
21
25
  { value: "ls", description: "list project workspaces" },
22
26
  { value: "projects", description: "discover Rigkit projects" },
23
27
  { value: "doctor", description: "show runtime diagnostics" },
@@ -45,11 +49,22 @@ const COMMAND_OPTIONS: Record<string, CompletionItem[]> = {
45
49
  { value: "--force", description: "overwrite existing config" },
46
50
  { value: "--json", description: "print JSON" },
47
51
  ],
48
- operation: [
52
+ plan: [
49
53
  { value: "--all", description: "run against every discovered project" },
50
54
  { value: "--discover", description: "discover projects below the selected directory" },
51
55
  { value: "--json", description: "print JSON" },
52
56
  ],
57
+ apply: [
58
+ { value: "--all", description: "run against every discovered project" },
59
+ { value: "--discover", description: "discover projects below the selected directory" },
60
+ { value: "--json", description: "print JSON" },
61
+ ],
62
+ create: [
63
+ { value: "--json", description: "print JSON" },
64
+ ],
65
+ run: [
66
+ { value: "--json", description: "print JSON" },
67
+ ],
53
68
  ls: [
54
69
  { value: "workspaces", description: "list workspaces" },
55
70
  { value: "snapshots", description: "list snapshots" },
@@ -66,6 +81,8 @@ const COMMAND_OPTIONS: Record<string, CompletionItem[]> = {
66
81
  ],
67
82
  };
68
83
 
84
+ const PROJECT_OPERATION_COMMANDS = new Set(["plan", "apply", "create"]);
85
+
69
86
  const OPTIONS_WITH_VALUES = new Set([
70
87
  "-C",
71
88
  "--project",
@@ -91,6 +108,13 @@ type RuntimeOperationDefinition = {
91
108
  };
92
109
  };
93
110
 
111
+ type RuntimeWorkspaceCompletion = {
112
+ name: string;
113
+ workflow: string;
114
+ createdAt: string;
115
+ updatedAt: string;
116
+ };
117
+
94
118
  export async function completeRig(input: CompleteRigInput): Promise<CompletionItem[]> {
95
119
  const cwd = input.cwd ?? process.cwd();
96
120
  const words = input.words.length > 0 ? input.words : ["rig"];
@@ -102,66 +126,49 @@ export async function completeRig(input: CompleteRigInput): Promise<CompletionIt
102
126
  if (expectsOptionValue(before)) return [];
103
127
 
104
128
  if (!command) {
105
- const rootOperation = parseRootOperation(before);
106
- if (rootOperation.operation) {
107
- if (current.startsWith("-")) {
108
- const operation = await safeResolveRuntimeOperation(resolveProjectDir(words, cwd), rootOperation.operation);
109
- return filterItems([
110
- ...(operation?.cli?.options ?? []).flatMap((option) => [
111
- { value: option.flag, description: option.name },
112
- ...(option.aliases ?? []).map((alias) => ({ value: alias, description: option.name })),
113
- ]),
114
- ...COMMAND_OPTIONS.operation,
115
- ...GLOBAL_OPTIONS,
116
- ], current);
117
- }
118
- const operation = await safeResolveRuntimeOperation(resolveProjectDir(words, cwd), rootOperation.operation);
119
- const operationPositionalCount = countRunOperationPositionals(rootOperation.args);
120
- const positional = operation?.cli?.positionals?.find((item) => item.index === operationPositionalCount);
121
- if (positional && /workspace|vm/i.test(positional.name)) {
122
- return filterItems(await workspaceTargets(resolveProjectDir(words, cwd), current, /vm/i.test(positional.name)), current);
123
- }
124
- return [];
125
- }
126
129
  return filterItems(
127
130
  current.startsWith("-")
128
131
  ? GLOBAL_OPTIONS
129
- : [...COMMANDS, ...await safeOperationTargets(resolveProjectDir(words, cwd), current), ...GLOBAL_OPTIONS],
132
+ : [...COMMANDS, ...GLOBAL_OPTIONS],
130
133
  current,
131
134
  );
132
135
  }
133
136
 
134
137
  if (current.startsWith("-")) {
135
138
  if (command === "run") {
136
- const run = parseRunCommand(before);
137
- if (run.operation) {
138
- const operation = await safeResolveRuntimeOperation(resolveProjectDir(words, cwd), run.operation);
139
+ const run = parseWorkspaceRunCommand(before);
140
+ if (run.workspace && run.operation) {
141
+ const operation = await safeResolveWorkspaceOperation(resolveProjectDir(words, cwd), run.operation);
139
142
  return filterItems([
140
143
  ...(operation?.cli?.options ?? []).flatMap((option) => [
141
144
  { value: option.flag, description: option.name },
142
145
  ...(option.aliases ?? []).map((alias) => ({ value: alias, description: option.name })),
143
146
  ]),
144
- ...COMMAND_OPTIONS.operation,
147
+ ...COMMAND_OPTIONS.run,
145
148
  ...GLOBAL_OPTIONS,
146
149
  ], current);
147
150
  }
148
151
  }
152
+ if (PROJECT_OPERATION_COMMANDS.has(command)) {
153
+ const operation = await safeResolveRuntimeOperation(resolveProjectDir(words, cwd), command);
154
+ return filterItems([
155
+ ...(operation?.cli?.options ?? []).flatMap((option) => [
156
+ { value: option.flag, description: option.name },
157
+ ...(option.aliases ?? []).map((alias) => ({ value: alias, description: option.name })),
158
+ ]),
159
+ ...(COMMAND_OPTIONS[command] ?? []),
160
+ ...GLOBAL_OPTIONS,
161
+ ], current);
162
+ }
149
163
  return filterItems([...(COMMAND_OPTIONS[command] ?? []), ...GLOBAL_OPTIONS], current);
150
164
  }
151
165
 
152
166
  const positionalCount = countPositionals(before, command);
153
167
 
154
168
  if (command === "run") {
155
- const run = parseRunCommand(before);
156
- if (!run.operation) {
157
- return filterItems(await safeOperationTargets(resolveProjectDir(words, cwd), current), current);
158
- }
159
- const operation = await safeResolveRuntimeOperation(resolveProjectDir(words, cwd), run.operation);
160
- const operationPositionalCount = countRunOperationPositionals(run.args);
161
- const positional = operation?.cli?.positionals?.find((item) => item.index === operationPositionalCount);
162
- if (positional && /workspace|vm/i.test(positional.name)) {
163
- return filterItems(await workspaceTargets(resolveProjectDir(words, cwd), current, /vm/i.test(positional.name)), current);
164
- }
169
+ const run = parseWorkspaceRunCommand(before);
170
+ if (!run.workspace) return filterItems(await workspaceTargets(resolveProjectDir(words, cwd)), current);
171
+ if (!run.operation) return filterItems(await safeWorkspaceOperationTargets(resolveProjectDir(words, cwd)), current);
165
172
  }
166
173
 
167
174
  if (command === "completion" && positionalCount === 0) {
@@ -178,6 +185,9 @@ export async function completeRig(input: CompleteRigInput): Promise<CompletionIt
178
185
  export function formatCompletionItems(items: CompletionItem[], shell: CompletionShell): string {
179
186
  const lines = items.map((item) => {
180
187
  if (shell === "bash") return item.value;
188
+ if (shell === "zsh" && item.noSpace) {
189
+ return `${item.value}\t${item.description ?? ""}\tnospace`;
190
+ }
181
191
  return item.description ? `${item.value}\t${item.description}` : item.value;
182
192
  });
183
193
  return lines.join("\n");
@@ -217,22 +227,37 @@ complete -c rig -f -a "(__rig_complete)"
217
227
  `;
218
228
  }
219
229
 
220
- return `#compdef rig
230
+ return `#compdef rig
221
231
  # rig zsh completion
222
232
  _rig() {
223
- local -a raw completions
224
- local line value description
233
+ local -a raw values displays nospace_values nospace_displays
234
+ local line value description rest marker display
225
235
  raw=("\${(@f)$(command rig __complete --shell zsh --index $((CURRENT - 1)) -- "\${words[@]}" 2>/dev/null)}")
226
236
  for line in "\${raw[@]}"; do
227
237
  value="\${line%%$'\\t'*}"
238
+ description=""
239
+ marker=""
228
240
  if [[ "$line" == *$'\\t'* ]]; then
229
- description="\${line#*$'\\t'}"
230
- completions+=("\${value}:\${description}")
241
+ rest="\${line#*$'\\t'}"
242
+ description="\${rest%%$'\\t'*}"
243
+ if [[ "$rest" == *$'\\t'* ]]; then
244
+ marker="\${rest#*$'\\t'}"
245
+ fi
246
+ fi
247
+ display="\${value}"
248
+ if [[ -n "$description" ]]; then
249
+ display="\${value} -- \${description}"
250
+ fi
251
+ if [[ "$marker" == "nospace" ]]; then
252
+ nospace_values+=("\${value}")
253
+ nospace_displays+=("\${display}")
231
254
  else
232
- completions+=("\${value}")
255
+ values+=("\${value}")
256
+ displays+=("\${display}")
233
257
  fi
234
258
  done
235
- _describe 'rig' completions
259
+ (( \${#nospace_values} )) && compadd -S '' -d nospace_displays -a nospace_values
260
+ (( \${#values} )) && compadd -d displays -a values
236
261
  }
237
262
  compdef _rig rig
238
263
  `;
@@ -278,7 +303,7 @@ function countPositionals(words: string[], command: string): number {
278
303
  return count;
279
304
  }
280
305
 
281
- function parseRunCommand(words: string[]): { operation?: string; args: string[] } {
306
+ function parseWorkspaceRunCommand(words: string[]): { workspace?: string; operation?: string; args: string[] } {
282
307
  let foundRun = false;
283
308
  const args: string[] = [];
284
309
  for (let index = 0; index < words.length; index += 1) {
@@ -295,37 +320,7 @@ function parseRunCommand(words: string[]): { operation?: string; args: string[]
295
320
  }
296
321
  args.push(word);
297
322
  }
298
- return { operation: args[0], args: args.slice(1) };
299
- }
300
-
301
- function parseRootOperation(words: string[]): { operation?: string; args: string[] } {
302
- const args: string[] = [];
303
- for (let index = 0; index < words.length; index += 1) {
304
- const word = words[index]!;
305
- if (OPTIONS_WITH_VALUES.has(word)) {
306
- index += 1;
307
- continue;
308
- }
309
- if (word.startsWith("--") && word.includes("=")) continue;
310
- if (word.startsWith("-")) continue;
311
- args.push(word);
312
- }
313
- return { operation: args[0], args: args.slice(1) };
314
- }
315
-
316
- function countRunOperationPositionals(args: string[]): number {
317
- let count = 0;
318
- for (let index = 0; index < args.length; index += 1) {
319
- const arg = args[index]!;
320
- if (OPTIONS_WITH_VALUES.has(arg)) {
321
- index += 1;
322
- continue;
323
- }
324
- if (arg.startsWith("--") && arg.includes("=")) continue;
325
- if (arg.startsWith("-")) continue;
326
- count += 1;
327
- }
328
- return count;
323
+ return { workspace: args[0], operation: args[1], args: args.slice(2) };
329
324
  }
330
325
 
331
326
  function expectsOptionValue(words: string[]): boolean {
@@ -362,92 +357,85 @@ function projectPaths(projectDir: string): { projectDir: string; configPath: str
362
357
 
363
358
  async function workspaceTargets(
364
359
  paths: { projectDir: string; configPath: string },
365
- _current: string,
366
- _includeVmIds: boolean,
367
360
  ): Promise<CompletionItem[]> {
368
361
  const workspaces = await readWorkspaces(paths);
369
362
  const items = workspaces.map((workspace) => ({
370
363
  value: workspace.name,
371
- description: workspace.workflow,
364
+ description: workspaceDescription(workspace, { prefix: false }),
372
365
  }));
373
366
 
374
367
  return dedupeItems(items);
375
368
  }
376
369
 
377
- async function readWorkspaces(paths: { projectDir: string; configPath: string }): Promise<Array<{ name: string; workflow: string }>> {
370
+ async function readWorkspaces(paths: { projectDir: string; configPath: string }): Promise<RuntimeWorkspaceCompletion[]> {
378
371
  const runtime = await getOrStartRuntime(paths);
379
372
  const { workspaces } = await runtime.control.workspaces();
380
373
  return workspaces.map((workspace) => ({
381
374
  name: workspace.name,
382
375
  workflow: workspace.workflow,
376
+ createdAt: workspace.createdAt,
377
+ updatedAt: workspace.updatedAt,
383
378
  }));
384
379
  }
385
380
 
386
- async function operationTargets(
381
+ async function safeWorkspaceOperationTargets(
387
382
  paths: { projectDir: string; configPath: string },
388
- current: string,
389
383
  ): Promise<CompletionItem[]> {
390
- const manifest = await readOperations(paths);
391
- if (current.includes("/")) {
392
- return workspaceOperationTargets(manifest, current);
384
+ try {
385
+ const manifest = await readOperations(paths);
386
+ return workspaceOperationTargets(manifest);
387
+ } catch {
388
+ return [];
393
389
  }
394
- const workspaces = await readWorkspaces(paths).catch(() => []);
395
- return manifest.operations.flatMap((operation) => [
396
- { value: operation.id, description: operation.description },
397
- ...(operation.aliases ?? []).map((alias) => ({ value: alias, description: operation.description })),
398
- ]).concat(workspaces.map((workspace) => ({
399
- value: `${workspace.name}/`,
400
- description: `workspace ${workspace.workflow}`,
401
- })));
402
390
  }
403
391
 
404
- function workspaceOperationTargets(manifest: RuntimeOperationManifest, current: string): CompletionItem[] {
405
- const slash = current.indexOf("/");
406
- if (slash < 0) return [];
407
- const workspace = current.slice(0, slash);
408
- if (!workspace) return [];
392
+ function workspaceOperationTargets(manifest: RuntimeOperationManifest): CompletionItem[] {
409
393
  return (manifest.workspaceOperations ?? []).flatMap((operation) => [
410
- { value: `${workspace}/${operation.id}`, description: operation.description ?? "workspace operation" },
394
+ { value: operation.id, description: operation.description ?? "workspace operation" },
411
395
  ...(operation.aliases ?? []).map((alias) => ({
412
- value: `${workspace}/${alias}`,
396
+ value: alias,
413
397
  description: operation.description ?? "workspace operation",
414
398
  })),
415
399
  ]);
416
400
  }
417
401
 
418
- async function safeOperationTargets(
402
+ async function resolveRuntimeOperation(
419
403
  paths: { projectDir: string; configPath: string },
420
- current: string,
421
- ): Promise<CompletionItem[]> {
404
+ operationId: string,
405
+ ): Promise<RuntimeOperationDefinition | undefined> {
406
+ const manifest = await readOperations(paths);
407
+ return manifest.operations.find((operation) =>
408
+ operation.id === operationId || operation.aliases?.includes(operationId)
409
+ );
410
+ }
411
+
412
+ async function safeResolveRuntimeOperation(
413
+ paths: { projectDir: string; configPath: string },
414
+ operationId: string,
415
+ ): Promise<RuntimeOperationDefinition | undefined> {
422
416
  try {
423
- return await operationTargets(paths, current);
417
+ return await resolveRuntimeOperation(paths, operationId);
424
418
  } catch {
425
- return [];
419
+ return undefined;
426
420
  }
427
421
  }
428
422
 
429
- async function resolveRuntimeOperation(
423
+ async function resolveWorkspaceOperation(
430
424
  paths: { projectDir: string; configPath: string },
431
425
  operationId: string,
432
426
  ): Promise<RuntimeOperationDefinition | undefined> {
433
427
  const manifest = await readOperations(paths);
434
- const workspaceOperation = parseWorkspaceOperationId(operationId);
435
- if (workspaceOperation) {
436
- return (manifest.workspaceOperations ?? []).find((operation) =>
437
- operation.id === workspaceOperation.operation || operation.aliases?.includes(workspaceOperation.operation)
438
- );
439
- }
440
- return manifest.operations.find((operation) =>
428
+ return (manifest.workspaceOperations ?? []).find((operation) =>
441
429
  operation.id === operationId || operation.aliases?.includes(operationId)
442
430
  );
443
431
  }
444
432
 
445
- async function safeResolveRuntimeOperation(
433
+ async function safeResolveWorkspaceOperation(
446
434
  paths: { projectDir: string; configPath: string },
447
435
  operationId: string,
448
436
  ): Promise<RuntimeOperationDefinition | undefined> {
449
437
  try {
450
- return await resolveRuntimeOperation(paths, operationId);
438
+ return await resolveWorkspaceOperation(paths, operationId);
451
439
  } catch {
452
440
  return undefined;
453
441
  }
@@ -458,15 +446,6 @@ async function readOperations(paths: { projectDir: string; configPath: string })
458
446
  return await runtime.control.operations() as unknown as RuntimeOperationManifest;
459
447
  }
460
448
 
461
- function parseWorkspaceOperationId(value: string): { workspace: string; operation: string } | undefined {
462
- const slash = value.indexOf("/");
463
- if (slash <= 0 || slash === value.length - 1) return undefined;
464
- return {
465
- workspace: value.slice(0, slash),
466
- operation: value.slice(slash + 1),
467
- };
468
- }
469
-
470
449
  function filterItems(items: CompletionItem[], current: string): CompletionItem[] {
471
450
  return dedupeItems(items).filter((item) => item.value.startsWith(current));
472
451
  }
@@ -481,3 +460,31 @@ function dedupeItems(items: CompletionItem[]): CompletionItem[] {
481
460
  }
482
461
  return deduped;
483
462
  }
463
+
464
+ function workspaceDescription(workspace: RuntimeWorkspaceCompletion, options: { prefix: boolean }): string {
465
+ const label = options.prefix ? `workspace ${workspace.workflow}` : workspace.workflow;
466
+ const age = formatWorkspaceAge(workspace.createdAt);
467
+ return age ? `${label} - ${age}` : label;
468
+ }
469
+
470
+ export function formatWorkspaceAge(createdAt: string, nowMs = Date.now()): string | undefined {
471
+ const createdAtMs = Date.parse(createdAt);
472
+ if (!Number.isFinite(createdAtMs)) return undefined;
473
+
474
+ const elapsedSeconds = Math.max(0, Math.floor((nowMs - createdAtMs) / 1000));
475
+ if (elapsedSeconds < 60) return "just now";
476
+
477
+ const elapsedMinutes = Math.floor(elapsedSeconds / 60);
478
+ if (elapsedMinutes < 60) return `${elapsedMinutes}m old`;
479
+
480
+ const elapsedHours = Math.floor(elapsedMinutes / 60);
481
+ if (elapsedHours < 48) return `${elapsedHours}h old`;
482
+
483
+ const elapsedDays = Math.floor(elapsedHours / 24);
484
+ if (elapsedDays < 30) return `${elapsedDays}d old`;
485
+
486
+ const elapsedMonths = Math.floor(elapsedDays / 30);
487
+ if (elapsedMonths < 24) return `${elapsedMonths}mo old`;
488
+
489
+ return `${Math.floor(elapsedMonths / 12)}y old`;
490
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const RIGKIT_CLI_VERSION = "0.2.4";
1
+ export const RIGKIT_CLI_VERSION = "0.2.6";
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { generateWorkspaceName } from "./workspace-name.ts";
3
+
4
+ describe("workspace name defaults", () => {
5
+ test("generates shell-safe adjective noun names", () => {
6
+ expect(generateWorkspaceName([], () => 0)).toBe("snowy-ridge");
7
+ });
8
+
9
+ test("skips generated names that already exist", () => {
10
+ const randomValues = [0, 0, 0.5, 0.5];
11
+ let index = 0;
12
+ const name = generateWorkspaceName(["snowy-ridge"], () => randomValues[index++] ?? 0.5);
13
+
14
+ expect(name).not.toBe("snowy-ridge");
15
+ expect(name).toMatch(/^(?!-)[A-Za-z0-9._-]+$/);
16
+ });
17
+ });
@@ -0,0 +1,59 @@
1
+ const adjectives = [
2
+ "snowy",
3
+ "bright",
4
+ "quiet",
5
+ "rapid",
6
+ "silver",
7
+ "clear",
8
+ "steady",
9
+ "bold",
10
+ "lucky",
11
+ "fresh",
12
+ "golden",
13
+ "nimble",
14
+ "calm",
15
+ "brisk",
16
+ "sharp",
17
+ "sunny",
18
+ ] as const;
19
+
20
+ const nouns = [
21
+ "ridge",
22
+ "harbor",
23
+ "signal",
24
+ "orbit",
25
+ "bridge",
26
+ "summit",
27
+ "field",
28
+ "grove",
29
+ "spark",
30
+ "stone",
31
+ "valley",
32
+ "meadow",
33
+ "trail",
34
+ "cove",
35
+ "anchor",
36
+ "beacon",
37
+ ] as const;
38
+
39
+ export function generateWorkspaceName(
40
+ existingNames: Iterable<string> = [],
41
+ random: () => number = Math.random,
42
+ ): string {
43
+ const existing = new Set(existingNames);
44
+
45
+ for (let attempt = 0; attempt < 100; attempt += 1) {
46
+ const name = `${pick(adjectives, random)}-${pick(nouns, random)}`;
47
+ if (!existing.has(name)) return name;
48
+ }
49
+
50
+ return `workspace-${Date.now().toString(36)}-${Math.floor(random() * 10000).toString(36)}`;
51
+ }
52
+
53
+ function pick<const Values extends readonly string[]>(
54
+ values: Values,
55
+ random: () => number,
56
+ ): Values[number] {
57
+ const index = Math.min(values.length - 1, Math.floor(random() * values.length));
58
+ return values[index]!;
59
+ }