@runloop/rl-cli 1.4.1 → 1.6.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
@@ -140,6 +140,16 @@ rli network-policy create # Create a new network policy
140
140
  rli network-policy delete <id> # Delete a network policy
141
141
  ```
142
142
 
143
+ ### Secret Commands (alias: `s`)
144
+
145
+ ```bash
146
+ rli secret create <name> # Create a new secret. Value can be pip...
147
+ rli secret list # List all secrets
148
+ rli secret get <name> # Get secret metadata by name
149
+ rli secret update <name> # Update a secret value (value from std...
150
+ rli secret delete <name> # Delete a secret
151
+ ```
152
+
143
153
  ### Mcp Commands
144
154
 
145
155
  ```bash
@@ -13,6 +13,7 @@ import { NavigationTips } from "../../components/NavigationTips.js";
13
13
  import { createTextColumn, Table } from "../../components/Table.js";
14
14
  import { ActionsPopup } from "../../components/ActionsPopup.js";
15
15
  import { formatTimeAgo } from "../../components/ResourceListView.js";
16
+ import { SearchBar } from "../../components/SearchBar.js";
16
17
  import { output, outputError } from "../../utils/output.js";
17
18
  import { getBlueprintUrl } from "../../utils/url.js";
18
19
  import { colors } from "../../utils/theme.js";
@@ -21,6 +22,7 @@ import { DevboxCreatePage } from "../../components/DevboxCreatePage.js";
21
22
  import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
22
23
  import { useViewportHeight } from "../../hooks/useViewportHeight.js";
23
24
  import { useCursorPagination } from "../../hooks/useCursorPagination.js";
25
+ import { useListSearch } from "../../hooks/useListSearch.js";
24
26
  import { useNavigation } from "../../store/navigationStore.js";
25
27
  import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js";
26
28
  const DEFAULT_PAGE_SIZE = 10;
@@ -39,8 +41,13 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
39
41
  const [selectedIndex, setSelectedIndex] = React.useState(0);
40
42
  const [showPopup, setShowPopup] = React.useState(false);
41
43
  const { navigate } = useNavigation();
44
+ // Search state
45
+ const search = useListSearch({
46
+ onSearchSubmit: () => setSelectedIndex(0),
47
+ onSearchClear: () => setSelectedIndex(0),
48
+ });
42
49
  // Calculate overhead for viewport height
43
- const overhead = 13;
50
+ const overhead = 13 + search.getSearchOverhead();
44
51
  const { viewportHeight, terminalWidth } = useViewportHeight({
45
52
  overhead,
46
53
  minHeight: 5,
@@ -70,6 +77,9 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
70
77
  if (params.startingAt) {
71
78
  queryParams.starting_after = params.startingAt;
72
79
  }
80
+ if (search.submittedSearchQuery) {
81
+ queryParams.search = search.submittedSearchQuery;
82
+ }
73
83
  // Fetch ONE page only
74
84
  const page = (await client.blueprints.list(queryParams));
75
85
  // Extract data and create defensive copies
@@ -89,7 +99,7 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
89
99
  totalCount: page.total_count || pageBlueprints.length,
90
100
  };
91
101
  return result;
92
- }, []);
102
+ }, [search.submittedSearchQuery]);
93
103
  // Use the shared pagination hook
94
104
  const { items: blueprints, loading, navigating, error: listError, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, } = useCursorPagination({
95
105
  fetchPage,
@@ -99,8 +109,9 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
99
109
  pollingEnabled: !showPopup &&
100
110
  !showCreateDevbox &&
101
111
  !executingOperation &&
102
- !showDeleteConfirm,
103
- deps: [PAGE_SIZE],
112
+ !showDeleteConfirm &&
113
+ !search.searchMode,
114
+ deps: [PAGE_SIZE, search.submittedSearchQuery],
104
115
  });
105
116
  // Memoize columns array
106
117
  const blueprintColumns = React.useMemo(() => [
@@ -272,6 +283,13 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
272
283
  : allOperations;
273
284
  // Handle input for all views
274
285
  useInput((input, key) => {
286
+ // Handle search mode input
287
+ if (search.searchMode) {
288
+ if (key.escape) {
289
+ search.cancelSearch();
290
+ }
291
+ return;
292
+ }
275
293
  // Handle operation input mode
276
294
  if (executingOperation && !operationResult && !operationError) {
277
295
  // Allow escape/q to cancel any operation, even during loading
@@ -431,7 +449,13 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
431
449
  };
432
450
  openBrowser();
433
451
  }
452
+ else if (input === "/") {
453
+ search.enterSearchMode();
454
+ }
434
455
  else if (key.escape) {
456
+ if (search.handleEscape()) {
457
+ return;
458
+ }
435
459
  if (onBack) {
436
460
  onBack();
437
461
  }
@@ -520,7 +544,7 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
520
544
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Blueprints", active: true }] }), _jsx(ErrorMessage, { message: "Failed to load blueprints", error: listError })] }));
521
545
  }
522
546
  // List view
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) => ({
547
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Blueprints", active: true }] }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: "Search blueprints..." }), !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] }), search.submittedSearchQuery && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.warning, children: ["Filtered: \"", search.submittedSearchQuery, "\""] })] }))] })), showPopup && selectedBlueprintItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedBlueprintItem, operations: allOperations.map((op) => ({
524
548
  key: op.key,
525
549
  label: op.label,
526
550
  color: op.color,
@@ -543,6 +567,7 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
543
567
  { key: "Enter", label: "Details" },
544
568
  { key: "a", label: "Actions" },
545
569
  { key: "o", label: "Browser" },
570
+ { key: "/", label: "Search" },
546
571
  { key: "Esc", label: "Back" },
547
572
  ] })] }));
548
573
  };
@@ -1,7 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { Box, Text, useInput, useApp } from "ink";
4
- import TextInput from "ink-text-input";
5
4
  import figures from "figures";
6
5
  import { getClient } from "../../utils/client.js";
7
6
  import { SpinnerComponent } from "../../components/Spinner.js";
@@ -11,6 +10,7 @@ import { Breadcrumb } from "../../components/Breadcrumb.js";
11
10
  import { NavigationTips } from "../../components/NavigationTips.js";
12
11
  import { Table, createTextColumn } from "../../components/Table.js";
13
12
  import { formatTimeAgo } from "../../components/ResourceListView.js";
13
+ import { SearchBar } from "../../components/SearchBar.js";
14
14
  import { output, outputError } from "../../utils/output.js";
15
15
  import { DevboxDetailPage } from "../../components/DevboxDetailPage.js";
16
16
  import { DevboxCreatePage } from "../../components/DevboxCreatePage.js";
@@ -20,6 +20,7 @@ import { getDevboxUrl } from "../../utils/url.js";
20
20
  import { useViewportHeight } from "../../hooks/useViewportHeight.js";
21
21
  import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
22
22
  import { useCursorPagination } from "../../hooks/useCursorPagination.js";
23
+ import { useListSearch } from "../../hooks/useListSearch.js";
23
24
  import { colors } from "../../utils/theme.js";
24
25
  import { useDevboxStore } from "../../store/devboxStore.js";
25
26
  const DEFAULT_PAGE_SIZE = 10;
@@ -31,9 +32,11 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
31
32
  const [showActions, setShowActions] = React.useState(false);
32
33
  const [showPopup, setShowPopup] = React.useState(false);
33
34
  const [selectedOperation, setSelectedOperation] = React.useState(0);
34
- const [searchMode, setSearchMode] = React.useState(false);
35
- const [searchQuery, setSearchQuery] = React.useState("");
36
- const [submittedSearchQuery, setSubmittedSearchQuery] = React.useState("");
35
+ // Search state using shared hook
36
+ const search = useListSearch({
37
+ onSearchSubmit: () => setSelectedIndex(0),
38
+ onSearchClear: () => setSelectedIndex(0),
39
+ });
37
40
  // Get devbox store setter to sync data for detail screen
38
41
  const setDevboxesInStore = useDevboxStore((state) => state.setDevboxes);
39
42
  // Calculate overhead for viewport height:
@@ -44,7 +47,7 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
44
47
  // - Help bar (marginTop + content): 2 lines
45
48
  // - Safety buffer for edge cases: 1 line
46
49
  // Total: 13 lines base + 2 if searching
47
- const overhead = 13 + (searchMode || submittedSearchQuery ? 2 : 0);
50
+ const overhead = 13 + search.getSearchOverhead();
48
51
  const { viewportHeight, terminalWidth } = useViewportHeight({
49
52
  overhead,
50
53
  minHeight: 5,
@@ -64,8 +67,8 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
64
67
  if (status) {
65
68
  queryParams.status = status;
66
69
  }
67
- if (submittedSearchQuery) {
68
- queryParams.search = submittedSearchQuery;
70
+ if (search.submittedSearchQuery) {
71
+ queryParams.search = search.submittedSearchQuery;
69
72
  }
70
73
  // Fetch ONE page only
71
74
  const page = (await client.devboxes.list(queryParams));
@@ -81,15 +84,19 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
81
84
  totalCount: page.total_count || pageDevboxes.length,
82
85
  };
83
86
  return result;
84
- }, [status, submittedSearchQuery]);
87
+ }, [status, search.submittedSearchQuery]);
85
88
  // Use the shared pagination hook
86
89
  const { items: devboxes, loading, navigating, error, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, } = useCursorPagination({
87
90
  fetchPage,
88
91
  pageSize: PAGE_SIZE,
89
92
  getItemId: (devbox) => devbox.id,
90
93
  pollInterval: 2000,
91
- pollingEnabled: !showDetails && !showCreate && !showActions && !showPopup && !searchMode,
92
- deps: [status, submittedSearchQuery, PAGE_SIZE],
94
+ pollingEnabled: !showDetails &&
95
+ !showCreate &&
96
+ !showActions &&
97
+ !showPopup &&
98
+ !search.searchMode,
99
+ deps: [status, search.submittedSearchQuery, PAGE_SIZE],
93
100
  });
94
101
  // Sync devboxes to store for detail screen
95
102
  React.useEffect(() => {
@@ -315,10 +322,9 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
315
322
  useInput((input, key) => {
316
323
  const pageDevboxes = devboxes.length;
317
324
  // Skip input handling when in search mode - let TextInput handle it
318
- if (searchMode) {
325
+ if (search.searchMode) {
319
326
  if (key.escape) {
320
- setSearchMode(false);
321
- setSearchQuery("");
327
+ search.cancelSearch();
322
328
  }
323
329
  return;
324
330
  }
@@ -416,24 +422,20 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
416
422
  openBrowser();
417
423
  }
418
424
  else if (input === "/") {
419
- setSearchMode(true);
425
+ search.enterSearchMode();
420
426
  }
421
427
  else if (key.escape) {
422
- if (submittedSearchQuery) {
423
- setSubmittedSearchQuery("");
424
- setSearchQuery("");
425
- setSelectedIndex(0);
428
+ if (search.handleEscape()) {
429
+ return;
430
+ }
431
+ if (onBack) {
432
+ onBack();
433
+ }
434
+ else if (onExit) {
435
+ onExit();
426
436
  }
427
437
  else {
428
- if (onBack) {
429
- onBack();
430
- }
431
- else if (onExit) {
432
- onExit();
433
- }
434
- else {
435
- inkExit();
436
- }
438
+ inkExit();
437
439
  }
438
440
  }
439
441
  });
@@ -472,13 +474,7 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
472
474
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }] }), _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
473
475
  }
474
476
  // Main list view
475
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }] }), searchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.pointerSmall, " Search: "] }), _jsx(TextInput, { value: searchQuery, onChange: setSearchQuery, placeholder: "Type to search...", onSubmit: () => {
476
- setSearchMode(false);
477
- setSubmittedSearchQuery(searchQuery);
478
- setSelectedIndex(0);
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
480
- ? submittedSearchQuery.substring(0, 50) + "..."
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: [
477
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }] }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: "Search devboxes..." }), !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] }), search.submittedSearchQuery && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.warning, children: ["Filtered: \"", search.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
478
  {
483
479
  icon: `${figures.arrowLeft}${figures.arrowRight}`,
484
480
  label: "Page",
@@ -12,11 +12,13 @@ import { NavigationTips } from "../../components/NavigationTips.js";
12
12
  import { Table, createTextColumn } from "../../components/Table.js";
13
13
  import { ActionsPopup } from "../../components/ActionsPopup.js";
14
14
  import { formatTimeAgo } from "../../components/ResourceListView.js";
15
+ import { SearchBar } from "../../components/SearchBar.js";
15
16
  import { output, outputError } from "../../utils/output.js";
16
17
  import { colors } from "../../utils/theme.js";
17
18
  import { useViewportHeight } from "../../hooks/useViewportHeight.js";
18
19
  import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
19
20
  import { useCursorPagination } from "../../hooks/useCursorPagination.js";
21
+ import { useListSearch } from "../../hooks/useListSearch.js";
20
22
  import { useNavigation } from "../../store/navigationStore.js";
21
23
  import { NetworkPolicyCreatePage } from "../../components/NetworkPolicyCreatePage.js";
22
24
  import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js";
@@ -60,8 +62,13 @@ const ListNetworkPoliciesUI = ({ onBack, onExit, }) => {
60
62
  const [showEditPolicy, setShowEditPolicy] = React.useState(false);
61
63
  const [editingPolicy, setEditingPolicy] = React.useState(null);
62
64
  const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
65
+ // Search state
66
+ const search = useListSearch({
67
+ onSearchSubmit: () => setSelectedIndex(0),
68
+ onSearchClear: () => setSelectedIndex(0),
69
+ });
63
70
  // Calculate overhead for viewport height
64
- const overhead = 13;
71
+ const overhead = 13 + search.getSearchOverhead();
65
72
  const { viewportHeight, terminalWidth } = useViewportHeight({
66
73
  overhead,
67
74
  minHeight: 5,
@@ -90,6 +97,9 @@ const ListNetworkPoliciesUI = ({ onBack, onExit, }) => {
90
97
  if (params.startingAt) {
91
98
  queryParams.starting_after = params.startingAt;
92
99
  }
100
+ if (search.submittedSearchQuery) {
101
+ queryParams.search = search.submittedSearchQuery;
102
+ }
93
103
  // Fetch ONE page only
94
104
  const page = (await client.networkPolicies.list(queryParams));
95
105
  // Extract data and create defensive copies
@@ -115,7 +125,7 @@ const ListNetworkPoliciesUI = ({ onBack, onExit, }) => {
115
125
  totalCount: page.total_count || pagePolicies.length,
116
126
  };
117
127
  return result;
118
- }, []);
128
+ }, [search.submittedSearchQuery]);
119
129
  // Use the shared pagination hook
120
130
  const { items: policies, loading, navigating, error, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, refresh, } = useCursorPagination({
121
131
  fetchPage,
@@ -126,8 +136,9 @@ const ListNetworkPoliciesUI = ({ onBack, onExit, }) => {
126
136
  !executingOperation &&
127
137
  !showCreatePolicy &&
128
138
  !showEditPolicy &&
129
- !showDeleteConfirm,
130
- deps: [PAGE_SIZE],
139
+ !showDeleteConfirm &&
140
+ !search.searchMode,
141
+ deps: [PAGE_SIZE, search.submittedSearchQuery],
131
142
  });
132
143
  // Operations for a specific network policy (shown in popup)
133
144
  const operations = React.useMemo(() => [
@@ -232,6 +243,13 @@ const ListNetworkPoliciesUI = ({ onBack, onExit, }) => {
232
243
  }
233
244
  };
234
245
  useInput((input, key) => {
246
+ // Handle search mode input
247
+ if (search.searchMode) {
248
+ if (key.escape) {
249
+ search.cancelSearch();
250
+ }
251
+ return;
252
+ }
235
253
  // Handle operation result display
236
254
  if (operationResult || operationError) {
237
255
  if (input === "q" || key.escape || key.return) {
@@ -363,7 +381,13 @@ const ListNetworkPoliciesUI = ({ onBack, onExit, }) => {
363
381
  setEditingPolicy(selectedPolicyItem);
364
382
  setShowEditPolicy(true);
365
383
  }
384
+ else if (input === "/") {
385
+ search.enterSearchMode();
386
+ }
366
387
  else if (key.escape) {
388
+ if (search.handleEscape()) {
389
+ return;
390
+ }
367
391
  if (onBack) {
368
392
  onBack();
369
393
  }
@@ -443,7 +467,7 @@ const ListNetworkPoliciesUI = ({ onBack, onExit, }) => {
443
467
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Network Policies", active: true }] }), _jsx(ErrorMessage, { message: "Failed to list network policies", error: error })] }));
444
468
  }
445
469
  // Main list view
446
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Network Policies", active: true }] }), !showPopup && (_jsx(Table, { data: policies, keyExtractor: (policy) => policy.id, selectedIndex: selectedIndex, title: `network_policies[${totalCount}]`, columns: columns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No network policies 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] })] })), showPopup && selectedPolicyItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedPolicyItem, operations: operations.map((op) => ({
470
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Network Policies", active: true }] }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: "Search network policies..." }), !showPopup && (_jsx(Table, { data: policies, keyExtractor: (policy) => policy.id, selectedIndex: selectedIndex, title: `network_policies[${totalCount}]`, columns: columns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No network policies 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] }), search.submittedSearchQuery && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.warning, children: ["Filtered: \"", search.submittedSearchQuery, "\""] })] }))] })), showPopup && selectedPolicyItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedPolicyItem, operations: operations.map((op) => ({
447
471
  key: op.key,
448
472
  label: op.label,
449
473
  color: op.color,
@@ -467,6 +491,7 @@ const ListNetworkPoliciesUI = ({ onBack, onExit, }) => {
467
491
  { key: "c", label: "Create" },
468
492
  { key: "e", label: "Edit" },
469
493
  { key: "a", label: "Actions" },
494
+ { key: "/", label: "Search" },
470
495
  { key: "Esc", label: "Back" },
471
496
  ] })] }));
472
497
  };
@@ -14,11 +14,13 @@ import { NavigationTips } from "../../components/NavigationTips.js";
14
14
  import { Table, createTextColumn } from "../../components/Table.js";
15
15
  import { ActionsPopup } from "../../components/ActionsPopup.js";
16
16
  import { formatTimeAgo } from "../../components/ResourceListView.js";
17
+ import { SearchBar } from "../../components/SearchBar.js";
17
18
  import { output, outputError } from "../../utils/output.js";
18
19
  import { colors } from "../../utils/theme.js";
19
20
  import { useViewportHeight } from "../../hooks/useViewportHeight.js";
20
21
  import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
21
22
  import { useCursorPagination } from "../../hooks/useCursorPagination.js";
23
+ import { useListSearch } from "../../hooks/useListSearch.js";
22
24
  import { useNavigation } from "../../store/navigationStore.js";
23
25
  import { formatFileSize } from "../../services/objectService.js";
24
26
  import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js";
@@ -38,8 +40,13 @@ const ListObjectsUI = ({ onBack, onExit, }) => {
38
40
  const [showDownloadPrompt, setShowDownloadPrompt] = React.useState(false);
39
41
  const [downloadPath, setDownloadPath] = React.useState("");
40
42
  const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
43
+ // Search state
44
+ const search = useListSearch({
45
+ onSearchSubmit: () => setSelectedIndex(0),
46
+ onSearchClear: () => setSelectedIndex(0),
47
+ });
41
48
  // Calculate overhead for viewport height
42
- const overhead = 13;
49
+ const overhead = 13 + search.getSearchOverhead();
43
50
  const { viewportHeight, terminalWidth } = useViewportHeight({
44
51
  overhead,
45
52
  minHeight: 5,
@@ -95,6 +102,9 @@ const ListObjectsUI = ({ onBack, onExit, }) => {
95
102
  if (params.startingAt) {
96
103
  queryParams.starting_after = params.startingAt;
97
104
  }
105
+ if (search.submittedSearchQuery) {
106
+ queryParams.search = search.submittedSearchQuery;
107
+ }
98
108
  // Fetch ONE page only
99
109
  const result = await client.objects.list(queryParams);
100
110
  // Extract data and create defensive copies
@@ -120,7 +130,7 @@ const ListObjectsUI = ({ onBack, onExit, }) => {
120
130
  hasMore: pageResult.has_more || false,
121
131
  totalCount: pageResult.total_count || pageObjects.length,
122
132
  };
123
- }, []);
133
+ }, [search.submittedSearchQuery]);
124
134
  // Use the shared pagination hook
125
135
  const { items: objects, loading, navigating, error, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, refresh, } = useCursorPagination({
126
136
  fetchPage,
@@ -130,8 +140,9 @@ const ListObjectsUI = ({ onBack, onExit, }) => {
130
140
  pollingEnabled: !showPopup &&
131
141
  !executingOperation &&
132
142
  !showDownloadPrompt &&
133
- !showDeleteConfirm,
134
- deps: [PAGE_SIZE],
143
+ !showDeleteConfirm &&
144
+ !search.searchMode,
145
+ deps: [PAGE_SIZE, search.submittedSearchQuery],
135
146
  });
136
147
  // Operations for objects
137
148
  const operations = React.useMemo(() => [
@@ -274,6 +285,13 @@ const ListObjectsUI = ({ onBack, onExit, }) => {
274
285
  }
275
286
  };
276
287
  useInput((input, key) => {
288
+ // Handle search mode input
289
+ if (search.searchMode) {
290
+ if (key.escape) {
291
+ search.cancelSearch();
292
+ }
293
+ return;
294
+ }
277
295
  // Handle operation result display
278
296
  if (operationResult || operationError) {
279
297
  if (input === "q" || key.escape || key.return) {
@@ -397,7 +415,13 @@ const ListObjectsUI = ({ onBack, onExit, }) => {
397
415
  setShowPopup(true);
398
416
  setSelectedOperation(0);
399
417
  }
418
+ else if (input === "/") {
419
+ search.enterSearchMode();
420
+ }
400
421
  else if (key.escape) {
422
+ if (search.handleEscape()) {
423
+ return;
424
+ }
401
425
  if (onBack) {
402
426
  onBack();
403
427
  }
@@ -470,7 +494,7 @@ const ListObjectsUI = ({ onBack, onExit, }) => {
470
494
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Storage Objects", active: true }] }), _jsx(ErrorMessage, { message: "Failed to list storage objects", error: error })] }));
471
495
  }
472
496
  // Main list view
473
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Storage Objects", active: true }] }), !showPopup && (_jsx(Table, { data: objects, keyExtractor: (obj) => obj.id, selectedIndex: selectedIndex, title: `storage_objects[${totalCount}]`, columns: columns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No storage objects found. Try: rli object upload", " ", "<file>"] }) })), !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 && selectedObjectItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedObjectItem, operations: operations.map((op) => ({
497
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Storage Objects", active: true }] }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: "Search storage objects..." }), !showPopup && (_jsx(Table, { data: objects, keyExtractor: (obj) => obj.id, selectedIndex: selectedIndex, title: `storage_objects[${totalCount}]`, columns: columns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No storage objects found. Try: rli object upload", " ", "<file>"] }) })), !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] }), search.submittedSearchQuery && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.warning, children: ["Filtered: \"", search.submittedSearchQuery, "\""] })] }))] })), showPopup && selectedObjectItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedObjectItem, operations: operations.map((op) => ({
474
498
  key: op.key,
475
499
  label: op.label,
476
500
  color: op.color,
@@ -490,6 +514,7 @@ const ListObjectsUI = ({ onBack, onExit, }) => {
490
514
  },
491
515
  { key: "Enter", label: "Details" },
492
516
  { key: "a", label: "Actions" },
517
+ { key: "/", label: "Search" },
493
518
  { key: "Esc", label: "Back" },
494
519
  ] })] }));
495
520
  };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Create secret command
3
+ */
4
+ import { getClient } from "../../utils/client.js";
5
+ import { output, outputError } from "../../utils/output.js";
6
+ import { getSecretValue } from "../../utils/stdin.js";
7
+ export async function createSecret(name, options = {}) {
8
+ try {
9
+ // Get secret value from stdin (piped) or interactive prompt
10
+ const value = await getSecretValue();
11
+ if (!value) {
12
+ outputError("Secret value cannot be empty", new Error("Empty value"));
13
+ }
14
+ const client = getClient();
15
+ const secret = await client.secrets.create({ name, value });
16
+ // Default: just output the ID for easy scripting
17
+ if (!options.output || options.output === "text") {
18
+ console.log(secret.id);
19
+ }
20
+ else {
21
+ output(secret, { format: options.output, defaultFormat: "json" });
22
+ }
23
+ }
24
+ catch (error) {
25
+ outputError("Failed to create secret", error);
26
+ }
27
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Delete secret command
3
+ */
4
+ import * as readline from "readline";
5
+ import { getClient } from "../../utils/client.js";
6
+ import { output } from "../../utils/output.js";
7
+ /**
8
+ * Prompt for confirmation
9
+ */
10
+ async function confirm(message) {
11
+ return new Promise((resolve) => {
12
+ const rl = readline.createInterface({
13
+ input: process.stdin,
14
+ output: process.stdout,
15
+ });
16
+ rl.question(`${message} [y/N] `, (answer) => {
17
+ rl.close();
18
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
19
+ });
20
+ });
21
+ }
22
+ export async function deleteSecret(name, options = {}) {
23
+ try {
24
+ const client = getClient();
25
+ // Confirm deletion unless --yes flag is passed
26
+ if (!options.yes) {
27
+ const confirmed = await confirm(`Are you sure you want to delete secret "${name}"?`);
28
+ if (!confirmed) {
29
+ console.log("Aborted.");
30
+ return;
31
+ }
32
+ }
33
+ // Delete by name
34
+ const secret = await client.secrets.delete(name);
35
+ // Default: show confirmation message
36
+ if (!options.output || options.output === "text") {
37
+ console.log(`Deleted secret "${name}" (${secret.id})`);
38
+ }
39
+ else {
40
+ output({ id: secret.id, name, status: "deleted" }, { format: options.output, defaultFormat: "json" });
41
+ }
42
+ }
43
+ catch (error) {
44
+ const errorMessage = error instanceof Error ? error.message : String(error);
45
+ if (errorMessage.includes("404") || errorMessage.includes("not found")) {
46
+ console.error(`Error: Secret "${name}" not found`);
47
+ }
48
+ else {
49
+ console.error(`Error: Failed to delete secret`);
50
+ console.error(` ${errorMessage}`);
51
+ }
52
+ process.exit(1);
53
+ }
54
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Get secret metadata command
3
+ *
4
+ * Note: The API doesn't have a direct "get by name" endpoint,
5
+ * so we list all secrets and filter by name.
6
+ */
7
+ import { getClient } from "../../utils/client.js";
8
+ import { output, outputError } from "../../utils/output.js";
9
+ export async function getSecret(name, options = {}) {
10
+ try {
11
+ const client = getClient();
12
+ // List all secrets and find by name
13
+ const result = await client.secrets.list({ limit: 5000 });
14
+ const secret = result.secrets?.find((s) => s.name === name);
15
+ if (!secret) {
16
+ outputError(`Secret "${name}" not found`, new Error("Secret not found"));
17
+ }
18
+ output(secret, { format: options.output, defaultFormat: "json" });
19
+ }
20
+ catch (error) {
21
+ outputError("Failed to get secret", error);
22
+ }
23
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * List secrets command
3
+ */
4
+ import { getClient } from "../../utils/client.js";
5
+ import { output, outputError } from "../../utils/output.js";
6
+ const DEFAULT_PAGE_SIZE = 20;
7
+ export async function listSecrets(options = {}) {
8
+ try {
9
+ const client = getClient();
10
+ const limit = options.limit
11
+ ? parseInt(options.limit, 10)
12
+ : DEFAULT_PAGE_SIZE;
13
+ // Fetch secrets
14
+ const result = await client.secrets.list({ limit });
15
+ // Extract secrets array
16
+ const secrets = result.secrets || [];
17
+ // Default: output JSON for lists
18
+ output(secrets, { format: options.output, defaultFormat: "json" });
19
+ }
20
+ catch (error) {
21
+ outputError("Failed to list secrets", error);
22
+ }
23
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Update secret command
3
+ */
4
+ import { getClient } from "../../utils/client.js";
5
+ import { output, outputError } from "../../utils/output.js";
6
+ import { getSecretValue } from "../../utils/stdin.js";
7
+ export async function updateSecret(name, options = {}) {
8
+ try {
9
+ // Get new secret value from stdin (piped) or interactive prompt
10
+ const value = await getSecretValue();
11
+ if (!value) {
12
+ outputError("Secret value cannot be empty", new Error("Empty value"));
13
+ }
14
+ const client = getClient();
15
+ const secret = await client.secrets.update(name, { value });
16
+ // Default: just output the ID for easy scripting
17
+ if (!options.output || options.output === "text") {
18
+ console.log(secret.id);
19
+ }
20
+ else {
21
+ output(secret, { format: options.output, defaultFormat: "json" });
22
+ }
23
+ }
24
+ catch (error) {
25
+ outputError("Failed to update secret", error);
26
+ }
27
+ }
@@ -12,11 +12,13 @@ import { NavigationTips } from "../../components/NavigationTips.js";
12
12
  import { Table, createTextColumn } from "../../components/Table.js";
13
13
  import { ActionsPopup } from "../../components/ActionsPopup.js";
14
14
  import { formatTimeAgo } from "../../components/ResourceListView.js";
15
+ import { SearchBar } from "../../components/SearchBar.js";
15
16
  import { output, outputError } from "../../utils/output.js";
16
17
  import { colors } from "../../utils/theme.js";
17
18
  import { useViewportHeight } from "../../hooks/useViewportHeight.js";
18
19
  import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
19
20
  import { useCursorPagination } from "../../hooks/useCursorPagination.js";
21
+ import { useListSearch } from "../../hooks/useListSearch.js";
20
22
  import { DevboxCreatePage } from "../../components/DevboxCreatePage.js";
21
23
  import { useNavigation } from "../../store/navigationStore.js";
22
24
  import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js";
@@ -35,8 +37,13 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
35
37
  const [operationLoading, setOperationLoading] = React.useState(false);
36
38
  const [showCreateDevbox, setShowCreateDevbox] = React.useState(false);
37
39
  const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
40
+ // Search state
41
+ const search = useListSearch({
42
+ onSearchSubmit: () => setSelectedIndex(0),
43
+ onSearchClear: () => setSelectedIndex(0),
44
+ });
38
45
  // Calculate overhead for viewport height
39
- const overhead = 13;
46
+ const overhead = 13 + search.getSearchOverhead();
40
47
  const { viewportHeight, terminalWidth } = useViewportHeight({
41
48
  overhead,
42
49
  minHeight: 5,
@@ -67,6 +74,9 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
67
74
  if (devboxId) {
68
75
  queryParams.devbox_id = devboxId;
69
76
  }
77
+ if (search.submittedSearchQuery) {
78
+ queryParams.search = search.submittedSearchQuery;
79
+ }
70
80
  // Fetch ONE page only
71
81
  const page = (await client.devboxes.listDiskSnapshots(queryParams));
72
82
  // Extract data and create defensive copies
@@ -87,7 +97,7 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
87
97
  totalCount: page.total_count || pageSnapshots.length,
88
98
  };
89
99
  return result;
90
- }, [devboxId]);
100
+ }, [devboxId, search.submittedSearchQuery]);
91
101
  // Use the shared pagination hook
92
102
  const { items: snapshots, loading, navigating, error, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, refresh, } = useCursorPagination({
93
103
  fetchPage,
@@ -97,8 +107,9 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
97
107
  pollingEnabled: !showPopup &&
98
108
  !executingOperation &&
99
109
  !showCreateDevbox &&
100
- !showDeleteConfirm,
101
- deps: [devboxId, PAGE_SIZE],
110
+ !showDeleteConfirm &&
111
+ !search.searchMode,
112
+ deps: [devboxId, PAGE_SIZE, search.submittedSearchQuery],
102
113
  });
103
114
  // Operations for snapshots
104
115
  const operations = React.useMemo(() => [
@@ -180,6 +191,13 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
180
191
  }
181
192
  };
182
193
  useInput((input, key) => {
194
+ // Handle search mode input
195
+ if (search.searchMode) {
196
+ if (key.escape) {
197
+ search.cancelSearch();
198
+ }
199
+ return;
200
+ }
183
201
  // Handle operation result display
184
202
  if (operationResult || operationError) {
185
203
  if (input === "q" || key.escape || key.return) {
@@ -289,7 +307,13 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
289
307
  setShowPopup(true);
290
308
  setSelectedOperation(0);
291
309
  }
310
+ else if (input === "/") {
311
+ search.enterSearchMode();
312
+ }
292
313
  else if (key.escape) {
314
+ if (search.handleEscape()) {
315
+ return;
316
+ }
293
317
  if (onBack) {
294
318
  onBack();
295
319
  }
@@ -374,7 +398,7 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
374
398
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
375
399
  { label: "Snapshots", active: !devboxId },
376
400
  ...(devboxId ? [{ label: `Devbox: ${devboxId}`, active: true }] : []),
377
- ] }), !showPopup && (_jsx(Table, { data: snapshots, keyExtractor: (snapshot) => snapshot.id, selectedIndex: selectedIndex, title: `snapshots[${totalCount}]`, columns: columns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No snapshots found. Try: rli snapshot create", " ", "<devbox-id>"] }) })), !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 && selectedSnapshotItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedSnapshotItem, operations: operations.map((op) => ({
401
+ ] }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: "Search snapshots..." }), !showPopup && (_jsx(Table, { data: snapshots, keyExtractor: (snapshot) => snapshot.id, selectedIndex: selectedIndex, title: `snapshots[${totalCount}]`, columns: columns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No snapshots found. Try: rli snapshot create", " ", "<devbox-id>"] }) })), !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] }), search.submittedSearchQuery && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.warning, children: ["Filtered: \"", search.submittedSearchQuery, "\""] })] }))] })), showPopup && selectedSnapshotItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedSnapshotItem, operations: operations.map((op) => ({
378
402
  key: op.key,
379
403
  label: op.label,
380
404
  color: op.color,
@@ -394,6 +418,7 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
394
418
  },
395
419
  { key: "Enter", label: "Details" },
396
420
  { key: "a", label: "Actions" },
421
+ { key: "/", label: "Search" },
397
422
  { key: "Esc", label: "Back" },
398
423
  ] })] }));
399
424
  };
@@ -0,0 +1,24 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import figures from "figures";
5
+ import { colors } from "../utils/theme.js";
6
+ /**
7
+ * Reusable search bar component for list views.
8
+ * Displays either an input mode or the active search with result count.
9
+ */
10
+ export function SearchBar({ searchMode, searchQuery, submittedSearchQuery, resultCount, onSearchChange, onSearchSubmit, placeholder = "Type to search...", maxDisplayLength = 50, }) {
11
+ // Don't render if no search is active
12
+ if (!searchMode && !submittedSearchQuery) {
13
+ return null;
14
+ }
15
+ // Search input mode
16
+ if (searchMode) {
17
+ return (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.pointerSmall, " Search: "] }), _jsx(TextInput, { value: searchQuery, onChange: onSearchChange, placeholder: placeholder, onSubmit: onSearchSubmit }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Enter to search, Esc to cancel]"] })] }));
18
+ }
19
+ // Display active search with results
20
+ const displayQuery = submittedSearchQuery.length > maxDisplayLength
21
+ ? submittedSearchQuery.substring(0, maxDisplayLength) + "..."
22
+ : submittedSearchQuery;
23
+ return (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.info, " Searching for: "] }), _jsx(Text, { color: colors.warning, bold: true, children: displayQuery }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", resultCount, " results) [/ to edit, Esc to clear]"] })] }));
24
+ }
@@ -0,0 +1,54 @@
1
+ import React from "react";
2
+ /**
3
+ * Shared hook for managing search state in list views.
4
+ * Provides consistent search behavior across all resource lists.
5
+ */
6
+ export function useListSearch(options = {}) {
7
+ const [searchMode, setSearchMode] = React.useState(false);
8
+ const [searchQuery, setSearchQuery] = React.useState("");
9
+ const [submittedSearchQuery, setSubmittedSearchQuery] = React.useState("");
10
+ const enterSearchMode = React.useCallback(() => {
11
+ setSearchMode(true);
12
+ }, []);
13
+ const cancelSearch = React.useCallback(() => {
14
+ setSearchMode(false);
15
+ setSearchQuery("");
16
+ }, []);
17
+ const submitSearch = React.useCallback(() => {
18
+ setSearchMode(false);
19
+ setSubmittedSearchQuery(searchQuery);
20
+ options.onSearchSubmit?.(searchQuery);
21
+ }, [searchQuery, options.onSearchSubmit]);
22
+ const clearSearch = React.useCallback(() => {
23
+ setSubmittedSearchQuery("");
24
+ setSearchQuery("");
25
+ options.onSearchClear?.();
26
+ }, [options.onSearchClear]);
27
+ const handleEscape = React.useCallback(() => {
28
+ if (searchMode) {
29
+ cancelSearch();
30
+ return true;
31
+ }
32
+ if (submittedSearchQuery) {
33
+ clearSearch();
34
+ return true;
35
+ }
36
+ return false;
37
+ }, [searchMode, submittedSearchQuery, cancelSearch, clearSearch]);
38
+ const getSearchOverhead = React.useCallback(() => {
39
+ // Search bar takes 2 lines when visible (1 line content + 1 marginBottom)
40
+ return searchMode || submittedSearchQuery ? 2 : 0;
41
+ }, [searchMode, submittedSearchQuery]);
42
+ return {
43
+ searchMode,
44
+ searchQuery,
45
+ submittedSearchQuery,
46
+ enterSearchMode,
47
+ cancelSearch,
48
+ setSearchQuery,
49
+ submitSearch,
50
+ clearSearch,
51
+ handleEscape,
52
+ getSearchOverhead,
53
+ };
54
+ }
@@ -439,6 +439,53 @@ export function createProgram() {
439
439
  const { deleteNetworkPolicy } = await import("../commands/network-policy/delete.js");
440
440
  await deleteNetworkPolicy(id, options);
441
441
  });
442
+ // Secret commands
443
+ const secret = program
444
+ .command("secret")
445
+ .description("Manage secrets")
446
+ .alias("s");
447
+ secret
448
+ .command("create <name>")
449
+ .description("Create a new secret. Value can be piped via stdin (e.g., echo 'val' | rli secret create name) or entered interactively with masked input for security.")
450
+ .option("-o, --output [format]", "Output format: text|json|yaml (default: text)")
451
+ .action(async (name, options) => {
452
+ const { createSecret } = await import("../commands/secret/create.js");
453
+ await createSecret(name, options);
454
+ });
455
+ secret
456
+ .command("list")
457
+ .description("List all secrets")
458
+ .option("--limit <n>", "Max results", "20")
459
+ .option("-o, --output [format]", "Output format: text|json|yaml (default: json)")
460
+ .action(async (options) => {
461
+ const { listSecrets } = await import("../commands/secret/list.js");
462
+ await listSecrets(options);
463
+ });
464
+ secret
465
+ .command("get <name>")
466
+ .description("Get secret metadata by name")
467
+ .option("-o, --output [format]", "Output format: text|json|yaml (default: json)")
468
+ .action(async (name, options) => {
469
+ const { getSecret } = await import("../commands/secret/get.js");
470
+ await getSecret(name, options);
471
+ });
472
+ secret
473
+ .command("update <name>")
474
+ .description("Update a secret value (value from stdin or secure prompt)")
475
+ .option("-o, --output [format]", "Output format: text|json|yaml (default: text)")
476
+ .action(async (name, options) => {
477
+ const { updateSecret } = await import("../commands/secret/update.js");
478
+ await updateSecret(name, options);
479
+ });
480
+ secret
481
+ .command("delete <name>")
482
+ .description("Delete a secret")
483
+ .option("-y, --yes", "Skip confirmation prompt")
484
+ .option("-o, --output [format]", "Output format: text|json|yaml (default: text)")
485
+ .action(async (name, options) => {
486
+ const { deleteSecret } = await import("../commands/secret/delete.js");
487
+ await deleteSecret(name, options);
488
+ });
442
489
  // MCP server commands
443
490
  const mcp = program
444
491
  .command("mcp")
@@ -117,7 +117,14 @@ export function output(data, options = {}) {
117
117
  export function outputError(message, error) {
118
118
  const errorMessage = error instanceof Error ? error.message : String(error || message);
119
119
  console.error(`Error: ${message}`);
120
- if (error && errorMessage !== message) {
120
+ // Only print the error message if it adds new information
121
+ // Skip if: same as message, message contains it, or it contains the message
122
+ const messageLower = message.toLowerCase();
123
+ const errorLower = errorMessage.toLowerCase();
124
+ const isRedundant = errorMessage === message ||
125
+ messageLower.includes(errorLower) ||
126
+ errorLower.includes(messageLower);
127
+ if (error && !isRedundant) {
121
128
  console.error(` ${errorMessage}`);
122
129
  }
123
130
  processUtils.exit(1);
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Utilities for reading secure input from stdin
3
+ */
4
+ /**
5
+ * Prompt for a secret value with masked input (shows * for each character)
6
+ * Only works when stdin is a TTY (interactive terminal)
7
+ */
8
+ export async function promptSecretValue(prompt = "Enter secret value: ") {
9
+ return new Promise((resolve) => {
10
+ process.stdout.write(prompt);
11
+ let value = "";
12
+ process.stdin.setRawMode(true);
13
+ process.stdin.resume();
14
+ process.stdin.setEncoding("utf8");
15
+ const onData = (char) => {
16
+ if (char === "\n" || char === "\r") {
17
+ process.stdin.setRawMode(false);
18
+ process.stdin.removeListener("data", onData);
19
+ process.stdin.pause();
20
+ process.stdout.write("\n");
21
+ resolve(value);
22
+ }
23
+ else if (char === "\u0003") {
24
+ // Ctrl+C
25
+ process.stdin.setRawMode(false);
26
+ process.stdout.write("\n");
27
+ process.exit(0);
28
+ }
29
+ else if (char === "\u007F" || char === "\b") {
30
+ // Backspace (0x7F) or Ctrl+H (0x08)
31
+ if (value.length > 0) {
32
+ value = value.slice(0, -1);
33
+ process.stdout.write("\b \b");
34
+ }
35
+ }
36
+ else if (char >= " ") {
37
+ // Only add printable characters
38
+ value += char;
39
+ process.stdout.write("*");
40
+ }
41
+ };
42
+ process.stdin.on("data", onData);
43
+ });
44
+ }
45
+ /**
46
+ * Read all data from stdin (for piped input)
47
+ */
48
+ export async function readStdin() {
49
+ const chunks = [];
50
+ for await (const chunk of process.stdin) {
51
+ chunks.push(Buffer.from(chunk));
52
+ }
53
+ return Buffer.concat(chunks).toString().trim();
54
+ }
55
+ /**
56
+ * Get a secret value from either piped stdin or interactive prompt
57
+ * Automatically detects whether input is piped or interactive
58
+ */
59
+ export async function getSecretValue(prompt = "Enter secret value: ") {
60
+ if (process.stdin.isTTY) {
61
+ return promptSecretValue(prompt);
62
+ }
63
+ else {
64
+ return readStdin();
65
+ }
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runloop/rl-cli",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "Beautiful CLI for the Runloop platform",
5
5
  "type": "module",
6
6
  "bin": {
@@ -87,13 +87,13 @@
87
87
  "zustand": "^5.0.2"
88
88
  },
89
89
  "devDependencies": {
90
- "@anthropic-ai/mcpb": "^1.1.1",
90
+ "@anthropic-ai/mcpb": "^2.1.2",
91
91
  "@types/jest": "^29.5.0",
92
92
  "@types/node": "^22.7.9",
93
93
  "@types/react": "^19.2.2",
94
94
  "@typescript-eslint/eslint-plugin": "^8.46.0",
95
95
  "@typescript-eslint/parser": "^8.46.0",
96
- "esbuild": "^0.25.11",
96
+ "esbuild": "^0.27.2",
97
97
  "eslint": "^9.37.0",
98
98
  "eslint-plugin-react": "^7.37.5",
99
99
  "eslint-plugin-react-hooks": "^6.1.1",