@runloop/rl-cli 1.2.0 → 1.3.0

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.
Files changed (55) hide show
  1. package/README.md +28 -8
  2. package/dist/commands/blueprint/list.js +97 -28
  3. package/dist/commands/blueprint/prune.js +7 -19
  4. package/dist/commands/devbox/create.js +3 -0
  5. package/dist/commands/devbox/list.js +44 -65
  6. package/dist/commands/menu.js +2 -1
  7. package/dist/commands/network-policy/create.js +27 -0
  8. package/dist/commands/network-policy/delete.js +21 -0
  9. package/dist/commands/network-policy/get.js +15 -0
  10. package/dist/commands/network-policy/list.js +494 -0
  11. package/dist/commands/object/list.js +516 -24
  12. package/dist/commands/snapshot/list.js +90 -29
  13. package/dist/components/Banner.js +109 -8
  14. package/dist/components/ConfirmationPrompt.js +45 -0
  15. package/dist/components/DevboxActionsMenu.js +42 -6
  16. package/dist/components/DevboxCard.js +1 -1
  17. package/dist/components/DevboxCreatePage.js +95 -81
  18. package/dist/components/DevboxDetailPage.js +218 -272
  19. package/dist/components/LogsViewer.js +8 -1
  20. package/dist/components/MainMenu.js +35 -4
  21. package/dist/components/NavigationTips.js +24 -0
  22. package/dist/components/NetworkPolicyCreatePage.js +264 -0
  23. package/dist/components/OperationsMenu.js +9 -1
  24. package/dist/components/ResourceActionsMenu.js +5 -1
  25. package/dist/components/ResourceDetailPage.js +204 -0
  26. package/dist/components/ResourceListView.js +19 -2
  27. package/dist/components/StatusBadge.js +2 -2
  28. package/dist/components/Table.js +6 -8
  29. package/dist/components/form/FormActionButton.js +7 -0
  30. package/dist/components/form/FormField.js +7 -0
  31. package/dist/components/form/FormListManager.js +112 -0
  32. package/dist/components/form/FormSelect.js +34 -0
  33. package/dist/components/form/FormTextInput.js +8 -0
  34. package/dist/components/form/index.js +8 -0
  35. package/dist/hooks/useViewportHeight.js +38 -20
  36. package/dist/router/Router.js +23 -1
  37. package/dist/screens/BlueprintDetailScreen.js +337 -0
  38. package/dist/screens/MenuScreen.js +6 -0
  39. package/dist/screens/NetworkPolicyCreateScreen.js +7 -0
  40. package/dist/screens/NetworkPolicyDetailScreen.js +247 -0
  41. package/dist/screens/NetworkPolicyListScreen.js +7 -0
  42. package/dist/screens/ObjectDetailScreen.js +377 -0
  43. package/dist/screens/ObjectListScreen.js +7 -0
  44. package/dist/screens/SnapshotDetailScreen.js +208 -0
  45. package/dist/services/blueprintService.js +30 -11
  46. package/dist/services/networkPolicyService.js +108 -0
  47. package/dist/services/objectService.js +101 -0
  48. package/dist/services/snapshotService.js +39 -3
  49. package/dist/store/blueprintStore.js +4 -10
  50. package/dist/store/index.js +1 -0
  51. package/dist/store/networkPolicyStore.js +83 -0
  52. package/dist/store/objectStore.js +92 -0
  53. package/dist/store/snapshotStore.js +4 -8
  54. package/dist/utils/commands.js +47 -0
  55. package/package.json +2 -2
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![CI](https://github.com/runloopai/rl-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/runloopai/rl-cli/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
6
 
7
- An interactive CLI for interacting with the [Runloop.ai](https://runloop.ai) platform. Use it as an **interactive command-line application** with rich UI components, or as a **traditional CLI** for scripting and automation.
7
+ A **TUI + CLI** for the [Runloop.ai](https://runloop.ai) platform. Use it as an **interactive TUI** (Terminal User Interface) with rich UI components, or as a **traditional CLI** for scripting and automation.
8
8
 
9
9
  📖 **[Full Documentation](https://docs.runloop.ai/docs/tools/cli)**
10
10
 
@@ -15,10 +15,10 @@ An interactive CLI for interacting with the [Runloop.ai](https://runloop.ai) pla
15
15
  ## Quick Example
16
16
 
17
17
  ```bash
18
- # Interactive mode - launches a beautiful UI menu
18
+ # TUI mode - launches an interactive terminal UI
19
19
  rli
20
20
 
21
- # Traditional CLI mode - perfect for scripts
21
+ # CLI mode - perfect for scripts and automation
22
22
  rli devbox list # Outputs JSON/text
23
23
  rli devbox create --name my-devbox
24
24
  rli devbox exec <devbox-id> echo "Hello World"
@@ -27,10 +27,11 @@ rli devbox delete <devbox-id>
27
27
 
28
28
  ## Features
29
29
 
30
+ - 🖥️ **TUI mode** — Interactive terminal UI with menus, tables, and real-time updates
31
+ - 🎯 **CLI mode** — Traditional commands with text, JSON, and YAML output for scripting
30
32
  - ⚡ Fast and responsive with pagination
31
33
  - 📦 Manage devboxes, snapshots, and blueprints
32
- - 🚀 Execute commands, ssh, view logs in devboxes
33
- - 🎯 Traditional CLI with text, json, and yaml output modes.
34
+ - 🚀 Execute commands, SSH, view logs in devboxes
34
35
  - 🤖 **Model Context Protocol (MCP) server for AI integration**
35
36
 
36
37
  ## Installation
@@ -53,13 +54,23 @@ Get your API key from [https://runloop.ai/settings](https://runloop.ai/settings)
53
54
 
54
55
  ## Usage
55
56
 
56
- ### Interactive CLI
57
+ ### TUI (Interactive Mode)
57
58
 
58
59
  ```bash
59
- rli # Run the interactive console
60
+ rli # Launch the interactive TUI
60
61
  rli --help # See help information
61
62
  ```
62
63
 
64
+ ### CLI (Scripting Mode)
65
+
66
+ All commands support `--output` (`-o`) for format control:
67
+
68
+ ```bash
69
+ rli devbox list # Default text output
70
+ rli devbox list -o json # JSON output
71
+ rli devbox list -o yaml # YAML output
72
+ ```
73
+
63
74
  ## Command Structure
64
75
 
65
76
  The CLI is organized into command buckets:
@@ -119,6 +130,15 @@ rli object upload <path> # Upload a file as an object
119
130
  rli object delete <id> # Delete an object (irreversible)
120
131
  ```
121
132
 
133
+ ### Network-policy Commands (alias: `np`)
134
+
135
+ ```bash
136
+ rli network-policy list # List network policies
137
+ rli network-policy get <id> # Get network policy details
138
+ rli network-policy create # Create a new network policy
139
+ rli network-policy delete <id> # Delete a network policy
140
+ ```
141
+
122
142
  ### Mcp Commands
123
143
 
124
144
  ```bash
@@ -160,7 +180,7 @@ rli mcp start --http --port 8080
160
180
 
161
181
  ## Theme Configuration
162
182
 
163
- The CLI supports both light and dark terminal themes and will automatically select the appropriate theme.
183
+ The TUI supports both light and dark terminal themes and will automatically select the appropriate theme.
164
184
 
165
185
  ## Development
166
186
 
@@ -9,6 +9,7 @@ import { SpinnerComponent } from "../../components/Spinner.js";
9
9
  import { ErrorMessage } from "../../components/ErrorMessage.js";
10
10
  import { SuccessMessage } from "../../components/SuccessMessage.js";
11
11
  import { Breadcrumb } from "../../components/Breadcrumb.js";
12
+ import { NavigationTips } from "../../components/NavigationTips.js";
12
13
  import { createTextColumn, Table } from "../../components/Table.js";
13
14
  import { ActionsPopup } from "../../components/ActionsPopup.js";
14
15
  import { formatTimeAgo } from "../../components/ResourceListView.js";
@@ -21,6 +22,7 @@ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
21
22
  import { useViewportHeight } from "../../hooks/useViewportHeight.js";
22
23
  import { useCursorPagination } from "../../hooks/useCursorPagination.js";
23
24
  import { useNavigation } from "../../store/navigationStore.js";
25
+ import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js";
24
26
  const DEFAULT_PAGE_SIZE = 10;
25
27
  const ListBlueprintsUI = ({ onBack, onExit, }) => {
26
28
  const { exit: inkExit } = useApp();
@@ -33,6 +35,7 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
33
35
  const [operationError, setOperationError] = React.useState(null);
34
36
  const [operationLoading, setOperationLoading] = React.useState(false);
35
37
  const [showCreateDevbox, setShowCreateDevbox] = React.useState(false);
38
+ const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
36
39
  const [selectedIndex, setSelectedIndex] = React.useState(0);
37
40
  const [showPopup, setShowPopup] = React.useState(false);
38
41
  const { navigate } = useNavigation();
@@ -44,13 +47,18 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
44
47
  });
45
48
  const PAGE_SIZE = viewportHeight;
46
49
  // All width constants
50
+ const fixedWidth = 6; // border + padding
47
51
  const statusIconWidth = 2;
48
52
  const statusTextWidth = 10;
49
53
  const idWidth = 25;
50
- const nameWidth = Math.max(15, terminalWidth >= 120 ? 30 : 25);
51
54
  const descriptionWidth = 40;
52
55
  const timeWidth = 20;
53
56
  const showDescription = terminalWidth >= 120;
57
+ // Name width uses remaining space after fixed columns
58
+ const baseWidth = fixedWidth + statusIconWidth + statusTextWidth + idWidth + timeWidth;
59
+ const optionalWidth = showDescription ? descriptionWidth : 0;
60
+ const remainingWidth = terminalWidth - baseWidth - optionalWidth;
61
+ const nameWidth = Math.min(80, Math.max(15, remainingWidth));
54
62
  // Fetch function for pagination hook
55
63
  const fetchPage = React.useCallback(async (params) => {
56
64
  const client = getClient();
@@ -88,7 +96,10 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
88
96
  pageSize: PAGE_SIZE,
89
97
  getItemId: (blueprint) => blueprint.id,
90
98
  pollInterval: 2000,
91
- pollingEnabled: !showPopup && !showCreateDevbox && !executingOperation,
99
+ pollingEnabled: !showPopup &&
100
+ !showCreateDevbox &&
101
+ !executingOperation &&
102
+ !showDeleteConfirm,
92
103
  deps: [PAGE_SIZE],
93
104
  });
94
105
  // Memoize columns array
@@ -99,10 +110,7 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
99
110
  width: statusIconWidth,
100
111
  render: (blueprint, _index, isSelected) => {
101
112
  const statusDisplay = getStatusDisplay(blueprint.status || "");
102
- const statusColor = statusDisplay.color === colors.textDim
103
- ? colors.info
104
- : statusDisplay.color;
105
- return (_jsxs(Text, { color: isSelected ? "white" : statusColor, bold: true, dimColor: false, inverse: isSelected, wrap: "truncate", children: [statusDisplay.icon, " "] }));
113
+ return (_jsxs(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, dimColor: false, inverse: isSelected, wrap: "truncate", children: [statusDisplay.icon, " "] }));
106
114
  },
107
115
  },
108
116
  {
@@ -123,13 +131,10 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
123
131
  width: statusTextWidth,
124
132
  render: (blueprint, _index, isSelected) => {
125
133
  const statusDisplay = getStatusDisplay(blueprint.status || "");
126
- const statusColor = statusDisplay.color === colors.textDim
127
- ? colors.info
128
- : statusDisplay.color;
129
134
  const safeWidth = Math.max(1, statusTextWidth);
130
135
  const truncated = statusDisplay.text.slice(0, safeWidth);
131
136
  const padded = truncated.padEnd(safeWidth, " ");
132
- return (_jsx(Text, { color: isSelected ? "white" : statusColor, bold: true, dimColor: false, inverse: isSelected, wrap: "truncate", children: padded }));
137
+ return (_jsx(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, dimColor: false, inverse: isSelected, wrap: "truncate", children: padded }));
133
138
  },
134
139
  },
135
140
  createTextColumn("name", "Name", (blueprint) => blueprint.name || "", {
@@ -156,6 +161,13 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
156
161
  // Helper function to generate operations based on selected blueprint
157
162
  const getOperationsForBlueprint = (blueprint) => {
158
163
  const operations = [];
164
+ // View Details is always first
165
+ operations.push({
166
+ key: "view_details",
167
+ label: "View Details",
168
+ color: colors.primary,
169
+ icon: figures.pointer,
170
+ });
159
171
  // View Logs is always available
160
172
  operations.push({
161
173
  key: "view_logs",
@@ -213,6 +225,14 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
213
225
  try {
214
226
  setOperationLoading(true);
215
227
  switch (operation) {
228
+ case "view_details":
229
+ // Navigate to the detail screen
230
+ setOperationLoading(false);
231
+ setExecutingOperation(null);
232
+ navigate("blueprint-detail", {
233
+ blueprintId: blueprint.id,
234
+ });
235
+ return;
216
236
  case "view_logs":
217
237
  // Navigate to the logs screen
218
238
  setOperationLoading(false);
@@ -295,16 +315,33 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
295
315
  else if (key.return) {
296
316
  setShowPopup(false);
297
317
  const operationKey = allOperations[selectedOperation].key;
298
- if (operationKey === "create_devbox") {
318
+ if (operationKey === "view_details") {
319
+ navigate("blueprint-detail", {
320
+ blueprintId: selectedBlueprintItem.id,
321
+ });
322
+ }
323
+ else if (operationKey === "create_devbox") {
299
324
  setSelectedBlueprint(selectedBlueprintItem);
300
325
  setShowCreateDevbox(true);
301
326
  }
327
+ else if (operationKey === "delete") {
328
+ // Show delete confirmation
329
+ setSelectedBlueprint(selectedBlueprintItem);
330
+ setShowDeleteConfirm(true);
331
+ }
302
332
  else {
303
333
  setSelectedBlueprint(selectedBlueprintItem);
304
334
  setExecutingOperation(operationKey);
305
335
  executeOperation(selectedBlueprintItem, operationKey);
306
336
  }
307
337
  }
338
+ else if (input === "v" && selectedBlueprintItem) {
339
+ // View details hotkey
340
+ setShowPopup(false);
341
+ navigate("blueprint-detail", {
342
+ blueprintId: selectedBlueprintItem.id,
343
+ });
344
+ }
308
345
  else if (key.escape || input === "q") {
309
346
  setShowPopup(false);
310
347
  setSelectedOperation(0);
@@ -321,10 +358,10 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
321
358
  else if (input === "d") {
322
359
  const deleteIndex = allOperations.findIndex((op) => op.key === "delete");
323
360
  if (deleteIndex >= 0) {
361
+ // Show delete confirmation
324
362
  setShowPopup(false);
325
363
  setSelectedBlueprint(selectedBlueprintItem);
326
- setExecutingOperation("delete");
327
- executeOperation(selectedBlueprintItem, "delete");
364
+ setShowDeleteConfirm(true);
328
365
  }
329
366
  }
330
367
  else if (input === "l") {
@@ -360,6 +397,12 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
360
397
  prevPage();
361
398
  setSelectedIndex(0);
362
399
  }
400
+ else if (key.return && selectedBlueprintItem) {
401
+ // Enter key navigates to detail view
402
+ navigate("blueprint-detail", {
403
+ blueprintId: selectedBlueprintItem.id,
404
+ });
405
+ }
363
406
  else if (input === "a") {
364
407
  setShowPopup(true);
365
408
  setSelectedOperation(0);
@@ -400,6 +443,21 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
400
443
  }
401
444
  }
402
445
  });
446
+ // Delete confirmation
447
+ if (showDeleteConfirm && selectedBlueprint) {
448
+ return (_jsx(ConfirmationPrompt, { title: "Delete Blueprint", message: `Are you sure you want to delete "${selectedBlueprint.name || selectedBlueprint.id}"?`, details: "This action cannot be undone.", breadcrumbItems: [
449
+ { label: "Blueprints" },
450
+ { label: selectedBlueprint.name || selectedBlueprint.id },
451
+ { label: "Delete", active: true },
452
+ ], onConfirm: () => {
453
+ setShowDeleteConfirm(false);
454
+ setExecutingOperation("delete");
455
+ executeOperation(selectedBlueprint, "delete");
456
+ }, onCancel: () => {
457
+ setShowDeleteConfirm(false);
458
+ setSelectedBlueprint(null);
459
+ } }));
460
+ }
403
461
  // Operation result display
404
462
  if (operationResult || operationError) {
405
463
  const operationLabel = operations.find((o) => o.key === executingOperation)?.label ||
@@ -410,7 +468,7 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
410
468
  label: selectedBlueprint?.name || selectedBlueprint?.id || "Blueprint",
411
469
  },
412
470
  { label: operationLabel, active: true },
413
- ] }), _jsx(Header, { title: "Operation Result" }), operationResult && _jsx(SuccessMessage, { message: operationResult }), operationError && (_jsx(ErrorMessage, { message: "Operation failed", error: operationError })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter], [q], or [esc] to continue" }) })] }));
471
+ ] }), _jsx(Header, { title: "Operation Result" }), operationResult && _jsx(SuccessMessage, { message: operationResult }), operationError && (_jsx(ErrorMessage, { message: "Operation failed", error: operationError })), _jsx(NavigationTips, { tips: [{ key: "Enter/q/esc", label: "Continue" }] })] }));
414
472
  }
415
473
  // Operation input mode
416
474
  if (executingOperation && selectedBlueprint) {
@@ -435,7 +493,10 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
435
493
  { label: "Blueprints" },
436
494
  { label: selectedBlueprint.name || selectedBlueprint.id },
437
495
  { label: operationLabel, active: true },
438
- ] }), _jsx(Header, { title: operationLabel }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children: selectedBlueprint.name || selectedBlueprint.id }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, children: [currentOp?.inputPrompt || "", " "] }) }), _jsx(Box, { marginTop: 1, children: _jsx(TextInput, { value: operationInput, onChange: setOperationInput, placeholder: currentOp?.inputPlaceholder || "" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter] to execute \u2022 [q or esc] Cancel" }) })] })] }));
496
+ ] }), _jsx(Header, { title: operationLabel }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children: selectedBlueprint.name || selectedBlueprint.id }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, children: [currentOp?.inputPrompt || "", " "] }) }), _jsx(Box, { marginTop: 1, children: _jsx(TextInput, { value: operationInput, onChange: setOperationInput, placeholder: currentOp?.inputPlaceholder || "" }) }), _jsx(NavigationTips, { marginTop: 1, paddingX: 0, tips: [
497
+ { key: "Enter", label: "Execute" },
498
+ { key: "q/esc", label: "Cancel" },
499
+ ] })] })] }));
439
500
  }
440
501
  // For operations that don't need input (like view_logs), fall through to list view
441
502
  }
@@ -458,24 +519,32 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
458
519
  if (listError) {
459
520
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Blueprints", active: true }] }), _jsx(ErrorMessage, { message: "Failed to load blueprints", error: listError })] }));
460
521
  }
461
- // Empty state
462
- if (blueprints.length === 0) {
463
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Blueprints", active: true }] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.warning, children: figures.info }), _jsx(Text, { children: " No blueprints found. Try: " }), _jsx(Text, { color: colors.primary, bold: true, children: "rli blueprint create" })] })] }));
464
- }
465
522
  // List view
466
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Blueprints", active: true }] }), !showPopup && (_jsx(Table, { data: blueprints, keyExtractor: (blueprint) => blueprint.id, selectedIndex: selectedIndex, title: `blueprints[${totalCount}]`, columns: blueprintColumns })), !showPopup && (_jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), navigating ? (_jsxs(Text, { color: colors.warning, children: [figures.pointer, " Loading page ", currentPage + 1, "..."] })) : (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] }))] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] })] })), showPopup && selectedBlueprintItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedBlueprintItem, operations: allOperations.map((op) => ({
523
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Blueprints", active: true }] }), !showPopup && (_jsx(Table, { data: blueprints, keyExtractor: (blueprint) => blueprint.id, selectedIndex: selectedIndex, title: `blueprints[${totalCount}]`, columns: blueprintColumns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No blueprints found. Try: rli blueprint create"] }) })), !showPopup && (_jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), navigating ? (_jsxs(Text, { color: colors.warning, children: [figures.pointer, " Loading page ", currentPage + 1, "..."] })) : (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] }))] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] })] })), showPopup && selectedBlueprintItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedBlueprintItem, operations: allOperations.map((op) => ({
467
524
  key: op.key,
468
525
  label: op.label,
469
526
  color: op.color,
470
527
  icon: op.icon,
471
- shortcut: op.key === "create_devbox"
472
- ? "c"
473
- : op.key === "delete"
474
- ? "d"
475
- : op.key === "view_logs"
476
- ? "l"
477
- : "",
478
- })), selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), (hasMore || hasPrev) && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [a] Actions"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [o] Browser"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Esc] Back"] })] })] }));
528
+ shortcut: op.key === "view_details"
529
+ ? "v"
530
+ : op.key === "create_devbox"
531
+ ? "c"
532
+ : op.key === "delete"
533
+ ? "d"
534
+ : op.key === "view_logs"
535
+ ? "l"
536
+ : "",
537
+ })), selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })), _jsx(NavigationTips, { showArrows: true, tips: [
538
+ {
539
+ icon: `${figures.arrowLeft}${figures.arrowRight}`,
540
+ label: "Page",
541
+ condition: hasMore || hasPrev,
542
+ },
543
+ { key: "Enter", label: "Details" },
544
+ { key: "a", label: "Actions" },
545
+ { key: "o", label: "Browser" },
546
+ { key: "Esc", label: "Back" },
547
+ ] })] }));
479
548
  };
480
549
  // Export the UI component for use in the main menu
481
550
  export { ListBlueprintsUI };
@@ -42,9 +42,9 @@ async function fetchAllBlueprintsWithName(name) {
42
42
  */
43
43
  function categorizeBlueprints(blueprints, keepCount) {
44
44
  // Filter successful builds
45
- const successful = blueprints.filter((b) => b.status === "build_complete" || b.status === "building_complete");
46
- // Filter failed builds
47
- const failed = blueprints.filter((b) => b.status !== "build_complete" && b.status !== "building_complete");
45
+ const successful = blueprints.filter((b) => b.status === "build_complete");
46
+ // Filter failed/incomplete builds
47
+ const failed = blueprints.filter((b) => b.status !== "build_complete");
48
48
  // Sort successful by create_time_ms descending (newest first)
49
49
  successful.sort((a, b) => (b.create_time_ms || 0) - (a.create_time_ms || 0));
50
50
  // Determine what to keep and delete
@@ -106,14 +106,8 @@ function displaySummary(name, result, isDryRun) {
106
106
  else {
107
107
  // Show all blueprints without summarizing
108
108
  for (const blueprint of result.toDelete) {
109
- const icon = blueprint.status === "build_complete" ||
110
- blueprint.status === "building_complete"
111
- ? "✓"
112
- : "✗";
113
- const statusLabel = blueprint.status === "build_complete" ||
114
- blueprint.status === "building_complete"
115
- ? "successful"
116
- : "failed";
109
+ const icon = blueprint.status === "build_complete" ? "✓" : "⚠";
110
+ const statusLabel = blueprint.status === "build_complete" ? "successful" : "failed";
117
111
  console.log(` ${icon} ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)} (${statusLabel})`);
118
112
  }
119
113
  }
@@ -127,14 +121,8 @@ function displayDeletedBlueprints(deleted) {
127
121
  }
128
122
  console.log("\nDeleted blueprints:");
129
123
  for (const blueprint of deleted) {
130
- const icon = blueprint.status === "build_complete" ||
131
- blueprint.status === "building_complete"
132
- ? "✓"
133
- : "✗";
134
- const statusLabel = blueprint.status === "build_complete" ||
135
- blueprint.status === "building_complete"
136
- ? "successful"
137
- : "failed";
124
+ const icon = blueprint.status === "build_complete" ? "✓" : "⚠";
125
+ const statusLabel = blueprint.status === "build_complete" ? "successful" : "failed";
138
126
  console.log(` ${icon} ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)} (${statusLabel})`);
139
127
  }
140
128
  }
@@ -74,6 +74,9 @@ export async function createDevbox(options = {}) {
74
74
  on_idle: options.idleAction,
75
75
  };
76
76
  }
77
+ if (options.networkPolicy) {
78
+ launchParameters.network_policy_id = options.networkPolicy;
79
+ }
77
80
  // Build create request
78
81
  const createRequest = {
79
82
  name: options.name || `devbox-${Date.now()}`,
@@ -8,6 +8,7 @@ import { SpinnerComponent } from "../../components/Spinner.js";
8
8
  import { ErrorMessage } from "../../components/ErrorMessage.js";
9
9
  import { getStatusDisplay } from "../../components/StatusBadge.js";
10
10
  import { Breadcrumb } from "../../components/Breadcrumb.js";
11
+ import { NavigationTips } from "../../components/NavigationTips.js";
11
12
  import { Table, createTextColumn } from "../../components/Table.js";
12
13
  import { formatTimeAgo } from "../../components/ResourceListView.js";
13
14
  import { output, outputError } from "../../utils/output.js";
@@ -104,46 +105,24 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
104
105
  const sourceWidth = 26;
105
106
  // ID is always full width (25 chars for dbx_31CYd5LLFbBxst8mqnUjO format)
106
107
  const idWidth = 26;
107
- // Responsive layout based on terminal width (simplified like blueprint list)
108
- const showCapabilities = terminalWidth >= 140;
109
- const showSource = terminalWidth >= 120;
108
+ // Responsive layout - hide less important columns on smaller screens
109
+ // Priority (most to least important): ID, Name, Status, Created, Source, Capabilities
110
+ const showCapabilities = terminalWidth >= 160;
111
+ const showSource = terminalWidth >= 135;
112
+ const showCreated = terminalWidth >= 100;
110
113
  // CRITICAL: Absolute maximum column widths to prevent Yoga crashes
111
114
  const ABSOLUTE_MAX_NAME_WIDTH = 80;
112
115
  // Name width is flexible and uses remaining space
113
- let nameWidth = 15;
114
- if (terminalWidth >= 120) {
115
- const remainingWidth = terminalWidth -
116
- fixedWidth -
117
- statusIconWidth -
118
- idWidth -
119
- statusTextWidth -
120
- timeWidth -
121
- capabilitiesWidth -
122
- sourceWidth -
123
- 12;
124
- nameWidth = Math.min(ABSOLUTE_MAX_NAME_WIDTH, Math.max(15, remainingWidth));
125
- }
126
- else if (terminalWidth >= 110) {
127
- const remainingWidth = terminalWidth -
128
- fixedWidth -
129
- statusIconWidth -
130
- idWidth -
131
- statusTextWidth -
132
- timeWidth -
133
- sourceWidth -
134
- 10;
135
- nameWidth = Math.min(ABSOLUTE_MAX_NAME_WIDTH, Math.max(12, remainingWidth));
136
- }
137
- else {
138
- const remainingWidth = terminalWidth -
139
- fixedWidth -
140
- statusIconWidth -
141
- idWidth -
142
- statusTextWidth -
143
- timeWidth -
144
- 10;
145
- nameWidth = Math.min(ABSOLUTE_MAX_NAME_WIDTH, Math.max(8, remainingWidth));
146
- }
116
+ // Only subtract widths of columns that are actually shown
117
+ const baseWidth = fixedWidth +
118
+ statusIconWidth +
119
+ idWidth +
120
+ statusTextWidth +
121
+ (showCreated ? timeWidth : 0) +
122
+ 6; // border + padding
123
+ const optionalWidth = (showSource ? sourceWidth : 0) + (showCapabilities ? capabilitiesWidth : 0);
124
+ const remainingWidth = terminalWidth - baseWidth - optionalWidth;
125
+ const nameWidth = Math.min(ABSOLUTE_MAX_NAME_WIDTH, Math.max(15, remainingWidth));
147
126
  // Build responsive column list (memoized to prevent recreating on every render)
148
127
  const tableColumns = React.useMemo(() => {
149
128
  const ABSOLUTE_MAX_NAME = 80;
@@ -156,22 +135,9 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
156
135
  width: statusIconWidth,
157
136
  render: (devbox, _index, isSelected) => {
158
137
  const statusDisplay = getStatusDisplay(devbox?.status);
159
- const statusColor = statusDisplay.color === colors.textDim
160
- ? colors.info
161
- : statusDisplay.color;
162
- return (_jsxs(Text, { color: isSelected ? "white" : statusColor, bold: true, dimColor: false, inverse: isSelected, wrap: "truncate", children: [statusDisplay.icon, " "] }));
138
+ return (_jsxs(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, dimColor: false, inverse: isSelected, wrap: "truncate", children: [statusDisplay.icon, " "] }));
163
139
  },
164
140
  },
165
- createTextColumn("name", "Name", (devbox) => {
166
- const name = String(devbox?.name || "");
167
- const safeMax = Math.min(nameWidth || 15, ABSOLUTE_MAX_NAME);
168
- return name.length > safeMax
169
- ? name.substring(0, Math.max(1, safeMax - 3)) + "..."
170
- : name;
171
- }, {
172
- width: Math.min(nameWidth || 15, ABSOLUTE_MAX_NAME),
173
- dimColor: false,
174
- }),
175
141
  createTextColumn("id", "ID", (devbox) => {
176
142
  const id = String(devbox?.id || "");
177
143
  const safeMax = Math.min(idWidth || 26, ABSOLUTE_MAX_ID);
@@ -184,6 +150,16 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
184
150
  dimColor: false,
185
151
  bold: false,
186
152
  }),
153
+ createTextColumn("name", "Name", (devbox) => {
154
+ const name = String(devbox?.name || "");
155
+ const safeMax = Math.min(nameWidth || 15, ABSOLUTE_MAX_NAME);
156
+ return name.length > safeMax
157
+ ? name.substring(0, Math.max(1, safeMax - 3)) + "..."
158
+ : name;
159
+ }, {
160
+ width: Math.min(nameWidth || 15, ABSOLUTE_MAX_NAME),
161
+ dimColor: false,
162
+ }),
187
163
  // Status text column with color matching the icon
188
164
  {
189
165
  key: "status",
@@ -191,13 +167,10 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
191
167
  width: statusTextWidth,
192
168
  render: (devbox, _index, isSelected) => {
193
169
  const statusDisplay = getStatusDisplay(devbox?.status);
194
- const statusColor = statusDisplay.color === colors.textDim
195
- ? colors.info
196
- : statusDisplay.color;
197
170
  const safeWidth = Math.max(1, statusTextWidth);
198
171
  const truncated = statusDisplay.text.slice(0, safeWidth);
199
172
  const padded = truncated.padEnd(safeWidth, " ");
200
- return (_jsx(Text, { color: isSelected ? "white" : statusColor, bold: true, dimColor: false, inverse: isSelected, wrap: "truncate", children: padded }));
173
+ return (_jsx(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, dimColor: false, inverse: isSelected, wrap: "truncate", children: padded }));
201
174
  },
202
175
  },
203
176
  createTextColumn("created", "Created", (devbox) => {
@@ -208,18 +181,11 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
208
181
  width: timeWidth,
209
182
  color: colors.textDim,
210
183
  dimColor: false,
184
+ visible: showCreated,
211
185
  }),
212
186
  ];
213
187
  if (showSource) {
214
- columns.push(createTextColumn("source", "Source", (devbox) => {
215
- if (devbox?.blueprint_id) {
216
- const bpId = String(devbox.blueprint_id);
217
- const truncated = bpId.slice(0, 16);
218
- const text = `${truncated}`;
219
- return text.length > 30 ? text.substring(0, 27) + "..." : text;
220
- }
221
- return "-";
222
- }, {
188
+ columns.push(createTextColumn("source", "Source", (devbox) => devbox?.blueprint_id || "-", {
223
189
  width: sourceWidth,
224
190
  color: colors.textDim,
225
191
  dimColor: false,
@@ -243,6 +209,7 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
243
209
  idWidth,
244
210
  statusTextWidth,
245
211
  timeWidth,
212
+ showCreated,
246
213
  showSource,
247
214
  sourceWidth,
248
215
  showCapabilities,
@@ -511,7 +478,19 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
511
478
  setSelectedIndex(0);
512
479
  } }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Enter to search, Esc to cancel]"] })] })), !searchMode && submittedSearchQuery && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.info, " Searching for: "] }), _jsx(Text, { color: colors.warning, bold: true, children: submittedSearchQuery.length > 50
513
480
  ? submittedSearchQuery.substring(0, 50) + "..."
514
- : submittedSearchQuery }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", totalCount, " results) [/ to edit, Esc to clear]"] })] })), !showPopup && (_jsx(Table, { data: devboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: "devboxes", columns: tableColumns })), !showPopup && (_jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), navigating ? (_jsxs(Text, { color: colors.warning, children: [figures.pointer, " Loading page ", currentPage + 1, "..."] })) : (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] }))] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] }), submittedSearchQuery && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.warning, children: ["Filtered: \"", submittedSearchQuery, "\""] })] }))] })), showPopup && selectedDevbox && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedDevbox, operations: operations, selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), (hasMore || hasPrev) && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Enter] Details"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [a] Actions"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [c] Create"] }), selectedDevbox && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [o] Open in Browser"] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [/] Search"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Esc] Back"] })] })] }));
481
+ : submittedSearchQuery }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", totalCount, " results) [/ to edit, Esc to clear]"] })] })), !showPopup && (_jsx(Table, { data: devboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: "devboxes", columns: tableColumns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No devboxes found. Press [c] to create one."] }) })), !showPopup && (_jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), navigating ? (_jsxs(Text, { color: colors.warning, children: [figures.pointer, " Loading page ", currentPage + 1, "..."] })) : (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] }))] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] }), submittedSearchQuery && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.warning, children: ["Filtered: \"", submittedSearchQuery, "\""] })] }))] })), showPopup && selectedDevbox && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedDevbox, operations: operations, selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })), _jsx(NavigationTips, { showArrows: true, tips: [
482
+ {
483
+ icon: `${figures.arrowLeft}${figures.arrowRight}`,
484
+ label: "Page",
485
+ condition: hasMore || hasPrev,
486
+ },
487
+ { key: "Enter", label: "Details" },
488
+ { key: "a", label: "Actions" },
489
+ { key: "c", label: "Create" },
490
+ { key: "o", label: "Open in Browser", condition: !!selectedDevbox },
491
+ { key: "/", label: "Search" },
492
+ { key: "Esc", label: "Back" },
493
+ ] })] }));
515
494
  };
516
495
  // Export the UI component for use in the main menu
517
496
  export { ListDevboxesUI };
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
- import { enterAlternateScreenBuffer, exitAlternateScreenBuffer, } from "../utils/screen.js";
3
+ import { enterAlternateScreenBuffer, exitAlternateScreenBuffer, clearScreen, } from "../utils/screen.js";
4
4
  import { processUtils } from "../utils/processUtils.js";
5
5
  import { Router } from "../router/Router.js";
6
6
  import { NavigationProvider } from "../store/navigationStore.js";
@@ -14,6 +14,7 @@ function App({ initialScreen = "menu", focusDevboxId, }) {
14
14
  }
15
15
  export async function runMainMenu(initialScreen = "menu", focusDevboxId) {
16
16
  enterAlternateScreenBuffer();
17
+ clearScreen(); // Ensure cursor is at top-left before Ink renders
17
18
  try {
18
19
  const { waitUntilExit } = render(_jsx(App, { initialScreen: initialScreen, focusDevboxId: focusDevboxId }, `app-${initialScreen}-${focusDevboxId}`), {
19
20
  patchConsole: false,
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Create network policy command
3
+ */
4
+ import { getClient } from "../../utils/client.js";
5
+ import { output, outputError } from "../../utils/output.js";
6
+ export async function createNetworkPolicy(options) {
7
+ try {
8
+ const client = getClient();
9
+ const policy = await client.networkPolicies.create({
10
+ name: options.name,
11
+ description: options.description,
12
+ allow_all: options.allowAll ?? false,
13
+ allow_devbox_to_devbox: options.allowDevboxToDevbox ?? false,
14
+ allowed_hostnames: options.allowedHostnames ?? [],
15
+ });
16
+ // Default: just output the ID for easy scripting
17
+ if (!options.output || options.output === "text") {
18
+ console.log(policy.id);
19
+ }
20
+ else {
21
+ output(policy, { format: options.output, defaultFormat: "json" });
22
+ }
23
+ }
24
+ catch (error) {
25
+ outputError("Failed to create network policy", error);
26
+ }
27
+ }