Skip to content

Commit 8dfec09

Browse files
add interactive picker to prime sandbox ssh (#689)
1 parent 4b8d182 commit 8dfec09

3 files changed

Lines changed: 327 additions & 6 deletions

File tree

packages/prime/src/prime_cli/commands/sandbox.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,15 @@
4141
obfuscate_env_vars,
4242
obfuscate_secrets,
4343
output_data_as_json,
44+
require_selection,
4445
sort_by_created,
4546
status_color,
4647
validate_output_format,
4748
)
4849
from ..utils.display import SANDBOX_STATUS_COLORS
4950
from ..utils.time_utils import now_utc, to_utc
5051

51-
app = PlainTyper(help="Manage code sandboxes", no_args_is_help=True)
52+
app = PlainTyper(help="Manage sandboxes", no_args_is_help=True)
5253
console = get_console()
5354

5455

@@ -195,6 +196,43 @@ def _guard_vm_unsupported(sandbox: Sandbox, feature_name: str) -> None:
195196
raise typer.Exit(1)
196197

197198

199+
def _ssh_sandbox_display(item: Dict[str, Any]) -> str:
200+
"""Format a sandbox row for the interactive SSH picker."""
201+
return f"{item['id']} {item['name']} [dim]({item['image']})[/dim]"
202+
203+
204+
def _select_sandbox_for_ssh(sandbox_client: SandboxClient) -> str:
205+
"""Let the user pick a running sandbox to SSH into when no ID is given."""
206+
# Page through every running sandbox before filtering, so SSH-able containers
207+
# on later pages aren't dropped when a user has many running sandboxes.
208+
sandboxes: List[Sandbox] = []
209+
with console.status("[bold blue]Loading sandboxes...", spinner="dots"):
210+
page = 1
211+
while True:
212+
sandbox_list = sandbox_client.list(status="RUNNING", per_page=100, page=page)
213+
sandboxes.extend(sandbox_list.sandboxes)
214+
if not sandbox_list.has_next:
215+
break
216+
page += 1
217+
218+
# SSH is only supported for non-VM sandboxes (see _guard_vm_unsupported).
219+
items = [
220+
{"id": sb.id, "name": sb.name, "image": sb.docker_image}
221+
for sb in sort_by_created(sandboxes)
222+
if not sb.vm
223+
]
224+
225+
selected = require_selection(
226+
items,
227+
"SSH into",
228+
"No running sandboxes available to SSH into.",
229+
item_type="sandbox",
230+
display_fn=_ssh_sandbox_display,
231+
page_size=50,
232+
)
233+
return str(selected.get("id"))
234+
235+
198236
@app.command("list", epilog=LIST_SANDBOXES_JSON_HELP)
199237
def list_sandboxes_cmd(
200238
team_id: Optional[str] = typer.Option(
@@ -236,7 +274,7 @@ def list_sandboxes_cmd(
236274
)
237275

238276
table = build_table(
239-
f"Code Sandboxes (Total: {sandbox_list.total})",
277+
f"Sandboxes (Total: {sandbox_list.total})",
240278
[
241279
("ID", "cyan"),
242280
("Name", "blue"),
@@ -1443,9 +1481,11 @@ def list_ports(
14431481
raise typer.Exit(1)
14441482

14451483

1446-
@app.command("ssh", no_args_is_help=True)
1484+
@app.command("ssh")
14471485
def ssh_connect(
1448-
sandbox_id: str = typer.Argument(..., help="Sandbox ID to SSH into"),
1486+
sandbox_id: Optional[str] = typer.Argument(
1487+
None, help="Sandbox ID to SSH into (interactive selection if not provided)"
1488+
),
14491489
ssh_args: Optional[List[str]] = typer.Argument(
14501490
None, help="Additional SSH arguments (e.g., -- -v for verbose)"
14511491
),
@@ -1459,9 +1499,11 @@ def ssh_connect(
14591499
"""Connect to a sandbox via SSH.
14601500
14611501
This command creates a SSH session with an ephemeral key and cleans up on disconnect.
1502+
Run without a sandbox ID to pick from your running sandboxes interactively.
14621503
14631504
\b
14641505
Examples:
1506+
prime sandbox ssh
14651507
prime sandbox ssh sb_abc123
14661508
prime sandbox ssh sb_abc123 --shell bash
14671509
prime sandbox ssh sb_abc123 -- -L 3000:localhost:3000
@@ -1473,7 +1515,7 @@ def ssh_connect(
14731515

14741516
def cleanup() -> None:
14751517
"""Clean up the SSH session and temporary keys."""
1476-
if session_id and sandbox_client:
1518+
if session_id and sandbox_client and sandbox_id:
14771519
try:
14781520
console.print("\n[bold blue]Cleaning up SSH session...[/bold blue]")
14791521
sandbox_client.close_ssh_session(sandbox_id, session_id)
@@ -1495,6 +1537,10 @@ def cleanup() -> None:
14951537
base_client = APIClient()
14961538
sandbox_client = SandboxClient(base_client)
14971539

1540+
# Pick a sandbox interactively when no ID was provided
1541+
if sandbox_id is None:
1542+
sandbox_id = _select_sandbox_for_ssh(sandbox_client)
1543+
14981544
# Check if sandbox is running
14991545
with console.status("[bold blue]Checking sandbox status...", spinner="dots"):
15001546
sandbox = sandbox_client.get(sandbox_id)

packages/prime/src/prime_cli/utils/prompt.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import Any, Callable, Dict, List, Optional
33

44
import typer
5+
from rich.markup import escape
56

67
from .plain import get_console
78

@@ -46,12 +47,15 @@ def require_selection(
4647
empty_message: str,
4748
item_type: str = "item",
4849
display_fn: Optional[Callable[[Dict[str, Any]], str]] = None,
50+
page_size: Optional[int] = None,
4951
) -> Dict[str, Any]:
5052
if not items:
5153
console.print(f"[yellow]{empty_message}[/yellow]")
5254
raise typer.Exit()
5355

54-
selected = select_item_interactive(items, action, item_type=item_type, display_fn=display_fn)
56+
selected = select_item_interactive(
57+
items, action, item_type=item_type, display_fn=display_fn, page_size=page_size
58+
)
5559
if not selected:
5660
console.print("\n[dim]Cancelled.[/dim]")
5761
raise typer.Exit()
@@ -64,6 +68,7 @@ def select_item_interactive(
6468
action: str = "select",
6569
item_type: str = "item",
6670
display_fn: Optional[Callable[[Dict[str, Any]], str]] = None,
71+
page_size: Optional[int] = None,
6772
) -> Optional[Dict[str, Any]]:
6873
"""Display items and let user select one interactively.
6974
@@ -73,6 +78,8 @@ def select_item_interactive(
7378
item_type: Type of item being selected (e.g., "secret", "variable")
7479
display_fn: Function to format each item for display.
7580
Defaults to showing 'name' and 'description' fields.
81+
page_size: When set and there are more items than this, show them
82+
page_size at a time with [n]ext / [p]rev navigation.
7683
7784
Returns:
7885
Selected item or None if cancelled
@@ -82,6 +89,9 @@ def select_item_interactive(
8289

8390
formatter = display_fn or _default_display_fn
8491

92+
if page_size is not None and len(items) > page_size:
93+
return _select_item_paged(items, action, item_type, formatter, page_size)
94+
8595
console.print(f"\n[bold]Select a {item_type} to {action}:[/bold]\n")
8696
for i, item in enumerate(items, 1):
8797
console.print(f" {i}. {formatter(item)}")
@@ -102,6 +112,72 @@ def select_item_interactive(
102112
return None
103113

104114

115+
def _select_item_paged(
116+
items: List[Dict[str, Any]],
117+
action: str,
118+
item_type: str,
119+
formatter: Callable[[Dict[str, Any]], str],
120+
page_size: int,
121+
) -> Optional[Dict[str, Any]]:
122+
"""Paged variant of the interactive selector with next/prev navigation.
123+
124+
Selection numbers are global (1..len(items)) — the number printed next to an
125+
item is what you type, on any page.
126+
"""
127+
total = len(items)
128+
total_pages = (total + page_size - 1) // page_size
129+
page = 0
130+
131+
while True:
132+
start = page * page_size
133+
end = min(start + page_size, total)
134+
135+
console.print(
136+
f"\n[bold]Select a {item_type} to {action} "
137+
f"(page {page + 1}/{total_pages}):[/bold]\n"
138+
)
139+
for i in range(start, end):
140+
console.print(f" {i + 1}. {formatter(items[i])}")
141+
142+
nav = []
143+
if page + 1 < total_pages:
144+
nav.append("[n] next page")
145+
if page > 0:
146+
nav.append("[p] prev page")
147+
console.print(f"\n[dim]{escape(' '.join(nav))}[/dim]\n")
148+
149+
try:
150+
choice = typer.prompt("Select (empty to cancel)", default="").strip()
151+
except KeyboardInterrupt:
152+
return None
153+
154+
if not choice:
155+
return None
156+
157+
lowered = choice.lower()
158+
if lowered == "n":
159+
if page + 1 < total_pages:
160+
page += 1
161+
else:
162+
console.print("[red]Already on the last page[/red]")
163+
continue
164+
if lowered == "p":
165+
if page > 0:
166+
page -= 1
167+
else:
168+
console.print("[red]Already on the first page[/red]")
169+
continue
170+
171+
try:
172+
selection = int(choice)
173+
except ValueError:
174+
console.print("[red]Please enter a number, or 'n'/'p' to navigate[/red]")
175+
continue
176+
if 1 <= selection <= total:
177+
return items[selection - 1]
178+
console.print(f"[red]Please enter a number between 1 and {total}[/red]")
179+
180+
105181
def prompt_for_value(
106182
prompt_text: str,
107183
required: bool = True,

0 commit comments

Comments
 (0)