@runloop/rl-cli 0.3.0 → 0.5.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.
package/README.md CHANGED
@@ -6,7 +6,6 @@ A beautiful, interactive CLI for managing Runloop devboxes built with Ink and Ty
6
6
 
7
7
  - 🎨 Beautiful terminal UI with colors and gradients
8
8
  - ⚡ Fast and responsive with pagination
9
- - 🔐 Secure API key management
10
9
  - 📦 Manage devboxes, snapshots, and blueprints
11
10
  - 🚀 Execute commands in devboxes
12
11
  - 📤 Upload files to devboxes
@@ -33,90 +32,31 @@ npm link
33
32
 
34
33
  ## Setup
35
34
 
36
- Configure your API key using either method:
37
-
38
- ### Option 1: Environment Variable (Recommended for CI/CD)
35
+ Configure your API key:
39
36
 
40
37
  ```bash
41
38
  export RUNLOOP_API_KEY=your_api_key_here
42
39
  ```
43
40
 
44
- ### Option 2: Interactive Setup
45
-
46
- ```bash
47
- rli auth
48
- ```
49
-
50
41
  Get your API key from [https://runloop.ai/settings](https://runloop.ai/settings)
51
42
 
52
43
  ## Usage
53
44
 
54
- ### Authentication
55
-
56
- ```bash
57
- # Interactive setup (stores API key locally)
58
- rli auth
59
-
60
- # Or use environment variable
61
- export RUNLOOP_API_KEY=your_api_key_here
62
- ```
63
-
64
- The CLI will automatically use `RUNLOOP_API_KEY` if set, otherwise it will use the stored configuration.
65
-
66
45
  ### Theme Configuration
67
46
 
68
- The CLI supports both light and dark terminal themes with automatic detection:
47
+ The CLI supports both light and dark terminal themes. Set the theme via environment variable:
69
48
 
70
49
  ```bash
71
- # Interactive theme selector with live preview
72
- rli config theme
73
-
74
- # Or set theme directly
75
- rli config theme auto # Auto-detect terminal background (default)
76
- rli config theme light # Force light mode (dark text on light background)
77
- rli config theme dark # Force dark mode (light text on dark background)
78
-
79
- # Or use environment variable
80
- export RUNLOOP_THEME=light
50
+ export RUNLOOP_THEME=light # Force light mode (dark text on light background)
51
+ export RUNLOOP_THEME=dark # Force dark mode (light text on dark background)
81
52
  ```
82
53
 
83
- **Interactive Mode:**
84
-
85
- - When you run `rli config theme` without arguments, you get an interactive selector
86
- - Use arrow keys to navigate between auto/light/dark options
87
- - See live preview of colors as you navigate
88
- - Press Enter to save, Esc to cancel
89
-
90
54
  **How it works:**
91
55
 
92
- - **auto** (default): Uses dark mode by default (theme detection is disabled to prevent terminal flashing)
56
+ - **auto** (default): Detects correct theme by default
93
57
  - **light**: Optimized for light-themed terminals (uses dark text colors)
94
58
  - **dark**: Optimized for dark-themed terminals (uses light text colors)
95
59
 
96
- **Terminal Compatibility:**
97
-
98
- - Works with all modern terminals (iTerm2, Terminal.app, VS Code integrated terminal, tmux)
99
- - The CLI defaults to dark mode for the best experience
100
- - You can manually set light or dark mode based on your terminal theme
101
-
102
- **Note on Auto-Detection:**
103
-
104
- - Auto theme detection is **disabled by default** to prevent screen flashing
105
- - To enable it, set `RUNLOOP_ENABLE_THEME_DETECTION=1`
106
- - If you use a light terminal, we recommend setting: `rli config theme light`
107
- - The result is cached, so subsequent runs are instant (no flashing!)
108
- - If you change your terminal theme, you can re-detect by running:
109
-
110
- ```bash
111
- rli config theme auto
112
- ```
113
- - To manually set your theme without detection:
114
- ```bash
115
- export RUNLOOP_THEME=dark # or light
116
- # Or disable auto-detection entirely:
117
- export RUNLOOP_DISABLE_THEME_DETECTION=1
118
- ```
119
-
120
60
  ### Devbox Commands
121
61
 
122
62
  ```bash
package/dist/cli.js CHANGED
@@ -5,7 +5,6 @@ import { listDevboxes } from "./commands/devbox/list.js";
5
5
  import { deleteDevbox } from "./commands/devbox/delete.js";
6
6
  import { execCommand } from "./commands/devbox/exec.js";
7
7
  import { uploadFile } from "./commands/devbox/upload.js";
8
- import { getConfig } from "./utils/config.js";
9
8
  import { VERSION } from "./version.js";
10
9
  import { exitAlternateScreenBuffer } from "./utils/screen.js";
11
10
  import { processUtils } from "./utils/processUtils.js";
@@ -21,37 +20,6 @@ program
21
20
  .name("rli")
22
21
  .description("Beautiful CLI for Runloop devbox management")
23
22
  .version(VERSION);
24
- program
25
- .command("auth")
26
- .description("Configure API authentication")
27
- .action(async () => {
28
- const { default: auth } = await import("./commands/auth.js");
29
- auth();
30
- });
31
- // Config commands
32
- const config = program
33
- .command("config")
34
- .description("Configure CLI settings")
35
- .action(async () => {
36
- const { showThemeConfig } = await import("./commands/config.js");
37
- showThemeConfig();
38
- });
39
- config
40
- .command("theme [mode]")
41
- .description("Get or set theme mode (auto|light|dark)")
42
- .action(async (mode) => {
43
- const { showThemeConfig, setThemeConfig } = await import("./commands/config.js");
44
- if (!mode) {
45
- showThemeConfig();
46
- }
47
- else if (mode === "auto" || mode === "light" || mode === "dark") {
48
- setThemeConfig(mode);
49
- }
50
- else {
51
- console.error(`\n❌ Invalid theme mode: ${mode}\nValid options: auto, light, dark\n`);
52
- processUtils.exit(1);
53
- }
54
- });
55
23
  // Devbox commands
56
24
  const devbox = program
57
25
  .command("devbox")
@@ -446,20 +414,26 @@ program
446
414
  // Initialize theme system early (before any UI rendering)
447
415
  const { initializeTheme } = await import("./utils/theme.js");
448
416
  await initializeTheme();
449
- // Check if API key is configured (except for auth, config, and mcp commands)
417
+ // Check if API key is configured (except for mcp commands)
450
418
  const args = process.argv.slice(2);
451
- if (args[0] !== "auth" &&
452
- args[0] !== "config" &&
453
- args[0] !== "mcp" &&
454
- args[0] !== "mcp-server" &&
455
- args[0] !== "--help" &&
456
- args[0] !== "-h" &&
457
- args.length > 0) {
458
- const config = getConfig();
459
- if (!config.apiKey) {
460
- console.error("\n❌ API key not configured. Run: rli auth\n");
461
- processUtils.exit(1);
462
- }
419
+ if (!process.env.RUNLOOP_API_KEY) {
420
+ console.error(`
421
+ API key not configured.
422
+
423
+ To get started:
424
+ 1. Go to https://platform.runloop.ai/settings and create an API key
425
+ 2. Set the environment variable:
426
+
427
+ export RUNLOOP_API_KEY=your_api_key_here
428
+
429
+ To make it permanent, add this line to your shell config:
430
+ • For zsh: echo 'export RUNLOOP_API_KEY=your_api_key_here' >> ~/.zshrc
431
+ • For bash: echo 'export RUNLOOP_API_KEY=your_api_key_here' >> ~/.bashrc
432
+
433
+ Then restart your terminal or run: source ~/.zshrc (or ~/.bashrc)
434
+ `);
435
+ processUtils.exit(1);
436
+ return; // Ensure execution stops
463
437
  }
464
438
  // If no command provided, show main menu
465
439
  if (args.length === 0) {
@@ -444,9 +444,10 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
444
444
  return (_jsx(DevboxCreatePage, { onBack: () => {
445
445
  setShowCreateDevbox(false);
446
446
  setSelectedBlueprint(null);
447
- }, onCreate: () => {
447
+ }, onCreate: (devbox) => {
448
448
  setShowCreateDevbox(false);
449
449
  setSelectedBlueprint(null);
450
+ navigate("devbox-detail", { devboxId: devbox.id });
450
451
  }, initialBlueprintId: selectedBlueprint.id }));
451
452
  }
452
453
  // Loading state
@@ -101,7 +101,7 @@ export async function installMcpConfig() {
101
101
  console.log("\n📝 Next steps:");
102
102
  console.log("1. Restart Claude Desktop completely (quit and reopen)");
103
103
  console.log('2. Ask Claude: "List my devboxes" or "What Runloop tools do you have?"');
104
- console.log('\n💡 Tip: Make sure you\'ve run "rli auth" to configure your API key first!');
104
+ console.log("\n💡 Tip: Make sure RUNLOOP_API_KEY environment variable is set!");
105
105
  }
106
106
  catch (error) {
107
107
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -16,9 +16,12 @@ import { colors } from "../../utils/theme.js";
16
16
  import { useViewportHeight } from "../../hooks/useViewportHeight.js";
17
17
  import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
18
18
  import { useCursorPagination } from "../../hooks/useCursorPagination.js";
19
+ import { DevboxCreatePage } from "../../components/DevboxCreatePage.js";
20
+ import { useNavigation } from "../../store/navigationStore.js";
19
21
  const DEFAULT_PAGE_SIZE = 10;
20
22
  const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
21
23
  const { exit: inkExit } = useApp();
24
+ const { navigate } = useNavigation();
22
25
  const [selectedIndex, setSelectedIndex] = React.useState(0);
23
26
  const [showPopup, setShowPopup] = React.useState(false);
24
27
  const [selectedOperation, setSelectedOperation] = React.useState(0);
@@ -28,6 +31,7 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
28
31
  const [operationResult, setOperationResult] = React.useState(null);
29
32
  const [operationError, setOperationError] = React.useState(null);
30
33
  const [operationLoading, setOperationLoading] = React.useState(false);
34
+ const [showCreateDevbox, setShowCreateDevbox] = React.useState(false);
31
35
  // Calculate overhead for viewport height
32
36
  const overhead = 13;
33
37
  const { viewportHeight, terminalWidth } = useViewportHeight({
@@ -82,11 +86,17 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
82
86
  pageSize: PAGE_SIZE,
83
87
  getItemId: (snapshot) => snapshot.id,
84
88
  pollInterval: 2000,
85
- pollingEnabled: !showPopup && !executingOperation,
89
+ pollingEnabled: !showPopup && !executingOperation && !showCreateDevbox,
86
90
  deps: [devboxId, PAGE_SIZE],
87
91
  });
88
92
  // Operations for snapshots
89
93
  const operations = React.useMemo(() => [
94
+ {
95
+ key: "create_devbox",
96
+ label: "Create Devbox from Snapshot",
97
+ color: colors.success,
98
+ icon: figures.play,
99
+ },
90
100
  {
91
101
  key: "delete",
92
102
  label: "Delete Snapshot",
@@ -164,6 +174,10 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
164
174
  }
165
175
  return;
166
176
  }
177
+ // Handle create devbox view
178
+ if (showCreateDevbox) {
179
+ return;
180
+ }
167
181
  // Handle popup navigation
168
182
  if (showPopup) {
169
183
  if (key.upArrow && selectedOperation > 0) {
@@ -175,15 +189,27 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
175
189
  else if (key.return) {
176
190
  setShowPopup(false);
177
191
  const operationKey = operations[selectedOperation].key;
178
- setSelectedSnapshot(selectedSnapshotItem);
179
- setExecutingOperation(operationKey);
180
- // Execute immediately after state update
181
- setTimeout(() => executeOperation(), 0);
192
+ if (operationKey === "create_devbox") {
193
+ setSelectedSnapshot(selectedSnapshotItem);
194
+ setShowCreateDevbox(true);
195
+ }
196
+ else {
197
+ setSelectedSnapshot(selectedSnapshotItem);
198
+ setExecutingOperation(operationKey);
199
+ // Execute immediately after state update
200
+ setTimeout(() => executeOperation(), 0);
201
+ }
182
202
  }
183
203
  else if (key.escape || input === "q") {
184
204
  setShowPopup(false);
185
205
  setSelectedOperation(0);
186
206
  }
207
+ else if (input === "c") {
208
+ // Create devbox hotkey
209
+ setShowPopup(false);
210
+ setSelectedSnapshot(selectedSnapshotItem);
211
+ setShowCreateDevbox(true);
212
+ }
187
213
  else if (input === "d") {
188
214
  // Delete hotkey
189
215
  setShowPopup(false);
@@ -256,6 +282,17 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
256
282
  { label: operationLabel, active: true },
257
283
  ] }), _jsx(Header, { title: "Executing Operation" }), _jsx(SpinnerComponent, { message: messages[executingOperation] || "Please wait..." })] }));
258
284
  }
285
+ // Create devbox screen
286
+ if (showCreateDevbox && selectedSnapshot) {
287
+ return (_jsx(DevboxCreatePage, { onBack: () => {
288
+ setShowCreateDevbox(false);
289
+ setSelectedSnapshot(null);
290
+ }, onCreate: (devbox) => {
291
+ setShowCreateDevbox(false);
292
+ setSelectedSnapshot(null);
293
+ navigate("devbox-detail", { devboxId: devbox.id });
294
+ }, initialSnapshotId: selectedSnapshot.id }));
295
+ }
259
296
  // Loading state
260
297
  if (loading && snapshots.length === 0) {
261
298
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
@@ -292,7 +329,11 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
292
329
  label: op.label,
293
330
  color: op.color,
294
331
  icon: op.icon,
295
- shortcut: op.key === "delete" ? "d" : "",
332
+ shortcut: op.key === "create_devbox"
333
+ ? "c"
334
+ : op.key === "delete"
335
+ ? "d"
336
+ : "",
296
337
  })), 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 [Esc] Back"] })] })] }));
297
338
  };
298
339
  // Export the UI component for use in the main menu
@@ -6,12 +6,12 @@ import { UpdateNotification } from "./UpdateNotification.js";
6
6
  export const Breadcrumb = ({ items, showVersionCheck = false, }) => {
7
7
  const env = process.env.RUNLOOP_ENV?.toLowerCase();
8
8
  const isDevEnvironment = env === "dev";
9
- return (_jsxs(Box, { marginBottom: 1, paddingX: 0, paddingY: 0, children: [_jsxs(Box, { borderStyle: "round", borderColor: colors.primary, paddingX: 2, paddingY: 0, children: [_jsx(Text, { color: colors.primary, bold: true, children: "rl" }), isDevEnvironment && (_jsxs(Text, { color: colors.error, bold: true, children: [" ", "(dev)"] })), _jsx(Text, { color: colors.textDim, children: " \u203A " }), items.map((item, index) => {
10
- // Limit label length to prevent Yoga layout engine errors
11
- const MAX_LABEL_LENGTH = 80;
12
- const truncatedLabel = item.label.length > MAX_LABEL_LENGTH
13
- ? item.label.substring(0, MAX_LABEL_LENGTH) + "..."
14
- : item.label;
15
- return (_jsxs(React.Fragment, { children: [_jsx(Text, { color: item.active ? colors.primary : colors.textDim, children: truncatedLabel }), index < items.length - 1 && (_jsx(Text, { color: colors.textDim, children: " \u203A " }))] }, index));
16
- })] }), showVersionCheck && (_jsx(Box, { paddingX: 2, marginTop: 0, children: _jsx(UpdateNotification, {}) }))] }));
9
+ return (_jsxs(Box, { justifyContent: "space-between", marginBottom: 1, paddingX: 0, paddingY: 0, children: [_jsx(Box, { flexShrink: 0, children: _jsxs(Box, { borderStyle: "round", borderColor: colors.primary, paddingX: 2, paddingY: 0, children: [_jsx(Text, { color: colors.primary, bold: true, children: "rl" }), isDevEnvironment && (_jsxs(Text, { color: colors.error, bold: true, children: [" ", "(dev)"] })), _jsx(Text, { color: colors.textDim, children: " \u203A " }), items.map((item, index) => {
10
+ // Limit label length to prevent Yoga layout engine errors
11
+ const MAX_LABEL_LENGTH = 80;
12
+ const truncatedLabel = item.label.length > MAX_LABEL_LENGTH
13
+ ? item.label.substring(0, MAX_LABEL_LENGTH) + "..."
14
+ : item.label;
15
+ return (_jsxs(React.Fragment, { children: [_jsx(Text, { color: item.active ? colors.primary : colors.textDim, children: truncatedLabel }), index < items.length - 1 && (_jsx(Text, { color: colors.textDim, children: " \u203A " }))] }, index));
16
+ })] }) }), showVersionCheck && _jsx(UpdateNotification, {})] }));
17
17
  };
@@ -11,7 +11,7 @@ import { Breadcrumb } from "./Breadcrumb.js";
11
11
  import { MetadataDisplay } from "./MetadataDisplay.js";
12
12
  import { colors } from "../utils/theme.js";
13
13
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
14
- export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
14
+ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, initialSnapshotId, }) => {
15
15
  const [currentField, setCurrentField] = React.useState("create");
16
16
  const [formData, setFormData] = React.useState({
17
17
  name: "",
@@ -23,7 +23,7 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
23
23
  keep_alive: "3600",
24
24
  metadata: {},
25
25
  blueprint_id: initialBlueprintId || "",
26
- snapshot_id: "",
26
+ snapshot_id: initialSnapshotId || "",
27
27
  });
28
28
  const [metadataKey, setMetadataKey] = React.useState("");
29
29
  const [metadataValue, setMetadataValue] = React.useState("");
@@ -6,8 +6,10 @@ import { Banner } from "./Banner.js";
6
6
  import { Breadcrumb } from "./Breadcrumb.js";
7
7
  import { VERSION } from "../version.js";
8
8
  import { colors } from "../utils/theme.js";
9
+ import { execCommand } from "../utils/exec.js";
9
10
  import { useViewportHeight } from "../hooks/useViewportHeight.js";
10
11
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
12
+ import { useUpdateCheck } from "../hooks/useUpdateCheck.js";
11
13
  const menuItems = [
12
14
  {
13
15
  key: "devboxes",
@@ -36,6 +38,8 @@ export const MainMenu = ({ onSelect }) => {
36
38
  const [selectedIndex, setSelectedIndex] = React.useState(0);
37
39
  // Use centralized viewport hook for consistent layout
38
40
  const { terminalHeight } = useViewportHeight({ overhead: 0 });
41
+ // Check for updates
42
+ const { updateAvailable } = useUpdateCheck();
39
43
  // Handle Ctrl+C to exit
40
44
  useExitOnCtrlC();
41
45
  useInput((input, key) => {
@@ -60,6 +64,13 @@ export const MainMenu = ({ onSelect }) => {
60
64
  else if (input === "s" || input === "3") {
61
65
  onSelect("snapshots");
62
66
  }
67
+ else if (input === "u" && updateAvailable) {
68
+ // Release terminal and exec into update command (never returns)
69
+ execCommand("sh", [
70
+ "-c",
71
+ "npm install -g @runloop/rl-cli@latest && exec rli",
72
+ ]);
73
+ }
63
74
  });
64
75
  // Use compact layout if terminal height is less than 20 lines (memoized)
65
76
  const useCompactLayout = terminalHeight < 20;
@@ -67,10 +78,10 @@ export const MainMenu = ({ onSelect }) => {
67
78
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingX: 2, marginBottom: 1, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Cloud development environments \u2022 v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
68
79
  const isSelected = index === selectedIndex;
69
80
  return (_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " }), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "- ", item.description] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
70
- }) }), _jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit"] }) })] }));
81
+ }) }), _jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit", updateAvailable && " • [u] Update"] }) })] }));
71
82
  }
72
83
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsx(Banner, {}), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, flexGrow: 1, children: [_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Text, { color: colors.text, bold: true, children: "Select a resource:" }) }), menuItems.map((item, index) => {
73
84
  const isSelected = index === selectedIndex;
74
85
  return (_jsxs(Box, { paddingX: 2, paddingY: 0, borderStyle: "single", borderColor: isSelected ? item.color : colors.border, marginTop: index === 0 ? 1 : 0, flexShrink: 0, children: [isSelected && (_jsxs(_Fragment, { children: [_jsx(Text, { color: item.color, bold: true, children: figures.pointer }), _jsx(Text, { children: " " })] })), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsx(Text, { color: colors.textDim, children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: item.description }), _jsx(Text, { children: " " }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["[", index + 1, "]"] })] }, item.key));
75
- })] }), _jsx(Box, { paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit"] }) }) })] }));
86
+ })] }), _jsx(Box, { paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit", updateAvailable && " • [u] Update"] }) }) })] }));
76
87
  };
@@ -87,15 +87,15 @@ export function ResourceListView({ config }) {
87
87
  React.useEffect(() => {
88
88
  fetchData(true);
89
89
  }, [fetchData]);
90
- // Auto-refresh
90
+ // Auto-refresh - STOP refreshing when there's an error to avoid flickering
91
91
  React.useEffect(() => {
92
- if (config.autoRefresh?.enabled) {
92
+ if (config.autoRefresh?.enabled && !error) {
93
93
  const interval = setInterval(() => {
94
94
  fetchData(false);
95
95
  }, config.autoRefresh.interval || 3000);
96
96
  return () => clearInterval(interval);
97
97
  }
98
- }, [config.autoRefresh, fetchData]);
98
+ }, [config.autoRefresh, fetchData, error]);
99
99
  // Removed refresh icon animation to prevent constant re-renders and flashing
100
100
  // Filter resources based on search query
101
101
  const filteredResources = React.useMemo(() => {
@@ -1,56 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React from "react";
3
2
  import { Box, Text } from "ink";
4
3
  import { colors } from "../utils/theme.js";
5
- import { VERSION } from "../version.js";
4
+ import { useUpdateCheck } from "../hooks/useUpdateCheck.js";
6
5
  /**
7
6
  * Version check component that checks npm for updates and displays a notification
8
7
  * Restored from git history and enhanced with better visual styling
9
8
  */
10
9
  export const UpdateNotification = () => {
11
- const [updateAvailable, setUpdateAvailable] = React.useState(null);
12
- const [isChecking, setIsChecking] = React.useState(true);
13
- React.useEffect(() => {
14
- const checkForUpdates = async () => {
15
- try {
16
- const currentVersion = VERSION;
17
- const response = await fetch("https://registry.npmjs.org/@runloop/rl-cli/latest");
18
- if (response.ok) {
19
- const data = (await response.json());
20
- const latestVersion = data.version;
21
- if (latestVersion && latestVersion !== currentVersion) {
22
- // Check if current version is older than latest
23
- const compareVersions = (version1, version2) => {
24
- const v1parts = version1.split(".").map(Number);
25
- const v2parts = version2.split(".").map(Number);
26
- for (let i = 0; i < Math.max(v1parts.length, v2parts.length); i++) {
27
- const v1part = v1parts[i] || 0;
28
- const v2part = v2parts[i] || 0;
29
- if (v1part > v2part)
30
- return 1;
31
- if (v1part < v2part)
32
- return -1;
33
- }
34
- return 0;
35
- };
36
- const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0;
37
- if (isUpdateAvailable) {
38
- setUpdateAvailable(latestVersion);
39
- }
40
- }
41
- }
42
- }
43
- catch {
44
- // Silently fail
45
- }
46
- finally {
47
- setIsChecking(false);
48
- }
49
- };
50
- checkForUpdates();
51
- }, []);
10
+ const { isChecking, updateAvailable, currentVersion } = useUpdateCheck();
52
11
  if (isChecking || !updateAvailable) {
53
12
  return null;
54
13
  }
55
- return (_jsxs(Box, { borderStyle: "round", borderColor: colors.warning, paddingX: 1, paddingY: 0, marginTop: 0, children: [_jsx(Text, { color: colors.warning, bold: true, children: "\u2728" }), _jsxs(Text, { color: colors.text, bold: true, children: [" ", "Update available:", " "] }), _jsx(Text, { color: colors.warning, bold: true, children: VERSION }), _jsxs(Text, { color: colors.primary, bold: true, children: [" ", "\u2192", " "] }), _jsx(Text, { color: colors.success, bold: true, children: updateAvailable }), _jsxs(Text, { color: colors.text, bold: true, children: [" ", "\u2022 Run:", " "] }), _jsx(Text, { color: colors.primary, bold: true, children: "npm install -g @runloop/rl-cli@latest" })] }));
14
+ return (_jsx(Box, { children: _jsxs(Box, { borderStyle: "arrow", borderColor: colors.warning, paddingX: 1, paddingY: 0, children: [_jsx(Text, { color: colors.warning, children: "\u2728" }), _jsx(Text, { color: colors.text, children: " Update available: " }), _jsx(Text, { color: colors.warning, children: currentVersion }), _jsx(Text, { color: colors.primary, children: " \u2192 " }), _jsx(Text, { color: colors.success, children: updateAvailable }), _jsx(Text, { color: colors.textDim, children: " \u2022 Press " }), _jsx(Text, { color: colors.primary, bold: true, children: "[u]" }), _jsx(Text, { color: colors.textDim, children: " to run: " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: "npm i -g @runloop/rl-cli@latest" })] }) }));
56
15
  };
@@ -119,9 +119,9 @@ export function useCursorPagination(config) {
119
119
  // Fetch page 0
120
120
  fetchPageData(0, true);
121
121
  }, [depsKey, fetchPageData]);
122
- // Polling effect
122
+ // Polling effect - STOP polling when there's an error to avoid flickering
123
123
  React.useEffect(() => {
124
- if (!pollInterval || pollInterval <= 0 || !pollingEnabled) {
124
+ if (!pollInterval || pollInterval <= 0 || !pollingEnabled || error) {
125
125
  return;
126
126
  }
127
127
  const timer = setInterval(() => {
@@ -130,7 +130,7 @@ export function useCursorPagination(config) {
130
130
  }
131
131
  }, pollInterval);
132
132
  return () => clearInterval(timer);
133
- }, [pollInterval, pollingEnabled, currentPage, fetchPageData]);
133
+ }, [pollInterval, pollingEnabled, currentPage, fetchPageData, error]);
134
134
  // Navigation functions
135
135
  const nextPage = React.useCallback(() => {
136
136
  if (!loading && !navigating && hasMore) {
@@ -0,0 +1,54 @@
1
+ import React from "react";
2
+ import { VERSION } from "../version.js";
3
+ /**
4
+ * Hook to check for CLI updates from npm registry
5
+ * Returns the latest version if an update is available
6
+ */
7
+ export function useUpdateCheck() {
8
+ const [updateAvailable, setUpdateAvailable] = React.useState(null);
9
+ const [isChecking, setIsChecking] = React.useState(true);
10
+ React.useEffect(() => {
11
+ const checkForUpdates = async () => {
12
+ try {
13
+ const currentVersion = VERSION;
14
+ const response = await fetch("https://registry.npmjs.org/@runloop/rl-cli/latest");
15
+ if (response.ok) {
16
+ const data = (await response.json());
17
+ const latestVersion = data.version;
18
+ if (latestVersion && latestVersion !== currentVersion) {
19
+ // Check if current version is older than latest
20
+ const compareVersions = (version1, version2) => {
21
+ const v1parts = version1.split(".").map(Number);
22
+ const v2parts = version2.split(".").map(Number);
23
+ for (let i = 0; i < Math.max(v1parts.length, v2parts.length); i++) {
24
+ const v1part = v1parts[i] || 0;
25
+ const v2part = v2parts[i] || 0;
26
+ if (v1part > v2part)
27
+ return 1;
28
+ if (v1part < v2part)
29
+ return -1;
30
+ }
31
+ return 0;
32
+ };
33
+ const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0;
34
+ if (isUpdateAvailable) {
35
+ setUpdateAvailable(latestVersion);
36
+ }
37
+ }
38
+ }
39
+ }
40
+ catch {
41
+ // Silently fail
42
+ }
43
+ finally {
44
+ setIsChecking(false);
45
+ }
46
+ };
47
+ checkForUpdates();
48
+ }, []);
49
+ return {
50
+ isChecking,
51
+ updateAvailable,
52
+ currentVersion: VERSION,
53
+ };
54
+ }
@@ -45,7 +45,7 @@ function getBaseUrl() {
45
45
  function getClient() {
46
46
  const config = getConfig();
47
47
  if (!config.apiKey) {
48
- throw new Error("API key not configured. Please set RUNLOOP_API_KEY environment variable or run: rli auth");
48
+ throw new Error("API key not configured. Please set RUNLOOP_API_KEY environment variable.");
49
49
  }
50
50
  const baseURL = getBaseUrl();
51
51
  return new Runloop({
@@ -54,7 +54,7 @@ function getClient() {
54
54
  timeout: 10000, // 10 seconds instead of default 30 seconds
55
55
  maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors)
56
56
  defaultHeaders: {
57
- "User-Agent": `Runloop/JS ${VERSION} - CLI MCP`,
57
+ "User-Agent": `Runloop/JS - CLI MCP ${VERSION}`,
58
58
  },
59
59
  });
60
60
  }
@@ -19,14 +19,14 @@ function getBaseUrl() {
19
19
  export function getClient() {
20
20
  const config = getConfig();
21
21
  if (!config.apiKey) {
22
- throw new Error("API key not configured. Run: rli auth");
22
+ throw new Error("API key not configured. Set RUNLOOP_API_KEY environment variable.");
23
23
  }
24
24
  const baseURL = getBaseUrl();
25
25
  return new Runloop({
26
26
  bearerToken: config.apiKey,
27
27
  baseURL,
28
28
  defaultHeaders: {
29
- "User-Agent": `Runloop/JS ${VERSION} - CLI`,
29
+ "User-Agent": `Runloop/JS - CLI ${VERSION}`,
30
30
  },
31
31
  });
32
32
  }
@@ -4,6 +4,7 @@ import { join } from "path";
4
4
  import { existsSync, statSync, mkdirSync, writeFileSync } from "fs";
5
5
  const config = new Conf({
6
6
  projectName: "runloop-cli",
7
+ cwd: join(homedir(), ".runloop"),
7
8
  });
8
9
  export function getConfig() {
9
10
  // Check environment variable first, then fall back to stored config
@@ -29,7 +30,7 @@ export function sshUrl() {
29
30
  : "ssh.runloop.ai:443";
30
31
  }
31
32
  export function getCacheDir() {
32
- return join(homedir(), ".cache", "rl-cli");
33
+ return join(homedir(), ".runloop", "rl-cli");
33
34
  }
34
35
  export function shouldCheckForUpdates() {
35
36
  const cacheDir = getCacheDir();
@@ -0,0 +1,22 @@
1
+ import { spawnSync } from "child_process";
2
+ import { showCursor } from "./screen.js";
3
+ import { processUtils } from "./processUtils.js";
4
+ /**
5
+ * Release terminal from Ink and exec into a new command (one-way, no return)
6
+ * This function never returns - it runs the command synchronously and exits.
7
+ */
8
+ export function execCommand(command, args) {
9
+ // Release terminal from Ink's control
10
+ process.stdin.pause();
11
+ if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) {
12
+ processUtils.stdin.setRawMode(false);
13
+ }
14
+ if (processUtils.stdout.isTTY) {
15
+ processUtils.stdout.write("\x1b[0m"); // SGR reset
16
+ }
17
+ showCursor();
18
+ // Run the command synchronously - this blocks until complete
19
+ const result = spawnSync(command, args, { stdio: "inherit" });
20
+ // Exit with the command's exit code
21
+ process.exit(result.status ?? 0);
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runloop/rl-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Beautiful CLI for the Runloop platform",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,7 +56,6 @@
56
56
  "dependencies": {
57
57
  "@modelcontextprotocol/sdk": "^1.19.1",
58
58
  "@runloop/api-client": "^1.0.0",
59
- "@runloop/rl-cli": "^0.1.2",
60
59
  "@types/express": "^5.0.3",
61
60
  "chalk": "^5.3.0",
62
61
  "commander": "^14.0.1",