Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions packages/prime/src/prime_cli/commands/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,12 @@ def tar_filter(tarinfo: tarfile.TarInfo) -> Optional[tarfile.TarInfo]:
@app.command("list", epilog=LIST_IMAGES_JSON_HELP)
def list_images(
output: str = typer.Option("table", "--output", "-o", help="Output format (table or json)"),
search: Optional[str] = typer.Option(
None,
"--search",
"-q",
help="Case-insensitive substring match on image name, tag, or reference",
),
all_images: bool = typer.Option(
False, "--all", "-a", help="[Deprecated] Show all accessible images (personal + team)"
),
Expand All @@ -626,9 +632,14 @@ def list_images(

Shows personal images by default, or team images when a team is configured.

The --search filter is applied server-side across all your images (not just
the current page), and pagination reflects the filtered results.

\b
Examples:
prime images list
prime images list --search myapp
prime images list -q nvidia
prime images list --num 100
prime images list --page 2
prime images list --output json
Expand Down Expand Up @@ -657,6 +668,8 @@ def list_images(
params: dict[str, str] = {"limit": str(num), "offset": str(offset)}
if config.team_id:
params["teamId"] = config.team_id
if search:
params["search"] = search

response = client.request("GET", "/images", params=params)
images: list[ImageRow] = response.get("data", [])
Expand All @@ -669,8 +682,14 @@ def list_images(

if not images:
if has_total_count and total_count == 0:
console.print("[yellow]No images or builds found.[/yellow]")
console.print("Push an image with: [bold]prime images push <name>:<tag>[/bold]")
if search:
console.print(f"[yellow]No images match '{search}'.[/yellow]")
console.print(
"Try a different search term or run without [bold]--search[/bold]."
)
else:
console.print("[yellow]No images or builds found.[/yellow]")
console.print("Push an image with: [bold]prime images push <name>:<tag>[/bold]")
elif has_total_count:
console.print(
f"[yellow]No images on page {page}. Total: {total_count} image(s).[/yellow]"
Expand All @@ -679,6 +698,9 @@ def list_images(
elif page > 1:
console.print(f"[yellow]No images on page {page}.[/yellow]")
console.print("Try [bold]--page 1[/bold] to start from the beginning.")
elif search:
console.print(f"[yellow]No images match '{search}'.[/yellow]")
console.print("Try a different search term or run without [bold]--search[/bold].")
else:
console.print("[yellow]No images or builds found.[/yellow]")
console.print("Push an image with: [bold]prime images push <name>:<tag>[/bold]")
Expand Down
115 changes: 115 additions & 0 deletions packages/prime/tests/test_images_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,121 @@ def test_list_cli_truncates_owner_prefix_on_narrow_terminal(run_images_list):
assert f"{USER_ID}/nvidia-basic-dev" not in result.output


def _run_list_capturing_params(
monkeypatch,
args: list[str],
*,
payload: list[ImageRow] | None = None,
team_id: str | None = None,
include_total_count: bool = True,
):
"""Invoke ``prime images list`` with extra CLI args, capturing the query
params forwarded to the API request.

When ``include_total_count`` is False the stubbed response omits
``totalCount`` entirely, mirroring a backend that does not report it (the
CLI keeps defensive branches for that case).
"""
captured: dict[str, Any] = {}

class DummyAPIClient:
def request(self, method, path, json=None, params=None):
assert method == "GET"
assert path == "/images"
captured["params"] = dict(params or {})
rows = payload or []
response: dict[str, Any] = {"data": rows}
if include_total_count:
response["totalCount"] = len(rows)
return response

monkeypatch.setattr("prime_cli.main.check_for_update", lambda: (False, None))
monkeypatch.setattr(images_cmd, "config", _StubConfig(team_id=team_id))
monkeypatch.setattr("prime_cli.commands.images.APIClient", DummyAPIClient)
result = CliRunner().invoke(app, ["images", "list", *args], env=TEST_ENV)
return result, captured


def test_list_forwards_search_param(monkeypatch):
result, captured = _run_list_capturing_params(
monkeypatch,
["--search", "myapp"],
payload=[_container(image="myapp:latest", pushed_at="2026-04-16T22:24:07")],
)
assert result.exit_code == 0, result.output
assert captured["params"].get("search") == "myapp"


def test_list_search_short_flag_q_forwards_param(monkeypatch):
result, captured = _run_list_capturing_params(
monkeypatch,
["-q", "nvidia"],
payload=[_container(pushed_at="2026-04-16T22:24:07")],
)
assert result.exit_code == 0, result.output
assert captured["params"].get("search") == "nvidia"


def test_list_without_search_omits_param(monkeypatch):
result, captured = _run_list_capturing_params(
monkeypatch,
[],
payload=[_container(pushed_at="2026-04-16T22:24:07")],
)
assert result.exit_code == 0, result.output
assert "search" not in captured["params"]


def test_list_empty_search_result_shows_search_aware_message(monkeypatch):
result, captured = _run_list_capturing_params(
monkeypatch,
["--search", "doesnotexist"],
payload=[],
)
assert result.exit_code == 0, result.output
assert captured["params"].get("search") == "doesnotexist"
assert "No images match 'doesnotexist'" in result.output


def test_list_search_out_of_range_page_without_total_count_shows_page_guidance(monkeypatch):
# Empty page > 1 with a search and no totalCount is ambiguous (likely
# out-of-range), so the page guidance must win — not a "no matches" claim.
result, _ = _run_list_capturing_params(
monkeypatch,
["--search", "myapp", "--page", "2"],
payload=[],
include_total_count=False,
)
assert result.exit_code == 0, result.output
assert "No images on page 2" in result.output
assert "No images match" not in result.output


def test_list_search_first_page_without_total_count_shows_no_matches(monkeypatch):
# First page empty with a search and no totalCount means no matches exist.
result, _ = _run_list_capturing_params(
monkeypatch,
["--search", "myapp"],
payload=[],
include_total_count=False,
)
assert result.exit_code == 0, result.output
assert "No images match 'myapp'" in result.output


def test_list_out_of_range_page_without_total_count_no_search(monkeypatch):
# Regression guard for the original (pre-search) behaviour: empty page > 1,
# no totalCount, no search still shows the page guidance.
result, _ = _run_list_capturing_params(
monkeypatch,
["--page", "3"],
payload=[],
include_total_count=False,
)
assert result.exit_code == 0, result.output
assert "No images on page 3" in result.output


def test_list_cli_newest_group_first(run_images_list):
result = run_images_list(
[
Expand Down
Loading