Improve cloud CLI human list views

This commit is contained in:
bearsyankees 2026-08-28 10:54:58 -04:00
parent bee88d95d2
commit b074e0f632
4 changed files with 336 additions and 8 deletions

View file

@ -364,7 +364,7 @@ strix cloud domains verify <domain-id> # prints the DNS record to add
The last four commands end at a person. Strix creates the link, opens the browser for an interactive terminal, and always prints the URL. The user enters the card, approves the installation, or adds the DNS record. Pass `--no-browser` to print the URL only.
The commands work for humans and agents: terminal output favors names, branches, states, and numbered selectors, while redirected output (or `--json`) preserves complete machine-readable records and IDs. Binary downloads are the exception: intentionally redirect their raw bytes, or use `--output FILE --json` to write the file and receive structured download metadata. There are no prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication or plan limit, `5` payment required. Set the token with `--token` or `STRIX_API_TOKEN` to skip the stored sign-in.
The commands work for humans and agents: terminal output favors names, branches, lifecycle states, and numbered selectors, while redirected output (or `--json`) preserves complete machine-readable records and IDs. Human asset, knowledge, test-user, and token lists retain the record IDs needed by follow-up commands but omit internal organization/user IDs; token lists label credentials as active, expired, or revoked. Binary downloads are the exception: intentionally redirect their raw bytes, or use `--output FILE --json` to write the file and receive structured download metadata. There are no prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication or plan limit, `5` payment required. Set the token with `--token` or `STRIX_API_TOKEN` to skip the stored sign-in.
Write commands take request fields as flags, and every write command also accepts one JSON object with `--data`:

View file

@ -16,7 +16,7 @@ There are two equivalent interfaces. Prefer the CLI:
- **`strix cloud` CLI** — every REST operation has a command in the form `strix cloud <resource> <verb>`. Install with `curl -sSL https://strix.ai/install | bash`. Run `strix cloud` to list all resources and `strix cloud <resource>` to list its verbs.
- **REST API** — base URL `https://app.strix.ai/api/v1`, `Authorization: Bearer <token>` on every request. Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · agent index: `https://docs.app.strix.ai/llms.txt` · OpenAPI: `https://docs.app.strix.ai/openapi.json`.
The CLI is equally usable by agents and people. Output is complete JSON when stdout is not a terminal, or when you pass `--json`; terminal tables favor names, branches, states, and numbered selectors. Binary downloads are the exception: redirect raw bytes intentionally, or use `--output FILE --json` to write the file and receive structured metadata. There are no interactive prompts when stdin is not a terminal. Exit codes: `0` success, `1` request/runtime error, `2` invalid usage, `4` authentication or plan limit, `5` payment required.
The CLI is equally usable by agents and people. Output is complete JSON when stdout is not a terminal, or when you pass `--json`; terminal tables favor names, branches, lifecycle states, and numbered selectors. Human asset, knowledge, test-user, and token lists retain the record IDs needed by follow-up commands but omit internal organization/user IDs; token lists label credentials as active, expired, or revoked. Binary downloads are the exception: redirect raw bytes intentionally, or use `--output FILE --json` to write the file and receive structured metadata. There are no interactive prompts when stdin is not a terminal. Exit codes: `0` success, `1` request/runtime error, `2` invalid usage, `4` authentication or plan limit, `5` payment required.
Every resource group has a useful default list/read action, and `-h` or `help` always shows its verbs. Native tab completion includes resources, verbs, flags, workspace commands, and local paths:

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import json
import re
import sys
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, TypeGuard
from rich.markup import escape
@ -31,8 +32,11 @@ _INTERNAL_COLUMNS = frozenset(
"user_id",
"userId",
"installation_id",
"added_by",
"created_by",
"connected_by",
"invited_by",
"uploaded_by",
"avatarUrl",
}
)
@ -119,6 +123,7 @@ _LIST_ENVELOPE_KEYS = frozenset(
"deliveries",
"entries",
"documents",
"docs",
"policies",
"tokens",
"uploads",
@ -146,6 +151,7 @@ _ENVELOPE_METADATA_KEYS = frozenset(
"summary",
"stats",
"scansThisMonth",
"organization_id",
}
)
@ -190,6 +196,75 @@ _VIEW_COLUMNS: dict[str, tuple[str, ...]] = {
"default_collection_name",
"connected_at",
),
"GET /domains": (
"domain",
"asset_type",
"verified",
"last_scan_at",
"context",
"tags",
"business_unit",
"id",
),
"GET /repositories": (
"full_name",
"provider",
"default_branch",
"pr_review_enabled",
"last_scan_at",
"tags",
"business_unit",
"id",
),
"GET /knowledge": (
"title",
"source_type",
"source_id",
"tags",
"severity",
"status",
"updated_at",
"id",
),
"GET /knowledge/repos/{repo}/entries": (
"title",
"source_type",
"source_id",
"tags",
"severity",
"status",
"updated_at",
"id",
),
"GET /knowledge/policies": (
"policy_key",
"policy_type",
"is_active",
"policy_value",
"updated_at",
"created_at",
"id",
),
"GET /domains/{domainId}/test-users": (
"label",
"username",
"mfa_method",
"mfa_email",
"has_password",
"login_url",
"updated_at",
"id",
),
"GET /tokens": (
"name",
"type",
"status",
"scopes",
"secret_prefix",
"expires_at",
"last_used_at",
"id",
),
"GET /webhooks": ("url", "events", "is_active", "last_delivery_at", "created_at", "id"),
"GET /webhooks/{webhookId}/deliveries": (
"event",
@ -253,6 +328,18 @@ def emit( # noqa: PLR0911, PLR0912
view=view,
)
return
if view == "GET /tokens":
token_rows = _token_rows(data)
if token_rows is not None:
_print_table(
console,
token_rows,
row_numbers=row_numbers,
omit_columns=omit_columns,
hint=hint,
view=view,
)
return
if view == "GET /scans":
scan_rows = _scan_rows(data)
if scan_rows is not None:
@ -364,6 +451,36 @@ def _integration_rows(data: Any) -> list[dict[str, Any]] | None:
return rows if found_collection else None
def _token_rows(data: Any) -> list[dict[str, Any]] | None:
"""Add an explicit lifecycle state to token rows for the human view."""
records = _list_of_dicts(data)
if records is None:
return None
rows: list[dict[str, Any]] = []
for record in records:
row = dict(record)
if record.get("revoked_at"):
row["status"] = "revoked"
elif _timestamp_has_passed(record.get("expires_at")):
row["status"] = "expired"
else:
row["status"] = "active"
rows.append(row)
return rows
def _timestamp_has_passed(value: Any) -> bool:
if not isinstance(value, str) or not value.strip():
return False
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed <= datetime.now(UTC)
def _scan_rows(data: Any) -> list[dict[str, Any]] | None:
"""Flatten the nested target and finding summaries returned by scan lists."""
records = _list_of_dicts(data)
@ -470,6 +587,7 @@ def _print_table(
columns = columns[:_MAX_TABLE_COLUMNS]
if console.width < _NARROW_TABLE_WIDTH:
_print_cards(console, rows, columns, row_numbers=row_numbers)
_print_long_identifiers(console, rows)
console.print(f"[dim]{len(rows)} item(s). Use --json for the full records.[/]")
if hint:
console.print(f"[dim]{escape(sanitize_terminal_text(hint))}[/]")
@ -485,6 +603,7 @@ def _print_table(
cells.insert(0, str(index))
table.add_row(*cells)
console.print(table)
_print_long_identifiers(console, rows)
console.print(f"[dim]{len(rows)} item(s). Use --json for the full records.[/]")
if hint:
console.print(f"[dim]{escape(sanitize_terminal_text(hint))}[/]")
@ -514,6 +633,32 @@ def _print_cards(
console.print(continuation + part, soft_wrap=True)
def _print_long_identifiers(console: Console, rows: list[dict[str, Any]]) -> None:
"""Print opaque IDs losslessly when the compact cell view shortens them."""
identifiers = [
(index, row, str(row["id"]))
for index, row in enumerate(rows, start=1)
if row.get("id") is not None and len(str(row["id"])) > _MAX_CELL_LENGTH
]
if not identifiers:
return
console.print("[dim]Copyable IDs:[/]")
for index, row, identifier in identifiers:
label = next(
(
str(row[key])
for key in ("title", "name", "label", "domain", "full_name")
if row.get(key)
),
f"item {index}",
)
console.print(
f" {index}. {sanitize_terminal_text(label)}: {sanitize_terminal_text(identifier)}",
markup=False,
soft_wrap=True,
)
def _print_detail(console: Console, data: dict[str, Any]) -> None:
"""Render one API record as a readable field/value view."""
keys = [key for key in _PREFERRED_KEYS if key in data and key not in _INTERNAL_COLUMNS]

View file

@ -1019,6 +1019,9 @@ def test_render_list_extraction() -> None:
rows = render._list_of_dicts({"scans": [{"id": "a"}, {"id": "b"}]})
assert rows == [{"id": "a"}, {"id": "b"}]
assert render._list_of_dicts({"scans": [], "total": 1}) == []
assert render._list_of_dicts(
{"organization_id": "org_1", "docs": [{"id": "doc_1"}], "total": 1}
) == [{"id": "doc_1"}]
assert render._list_of_dicts([{"id": "a"}, "x"]) is None
@ -1299,17 +1302,13 @@ def test_corrected_help_distinguishes_inboxes_reports_and_self_hosted_commands()
assert "magic_link" in parameters["mfa_method"]
assert " or email." not in parameters["mfa_method"]
vulnerability_update = {
param.name: param.help for param in SPEC["vulns"]["update"].body
}
vulnerability_update = {param.name: param.help for param in SPEC["vulns"]["update"].body}
assert "in_progress" in vulnerability_update["status"]
assert "not_affected" in vulnerability_update["status"]
assert "triaged" not in vulnerability_update["status"]
assert "false_positive" not in vulnerability_update["status"]
chat_download = {
param.name: param.help for param in SPEC["chat"]["files download"].query
}
chat_download = {param.name: param.help for param in SPEC["chat"]["files download"].query}
assert "Relative path" in chat_download["path"]
assert "/workspace" in chat_download["path"]
@ -1923,6 +1922,190 @@ def test_pr_review_human_list_prioritizes_actionable_fields(
assert value not in output
@pytest.mark.parametrize(
("command", "payload", "visible", "hidden"),
[
(
["domains", "list"],
{
"items": [
{
"id": "domain-id",
"organization_id": "org-secret",
"domain": "staging.example.com",
"asset_type": "web_app",
"verified": True,
"context": "staging",
"tags": ["customer-facing"],
"business_unit": "product",
"last_scan_at": "2026-08-27T12:00:00Z",
"added_by": "user-secret",
}
],
"meta": {"total_items": 1},
},
("staging.example.com", "web_app", "yes", "staging", "domain-id"),
("org-secret", "user-secret", "added by"),
),
(
["repos", "list"],
{
"items": [
{
"id": "repo-id",
"organization_id": "org-secret",
"full_name": "usestrix/strix",
"provider": "github",
"default_branch": "main",
"pr_review_enabled": True,
"tags": ["core"],
"business_unit": "product",
"last_scan_at": "2026-08-27T12:00:00Z",
"added_by": "user-secret",
}
],
"meta": {"total_items": 1},
},
("usestrix/strix", "github", "main", "yes", "repo-id"),
("org-secret", "user-secret", "added by"),
),
(
["knowledge", "list"],
{
"organization_id": "org-secret",
"docs": [
{
"id": (
"doc-id-that-is-deliberately-long-enough-to-require-a-lossless-"
"copyable-value"
),
"organization_id": "org-secret",
"title": "Authentication",
"source_type": "manual",
"source_id": "dashboard/notes/auth.md",
"content": "Long private content should stay out of the list.",
"tags": ["auth"],
"severity": None,
"status": None,
"updated_at": "2026-08-27T12:00:00Z",
}
],
"total": 1,
},
(
"Authentication",
"manual",
"dashboard/notes/auth.md",
"doc-id-that-is-deliberately-long-enough-to-require-a-lossless-copyable-value",
"Copyable IDs",
),
("org-secret", "Long private content"),
),
(
["domains", "test-users", "list", "domain-id"],
{
"items": [
{
"id": "test-user-id",
"organization_id": "org-secret",
"domain_id": "domain-id",
"label": "Staging admin",
"username": "admin@example.com",
"mfa_method": "email_otp",
"mfa_email": "inbox@security-mail.strix.ai",
"has_password": True,
"login_url": "https://staging.example.com/login",
"updated_at": "2026-08-27T12:00:00Z",
"created_by": "user-secret",
}
],
"agentmail_configured": True,
},
(
"Staging admin",
"admin@example.com",
"email_otp",
"inbox@security-mail.strix.ai",
"test-user-id",
),
("org-secret", "user-secret", "domain id"),
),
],
)
def test_human_lists_prioritize_actionable_fields(
monkeypatch: pytest.MonkeyPatch,
capsys: Any,
command: list[str],
payload: dict[str, Any],
visible: tuple[str, ...],
hidden: tuple[str, ...],
) -> None:
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload))
assert cloud.run_cloud(command) == 0
output = capsys.readouterr().out
for value in visible:
assert value in output
for value in hidden:
assert value not in output
def test_token_human_list_shows_lifecycle_status(
monkeypatch: pytest.MonkeyPatch, capsys: Any
) -> None:
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr(
http,
"request",
lambda *_a, **_k: FakeResponse(
payload={
"tokens": [
{
"id": "active-id",
"organization_id": "org-secret",
"name": "Active CI",
"type": "service",
"scopes": ["scans:read"],
"secret_prefix": "strix_svc_a",
"expires_at": "2099-01-01T00:00:00Z",
"last_used_at": None,
"revoked_at": None,
},
{
"id": "revoked-id",
"organization_id": "org-secret",
"name": "Old CI",
"type": "service",
"scopes": ["scans:read"],
"secret_prefix": "strix_svc_r",
"expires_at": None,
"last_used_at": None,
"revoked_at": "2026-08-27T12:00:00Z",
},
{
"id": "expired-id",
"organization_id": "org-secret",
"name": "Expired CI",
"type": "service",
"scopes": ["scans:read"],
"secret_prefix": "strix_svc_e",
"expires_at": "2000-01-01T00:00:00Z",
"last_used_at": None,
"revoked_at": None,
},
]
}
),
)
assert cloud.run_cloud(["tokens", "list"]) == 0
output = capsys.readouterr().out
for value in ("Active CI", "active", "Old CI", "revoked", "Expired CI", "expired"):
assert value in output
assert "org-secret" not in output
def test_human_get_prioritizes_details_and_hides_internal_identity_fields(
monkeypatch: pytest.MonkeyPatch, capsys: Any
) -> None: