mirror of
https://github.com/usestrix/strix.git
synced 2026-09-08 22:21:05 +00:00
Improve cloud CLI human list views
This commit is contained in:
parent
bee88d95d2
commit
b074e0f632
4 changed files with 336 additions and 8 deletions
|
|
@ -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`:
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue