@rigkit/cli 0.2.5 → 0.2.7

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
@@ -5,9 +5,9 @@ Global `rig` CLI.
5
5
  ```bash
6
6
  npm i -g @rigkit/cli
7
7
  rig init
8
- rig run plan
8
+ rig plan
9
9
  rig ls
10
- rig run plan github:owner/repo
10
+ rig plan github:owner/repo
11
11
  ```
12
12
 
13
13
  `rig init` asks for a project name, Freestyle API key, and package manager. It creates a project folder containing a workflow-based `rig.config.ts`, `.env`, `.env.example`, `package.json`, and local ignore rules.
@@ -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 run <workspace>/<operation>`, for example `rig run website-workspace/open-cmux` or `rig run 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.5",
3
+ "version": "0.2.7",
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/engine": "0.2.5",
29
- "@rigkit/runtime-client": "0.2.5",
30
- "@rigkit/provider-cmux": "0.2.5"
28
+ "@rigkit/provider-cmux": "0.2.7",
29
+ "@rigkit/runtime-client": "0.2.7",
30
+ "@rigkit/engine": "0.2.7"
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("run 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,7 +35,8 @@ 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("run 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
42
  test("rejects operation shorthand at the root", async () => {
@@ -110,7 +112,7 @@ describe("CLI entrypoint", () => {
110
112
  const projectDir = mkdtempSync(join(tmpdir(), "rigkit-cli-create-name-"));
111
113
 
112
114
  await withWorkspaceRuntime({ projectDir }, async ({ env }) => {
113
- const result = await runCli(["-C", projectDir, "run", "create", "--name", "some workspace", "--json"], { env });
115
+ const result = await runCli(["-C", projectDir, "create", "--name", "some workspace", "--json"], { env });
114
116
 
115
117
  expect(result.exitCode).toBe(1);
116
118
  expect(result.stdout).toBe("");
@@ -122,7 +124,7 @@ describe("CLI entrypoint", () => {
122
124
  const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-run-all-"));
123
125
 
124
126
  try {
125
- const result = await runCli(["run", "plan", "--all", "--json"], { cwd });
127
+ const result = await runCli(["plan", "--all", "--json"], { cwd });
126
128
 
127
129
  expect(result.exitCode).toBe(1);
128
130
  expect(result.stdout).toBe("");
@@ -140,7 +142,7 @@ describe("CLI entrypoint", () => {
140
142
  writeFileSync(join(cwd, "web", "rig.config.ts"), "export default {}\n");
141
143
 
142
144
  try {
143
- const result = await runCli(["run", "plan", "--discover", "--json"], { cwd });
145
+ const result = await runCli(["plan", "--discover", "--json"], { cwd });
144
146
 
145
147
  expect(result.exitCode).toBe(1);
146
148
  expect(result.stdout).toBe("");
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;
@@ -196,22 +197,46 @@ async function runCli(argv: string[]): Promise<void> {
196
197
  });
197
198
  });
198
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
+
199
228
  program
200
- .command("run <operation> [args...]")
201
- .description("Run a project operation exposed by the runtime")
229
+ .command("run <workspace> <operation> [args...]")
230
+ .description("Run a workspace operation")
202
231
  .allowUnknownOption(true)
203
- .option("--all", "Run against every discovered project")
204
- .option("--discover", "Discover projects below the selected directory")
205
232
  .option("--json", "Print machine-readable JSON")
206
233
  .action(async (
234
+ workspace: string,
207
235
  operation: string,
208
236
  args: string[],
209
- options: { all?: boolean; discover?: boolean; json?: boolean },
237
+ options: { json?: boolean },
210
238
  ) => {
211
- await runProjectOperation(makeInvocation(rootOptions(program), options.json), operation, args ?? [], {
212
- all: Boolean(options.all),
213
- discover: Boolean(options.discover),
214
- });
239
+ await runWorkspaceOperation(makeInvocation(rootOptions(program), options.json), workspace, operation, args ?? []);
215
240
  });
216
241
 
217
242
  program
@@ -468,11 +493,12 @@ async function promptPackageManager(defaultValue: PackageManager): Promise<Packa
468
493
  return answers.packageManager;
469
494
  }
470
495
 
471
- async function promptWorkspaceName(): Promise<string> {
496
+ async function promptWorkspaceName(defaultValue: string): Promise<string> {
472
497
  const answers = await inquirer.prompt<{ name: string }>([{
473
498
  type: "input",
474
499
  name: "name",
475
500
  message: "Workspace name:",
501
+ default: defaultValue,
476
502
  validate(value: string) {
477
503
  return validateWorkspaceName(value.trim());
478
504
  },
@@ -497,21 +523,11 @@ function assertValidWorkspaceName(value: unknown): void {
497
523
  if (valid !== true) throw new Error(`Invalid workspace name "${value}". ${valid}`);
498
524
  }
499
525
 
500
- async function promptWorkspaceOperation(
501
- workspace: string,
502
- operations: RuntimeOperationDefinition[],
503
- ): Promise<string> {
504
- const answers = await inquirer.prompt<{ operation: string }>([{
505
- type: "select",
506
- name: "operation",
507
- message: `Operation for ${workspace}:`,
508
- choices: operations.map((operation) => ({
509
- name: operation.id,
510
- value: operation.id,
511
- description: operation.description || operation.title || "workspace operation",
512
- })),
513
- }]);
514
- return answers.operation;
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);
515
531
  }
516
532
 
517
533
  async function runPackageManagerInstall(
@@ -595,7 +611,7 @@ function printInitResult(result: InitProjectResult, install: InitInstallResult):
595
611
  if (install.skipped) {
596
612
  console.log(` ${detectInstallCommand(result.packageJsonPath)}`);
597
613
  }
598
- console.log(" rig run plan");
614
+ console.log(" rig plan");
599
615
  }
600
616
 
601
617
  function displayProjectDir(projectDir: string): string {
@@ -667,6 +683,47 @@ async function runProjectOperation(
667
683
  printInteractiveOutputGap(invocation);
668
684
  }
669
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);
725
+ }
726
+
670
727
  async function runDiscoveredProjectOperation(
671
728
  invocation: CliInvocation,
672
729
  requestedOperation: string,
@@ -791,13 +848,13 @@ async function executeRuntimeOperation(
791
848
  result: unknown;
792
849
  }> {
793
850
  const manifest = await readRuntimeOperations(runtime);
794
- const resolved = await resolveRequestedRuntimeOperation(invocation, runtime, manifest, requestedOperation);
851
+ const resolved = findRuntimeOperation(manifest, requestedOperation);
795
852
  if (!resolved) {
796
853
  throw new Error(`This project does not define a Rigkit operation named "${requestedOperation}".`);
797
854
  }
798
855
  const { operation, runOperation } = resolved;
799
856
 
800
- const parsed = await parseOperationArgsWithPrompts(invocation, operation, args);
857
+ const parsed = await parseOperationArgsWithPrompts(invocation, runtime, operation, args);
801
858
  enforceHostOnlyBooleanGuards(operation, parsed);
802
859
 
803
860
  const result = await runRuntimeOperation<unknown>(
@@ -810,55 +867,19 @@ async function executeRuntimeOperation(
810
867
  return { operation, parsed, result };
811
868
  }
812
869
 
813
- async function resolveRequestedRuntimeOperation(
814
- invocation: CliInvocation,
815
- runtime: RuntimeClient,
816
- manifest: RuntimeOperationManifest,
817
- requestedOperation: string,
818
- ): Promise<{ operation: RuntimeOperationDefinition; runOperation: string } | undefined> {
819
- const resolved = findRuntimeOperation(manifest, requestedOperation);
820
- if (resolved) return resolved;
821
-
822
- if (requestedOperation.includes("/") || wantsJson(invocation) || !canPrompt()) return undefined;
823
-
824
- const workspaces = await runtime.control.workspaces()
825
- .then((response) => response.workspaces as WorkspaceRecord[])
826
- .catch(() => []);
827
- const workspace = workspaces.find((item) => item.name === requestedOperation);
828
- if (!workspace || (manifest.workspaceOperations ?? []).length === 0) return undefined;
829
-
830
- const operationId = await promptWorkspaceOperation(workspace.name, manifest.workspaceOperations ?? []);
831
- const operation = (manifest.workspaceOperations ?? []).find((item) => item.id === operationId);
832
- return operation ? { operation, runOperation: `${workspace.name}/${operation.id}` } : undefined;
833
- }
834
-
835
870
  function findRuntimeOperation(
836
871
  manifest: RuntimeOperationManifest,
837
872
  requestedOperation: string,
838
873
  ): { operation: RuntimeOperationDefinition; runOperation: string } | undefined {
839
- const workspaceOperation = parseWorkspaceOperationId(requestedOperation);
840
- if (workspaceOperation) {
841
- const operation = (manifest.workspaceOperations ?? []).find((item) => item.id === workspaceOperation.operation);
842
- return operation ? { operation, runOperation: requestedOperation } : undefined;
843
- }
844
-
845
874
  const operation = manifest.operations.find((operation) =>
846
875
  operation.id === requestedOperation || operation.aliases?.includes(requestedOperation)
847
876
  );
848
877
  return operation ? { operation, runOperation: operation.id } : undefined;
849
878
  }
850
879
 
851
- function parseWorkspaceOperationId(value: string): { workspace: string; operation: string } | undefined {
852
- const slash = value.indexOf("/");
853
- if (slash <= 0 || slash === value.length - 1) return undefined;
854
- return {
855
- workspace: value.slice(0, slash),
856
- operation: value.slice(slash + 1),
857
- };
858
- }
859
-
860
880
  async function parseOperationArgsWithPrompts(
861
881
  invocation: CliInvocation,
882
+ runtime: RuntimeClient,
862
883
  operation: RuntimeOperationDefinition,
863
884
  args: string[],
864
885
  ): Promise<ParsedOperationInput> {
@@ -872,7 +893,7 @@ async function parseOperationArgsWithPrompts(
872
893
  !wantsJson(invocation) &&
873
894
  canPrompt()
874
895
  ) {
875
- parsed.input.name = await promptWorkspaceName();
896
+ parsed.input.name = await promptWorkspaceName(await defaultWorkspaceName(runtime));
876
897
  }
877
898
  if (operation.createsWorkspace && parsed.input.name !== undefined) {
878
899
  assertValidWorkspaceName(parsed.input.name);
@@ -1148,7 +1169,10 @@ async function runHelp(invocation: CliInvocation): Promise<void> {
1148
1169
  commands: [
1149
1170
  { name: "help", description: "Show Rigkit CLI help" },
1150
1171
  { name: "init", description: "Initialize a Rigkit project" },
1151
- { name: "run", 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" },
1152
1176
  { name: "ls", description: "List project workspaces" },
1153
1177
  { name: "projects", description: "Discover Rigkit projects below the current directory" },
1154
1178
  { name: "doctor", description: "Show Rigkit runtime diagnostics" },
@@ -1167,7 +1191,10 @@ async function runHelp(invocation: CliInvocation): Promise<void> {
1167
1191
  "Commands:",
1168
1192
  " help Show Rigkit CLI help",
1169
1193
  " init Initialize a Rigkit project",
1170
- " run 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",
1171
1198
  " ls List project workspaces",
1172
1199
  " projects Discover Rigkit projects below the current directory",
1173
1200
  " doctor Show Rigkit runtime diagnostics",
@@ -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", "run", "ssh", ""],
15
- currentIndex: 3,
14
+ words: ["rig", "run", ""],
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("created 2h ago");
20
20
  });
21
21
  });
22
22
 
@@ -25,8 +25,8 @@ describe("CLI completion", () => {
25
25
  await withWorkspaceRuntime({ projectDir }, async () => {
26
26
  const items = await completeRig({
27
27
  cwd: projectDir,
28
- words: ["rig", "run", "ssh", "vm-"],
29
- currentIndex: 3,
28
+ words: ["rig", "run", "vm-"],
29
+ currentIndex: 2,
30
30
  });
31
31
 
32
32
  expect(items).toEqual([]);
@@ -39,8 +39,8 @@ 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", "run", "ssh", ""],
43
- currentIndex: 5,
42
+ words: ["rig", "-C", "project", "run", ""],
43
+ currentIndex: 4,
44
44
  });
45
45
 
46
46
  expect(items.map((item) => item.value)).toEqual(["api", "web"]);
@@ -55,42 +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: "created 2h ago" });
60
60
 
61
61
  const exactWorkspace = await completeRig({
62
62
  cwd: projectDir,
63
63
  words: ["rig", "run", "api"],
64
64
  currentIndex: 2,
65
65
  });
66
- expect(exactWorkspace.map((item) => item.value)).toEqual(["api/remove", "api/open-cmux"]);
66
+ expect(exactWorkspace.map((item) => item.value)).toEqual(["api"]);
67
67
 
68
68
  const workspaceAfterSpace = await completeRig({
69
69
  cwd: projectDir,
70
70
  words: ["rig", "run", "api", ""],
71
71
  currentIndex: 3,
72
72
  });
73
- expect(workspaceAfterSpace.map((item) => item.value)).toEqual(["api/remove", "api/open-cmux"]);
73
+ expect(workspaceAfterSpace.map((item) => item.value)).toEqual(["remove", "open-cmux"]);
74
74
 
75
- const slashWorkspace = await completeRig({
75
+ const operationPrefix = await completeRig({
76
76
  cwd: projectDir,
77
- words: ["rig", "run", "api/"],
78
- currentIndex: 2,
77
+ words: ["rig", "run", "api", "open"],
78
+ currentIndex: 3,
79
79
  });
80
- expect(slashWorkspace.map((item) => item.value)).toEqual(["api/remove", "api/open-cmux"]);
80
+ expect(operationPrefix.map((item) => item.value)).toEqual(["open-cmux"]);
81
81
  });
82
82
  });
83
83
 
84
- test("does not complete runtime operations at the root command position", async () => {
84
+ test("completes top-level project commands at the root command position", async () => {
85
85
  const projectDir = mkdtempSync(join(tmpdir(), "rigkit-completion-"));
86
86
  await withWorkspaceRuntime({ projectDir }, async () => {
87
87
  const items = await completeRig({
88
88
  cwd: projectDir,
89
- words: ["rig", "s"],
89
+ words: ["rig", "p"],
90
90
  currentIndex: 1,
91
91
  });
92
92
 
93
- expect(items.map((item) => item.value)).not.toContain("ssh");
93
+ expect(items.map((item) => item.value)).toEqual(["plan", "projects"]);
94
94
  });
95
95
  });
96
96
 
@@ -102,7 +102,20 @@ describe("CLI completion", () => {
102
102
  expect(formatCompletionItems([{ value: "api", description: "workspace smoke", noSpace: true }], "zsh"))
103
103
  .toBe("api\tworkspace smoke\tnospace");
104
104
  expect(renderCompletionScript("zsh")).toContain("rig __complete");
105
- expect(renderCompletionScript("zsh")).toContain("compadd -S ''");
105
+ expect(renderCompletionScript("zsh")).toContain("compadd -ld displays -a values");
106
+ expect(renderCompletionScript("zsh")).toContain("compadd -S '' -ld nospace_displays -a nospace_values");
107
+ expect(renderCompletionScript("zsh")).toContain('display="${value} -- ${description}"');
108
+ expect(renderCompletionScript("zsh")).not.toContain("_describe");
109
+ });
110
+
111
+ test("formats workspace ages", () => {
112
+ const now = Date.parse("2026-05-14T12:00:00.000Z");
113
+
114
+ expect(formatWorkspaceAge("2026-05-14T11:59:45.000Z", now)).toBe("just now");
115
+ expect(formatWorkspaceAge("2026-05-14T11:30:00.000Z", now)).toBe("30m ago");
116
+ expect(formatWorkspaceAge("2026-05-14T09:00:00.000Z", now)).toBe("3h ago");
117
+ expect(formatWorkspaceAge("2026-05-11T12:00:00.000Z", now)).toBe("3d ago");
118
+ expect(formatWorkspaceAge("not-a-date", now)).toBeUndefined();
106
119
  });
107
120
 
108
121
  test("completes ls targets", async () => {
@@ -154,7 +167,10 @@ async function withWorkspaceRuntime(
154
167
  });
155
168
  }
156
169
  if (pathname === "/workspaces") {
157
- const now = new Date(0).toISOString();
170
+ const nowMs = Date.now();
171
+ const apiCreatedAt = new Date(nowMs - 2 * 60 * 60 * 1000).toISOString();
172
+ const webCreatedAt = new Date(nowMs - 5 * 60 * 1000).toISOString();
173
+ const updatedAt = new Date(nowMs).toISOString();
158
174
  return runtimeJson({
159
175
  workspaces: [
160
176
  {
@@ -162,16 +178,16 @@ async function withWorkspaceRuntime(
162
178
  name: "api",
163
179
  workflow: "smoke",
164
180
  ctx: {},
165
- createdAt: now,
166
- updatedAt: now,
181
+ createdAt: apiCreatedAt,
182
+ updatedAt,
167
183
  },
168
184
  {
169
185
  id: "workspace-web",
170
186
  name: "web",
171
187
  workflow: "smoke",
172
188
  ctx: {},
173
- createdAt: now,
174
- updatedAt: now,
189
+ createdAt: webCreatedAt,
190
+ updatedAt,
175
191
  },
176
192
  ],
177
193
  });
package/src/completion.ts CHANGED
@@ -18,7 +18,10 @@ type CompleteRigInput = {
18
18
  const COMMANDS: CompletionItem[] = [
19
19
  { value: "help", description: "show CLI help" },
20
20
  { value: "init", description: "initialize a Rigkit project" },
21
- { 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" },
22
25
  { value: "ls", description: "list project workspaces" },
23
26
  { value: "projects", description: "discover Rigkit projects" },
24
27
  { value: "doctor", description: "show runtime diagnostics" },
@@ -46,11 +49,22 @@ const COMMAND_OPTIONS: Record<string, CompletionItem[]> = {
46
49
  { value: "--force", description: "overwrite existing config" },
47
50
  { value: "--json", description: "print JSON" },
48
51
  ],
49
- operation: [
52
+ plan: [
50
53
  { value: "--all", description: "run against every discovered project" },
51
54
  { value: "--discover", description: "discover projects below the selected directory" },
52
55
  { value: "--json", description: "print JSON" },
53
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
+ ],
54
68
  ls: [
55
69
  { value: "workspaces", description: "list workspaces" },
56
70
  { value: "snapshots", description: "list snapshots" },
@@ -67,6 +81,8 @@ const COMMAND_OPTIONS: Record<string, CompletionItem[]> = {
67
81
  ],
68
82
  };
69
83
 
84
+ const PROJECT_OPERATION_COMMANDS = new Set(["plan", "apply", "create"]);
85
+
70
86
  const OPTIONS_WITH_VALUES = new Set([
71
87
  "-C",
72
88
  "--project",
@@ -92,6 +108,13 @@ type RuntimeOperationDefinition = {
92
108
  };
93
109
  };
94
110
 
111
+ type RuntimeWorkspaceCompletion = {
112
+ name: string;
113
+ workflow: string;
114
+ createdAt: string;
115
+ updatedAt: string;
116
+ };
117
+
95
118
  export async function completeRig(input: CompleteRigInput): Promise<CompletionItem[]> {
96
119
  const cwd = input.cwd ?? process.cwd();
97
120
  const words = input.words.length > 0 ? input.words : ["rig"];
@@ -113,38 +136,39 @@ export async function completeRig(input: CompleteRigInput): Promise<CompletionIt
113
136
 
114
137
  if (current.startsWith("-")) {
115
138
  if (command === "run") {
116
- const run = parseRunCommand(before);
117
- if (run.operation) {
118
- 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);
119
142
  return filterItems([
120
143
  ...(operation?.cli?.options ?? []).flatMap((option) => [
121
144
  { value: option.flag, description: option.name },
122
145
  ...(option.aliases ?? []).map((alias) => ({ value: alias, description: option.name })),
123
146
  ]),
124
- ...COMMAND_OPTIONS.operation,
147
+ ...COMMAND_OPTIONS.run,
125
148
  ...GLOBAL_OPTIONS,
126
149
  ], current);
127
150
  }
128
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
+ }
129
163
  return filterItems([...(COMMAND_OPTIONS[command] ?? []), ...GLOBAL_OPTIONS], current);
130
164
  }
131
165
 
132
166
  const positionalCount = countPositionals(before, command);
133
167
 
134
168
  if (command === "run") {
135
- const run = parseRunCommand(before);
136
- if (!run.operation) {
137
- return filterItems(await safeOperationTargets(resolveProjectDir(words, cwd), current), current);
138
- }
139
- const operation = await safeResolveRuntimeOperation(resolveProjectDir(words, cwd), run.operation);
140
- if (!operation && run.args.length === 0) {
141
- return filterItems(await safeWorkspaceOperationTargets(resolveProjectDir(words, cwd), run.operation), current);
142
- }
143
- const operationPositionalCount = countRunOperationPositionals(run.args);
144
- const positional = operation?.cli?.positionals?.find((item) => item.index === operationPositionalCount);
145
- if (positional && /workspace|vm/i.test(positional.name)) {
146
- return filterItems(await workspaceTargets(resolveProjectDir(words, cwd), current, /vm/i.test(positional.name)), current);
147
- }
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);
148
172
  }
149
173
 
150
174
  if (command === "completion" && positionalCount === 0) {
@@ -206,8 +230,8 @@ complete -c rig -f -a "(__rig_complete)"
206
230
  return `#compdef rig
207
231
  # rig zsh completion
208
232
  _rig() {
209
- local -a raw values descriptions nospace_values nospace_descriptions
210
- local line value description rest marker
233
+ local -a raw values displays nospace_values nospace_displays
234
+ local line value description rest marker display
211
235
  raw=("\${(@f)$(command rig __complete --shell zsh --index $((CURRENT - 1)) -- "\${words[@]}" 2>/dev/null)}")
212
236
  for line in "\${raw[@]}"; do
213
237
  value="\${line%%$'\\t'*}"
@@ -220,16 +244,20 @@ _rig() {
220
244
  marker="\${rest#*$'\\t'}"
221
245
  fi
222
246
  fi
247
+ display="\${value}"
248
+ if [[ -n "$description" ]]; then
249
+ display="\${value} -- \${description}"
250
+ fi
223
251
  if [[ "$marker" == "nospace" ]]; then
224
252
  nospace_values+=("\${value}")
225
- nospace_descriptions+=("\${description}")
253
+ nospace_displays+=("\${display}")
226
254
  else
227
255
  values+=("\${value}")
228
- descriptions+=("\${description}")
256
+ displays+=("\${display}")
229
257
  fi
230
258
  done
231
- (( \${#nospace_values} )) && compadd -S '' -d nospace_descriptions -a nospace_values
232
- (( \${#values} )) && compadd -d descriptions -a values
259
+ (( \${#nospace_values} )) && compadd -S '' -ld nospace_displays -a nospace_values
260
+ (( \${#values} )) && compadd -ld displays -a values
233
261
  }
234
262
  compdef _rig rig
235
263
  `;
@@ -275,7 +303,7 @@ function countPositionals(words: string[], command: string): number {
275
303
  return count;
276
304
  }
277
305
 
278
- function parseRunCommand(words: string[]): { operation?: string; args: string[] } {
306
+ function parseWorkspaceRunCommand(words: string[]): { workspace?: string; operation?: string; args: string[] } {
279
307
  let foundRun = false;
280
308
  const args: string[] = [];
281
309
  for (let index = 0; index < words.length; index += 1) {
@@ -292,22 +320,7 @@ function parseRunCommand(words: string[]): { operation?: string; args: string[]
292
320
  }
293
321
  args.push(word);
294
322
  }
295
- return { operation: args[0], args: args.slice(1) };
296
- }
297
-
298
- function countRunOperationPositionals(args: string[]): number {
299
- let count = 0;
300
- for (let index = 0; index < args.length; index += 1) {
301
- const arg = args[index]!;
302
- if (OPTIONS_WITH_VALUES.has(arg)) {
303
- index += 1;
304
- continue;
305
- }
306
- if (arg.startsWith("--") && arg.includes("=")) continue;
307
- if (arg.startsWith("-")) continue;
308
- count += 1;
309
- }
310
- return count;
323
+ return { workspace: args[0], operation: args[1], args: args.slice(2) };
311
324
  }
312
325
 
313
326
  function expectsOptionValue(words: string[]): boolean {
@@ -344,112 +357,85 @@ function projectPaths(projectDir: string): { projectDir: string; configPath: str
344
357
 
345
358
  async function workspaceTargets(
346
359
  paths: { projectDir: string; configPath: string },
347
- _current: string,
348
- _includeVmIds: boolean,
349
360
  ): Promise<CompletionItem[]> {
350
361
  const workspaces = await readWorkspaces(paths);
351
362
  const items = workspaces.map((workspace) => ({
352
363
  value: workspace.name,
353
- description: workspace.workflow,
364
+ description: workspaceDescription(workspace),
354
365
  }));
355
366
 
356
367
  return dedupeItems(items);
357
368
  }
358
369
 
359
- 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[]> {
360
371
  const runtime = await getOrStartRuntime(paths);
361
372
  const { workspaces } = await runtime.control.workspaces();
362
373
  return workspaces.map((workspace) => ({
363
374
  name: workspace.name,
364
375
  workflow: workspace.workflow,
376
+ createdAt: workspace.createdAt,
377
+ updatedAt: workspace.updatedAt,
365
378
  }));
366
379
  }
367
380
 
368
- async function operationTargets(
369
- paths: { projectDir: string; configPath: string },
370
- current: string,
371
- ): Promise<CompletionItem[]> {
372
- const manifest = await readOperations(paths);
373
- if (current.includes("/")) {
374
- return workspaceOperationTargets(manifest, current);
375
- }
376
- const workspaces = await readWorkspaces(paths).catch(() => []);
377
- if (workspaces.some((workspace) => workspace.name === current)) {
378
- return workspaceOperationTargets(manifest, `${current}/`);
379
- }
380
- return manifest.operations.flatMap((operation) => [
381
- { value: operation.id, description: operation.description },
382
- ...(operation.aliases ?? []).map((alias) => ({ value: alias, description: operation.description })),
383
- ]).concat(workspaces.map((workspace) => ({
384
- value: workspace.name,
385
- description: `workspace ${workspace.workflow}`,
386
- noSpace: true,
387
- })));
388
- }
389
-
390
381
  async function safeWorkspaceOperationTargets(
391
382
  paths: { projectDir: string; configPath: string },
392
- workspace: string,
393
383
  ): Promise<CompletionItem[]> {
394
384
  try {
395
- const [manifest, workspaces] = await Promise.all([
396
- readOperations(paths),
397
- readWorkspaces(paths),
398
- ]);
399
- if (!workspaces.some((item) => item.name === workspace)) return [];
400
- return workspaceOperationTargets(manifest, `${workspace}/`);
385
+ const manifest = await readOperations(paths);
386
+ return workspaceOperationTargets(manifest);
401
387
  } catch {
402
388
  return [];
403
389
  }
404
390
  }
405
391
 
406
- function workspaceOperationTargets(manifest: RuntimeOperationManifest, current: string): CompletionItem[] {
407
- const slash = current.indexOf("/");
408
- if (slash < 0) return [];
409
- const workspace = current.slice(0, slash);
410
- if (!workspace) return [];
392
+ function workspaceOperationTargets(manifest: RuntimeOperationManifest): CompletionItem[] {
411
393
  return (manifest.workspaceOperations ?? []).flatMap((operation) => [
412
- { value: `${workspace}/${operation.id}`, description: operation.description ?? "workspace operation" },
394
+ { value: operation.id, description: operation.description ?? "workspace operation" },
413
395
  ...(operation.aliases ?? []).map((alias) => ({
414
- value: `${workspace}/${alias}`,
396
+ value: alias,
415
397
  description: operation.description ?? "workspace operation",
416
398
  })),
417
399
  ]);
418
400
  }
419
401
 
420
- async function safeOperationTargets(
402
+ async function resolveRuntimeOperation(
421
403
  paths: { projectDir: string; configPath: string },
422
- current: string,
423
- ): 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> {
424
416
  try {
425
- return await operationTargets(paths, current);
417
+ return await resolveRuntimeOperation(paths, operationId);
426
418
  } catch {
427
- return [];
419
+ return undefined;
428
420
  }
429
421
  }
430
422
 
431
- async function resolveRuntimeOperation(
423
+ async function resolveWorkspaceOperation(
432
424
  paths: { projectDir: string; configPath: string },
433
425
  operationId: string,
434
426
  ): Promise<RuntimeOperationDefinition | undefined> {
435
427
  const manifest = await readOperations(paths);
436
- const workspaceOperation = parseWorkspaceOperationId(operationId);
437
- if (workspaceOperation) {
438
- return (manifest.workspaceOperations ?? []).find((operation) =>
439
- operation.id === workspaceOperation.operation || operation.aliases?.includes(workspaceOperation.operation)
440
- );
441
- }
442
- return manifest.operations.find((operation) =>
428
+ return (manifest.workspaceOperations ?? []).find((operation) =>
443
429
  operation.id === operationId || operation.aliases?.includes(operationId)
444
430
  );
445
431
  }
446
432
 
447
- async function safeResolveRuntimeOperation(
433
+ async function safeResolveWorkspaceOperation(
448
434
  paths: { projectDir: string; configPath: string },
449
435
  operationId: string,
450
436
  ): Promise<RuntimeOperationDefinition | undefined> {
451
437
  try {
452
- return await resolveRuntimeOperation(paths, operationId);
438
+ return await resolveWorkspaceOperation(paths, operationId);
453
439
  } catch {
454
440
  return undefined;
455
441
  }
@@ -460,15 +446,6 @@ async function readOperations(paths: { projectDir: string; configPath: string })
460
446
  return await runtime.control.operations() as unknown as RuntimeOperationManifest;
461
447
  }
462
448
 
463
- function parseWorkspaceOperationId(value: string): { workspace: string; operation: string } | undefined {
464
- const slash = value.indexOf("/");
465
- if (slash <= 0 || slash === value.length - 1) return undefined;
466
- return {
467
- workspace: value.slice(0, slash),
468
- operation: value.slice(slash + 1),
469
- };
470
- }
471
-
472
449
  function filterItems(items: CompletionItem[], current: string): CompletionItem[] {
473
450
  return dedupeItems(items).filter((item) => item.value.startsWith(current));
474
451
  }
@@ -483,3 +460,30 @@ function dedupeItems(items: CompletionItem[]): CompletionItem[] {
483
460
  }
484
461
  return deduped;
485
462
  }
463
+
464
+ function workspaceDescription(workspace: RuntimeWorkspaceCompletion): string {
465
+ const age = formatWorkspaceAge(workspace.createdAt);
466
+ return age ? `created ${age}` : "created date unknown";
467
+ }
468
+
469
+ export function formatWorkspaceAge(createdAt: string, nowMs = Date.now()): string | undefined {
470
+ const createdAtMs = Date.parse(createdAt);
471
+ if (!Number.isFinite(createdAtMs)) return undefined;
472
+
473
+ const elapsedSeconds = Math.max(0, Math.floor((nowMs - createdAtMs) / 1000));
474
+ if (elapsedSeconds < 60) return "just now";
475
+
476
+ const elapsedMinutes = Math.floor(elapsedSeconds / 60);
477
+ if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`;
478
+
479
+ const elapsedHours = Math.floor(elapsedMinutes / 60);
480
+ if (elapsedHours < 48) return `${elapsedHours}h ago`;
481
+
482
+ const elapsedDays = Math.floor(elapsedHours / 24);
483
+ if (elapsedDays < 30) return `${elapsedDays}d ago`;
484
+
485
+ const elapsedMonths = Math.floor(elapsedDays / 30);
486
+ if (elapsedMonths < 24) return `${elapsedMonths}mo ago`;
487
+
488
+ return `${Math.floor(elapsedMonths / 12)}y ago`;
489
+ }
package/src/init.test.ts CHANGED
@@ -42,8 +42,8 @@ describe("initProject", () => {
42
42
 
43
43
  const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
44
44
  expect(pkg.name).toBe("platform-api");
45
- expect(pkg.scripts.plan).toBe("rig run plan");
46
- expect(pkg.scripts.apply).toBe("rig run apply");
45
+ expect(pkg.scripts.plan).toBe("rig plan");
46
+ expect(pkg.scripts.apply).toBe("rig apply");
47
47
  expect(pkg.devDependencies[PROJECT_PACKAGE_NAME]).toBe(RIGKIT_CLI_VERSION);
48
48
  expect(pkg.devDependencies[FREESTYLE_PROVIDER_PACKAGE_NAME]).toBe(RIGKIT_CLI_VERSION);
49
49
  expect(pkg.devDependencies[FREESTYLE_SDK_PACKAGE_NAME]).toBe(FREESTYLE_SDK_PACKAGE_VERSION);
@@ -74,7 +74,7 @@ describe("initProject", () => {
74
74
  const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
75
75
  expect(pkg.name).toBe("existing");
76
76
  expect(pkg.scripts.test).toBe("bun test");
77
- expect(pkg.scripts.plan).toBe("rig run plan");
77
+ expect(pkg.scripts.plan).toBe("rig plan");
78
78
  });
79
79
  });
80
80
 
package/src/init.ts CHANGED
@@ -229,7 +229,7 @@ function ensureProjectPackageJson(
229
229
  }
230
230
 
231
231
  const scripts = pkg.scripts as Record<string, string>;
232
- for (const [key, value] of Object.entries({ plan: "rig run plan", apply: "rig run apply" })) {
232
+ for (const [key, value] of Object.entries({ plan: "rig plan", apply: "rig apply" })) {
233
233
  if (scripts[key] !== value) {
234
234
  scripts[key] = value;
235
235
  updated = true;
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const RIGKIT_CLI_VERSION = "0.2.5";
1
+ export const RIGKIT_CLI_VERSION = "0.2.7";
@@ -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
+ }