feat(cli): manage workspaces and hosted onboarding links from strix cloud

This commit is contained in:
Alex Schapiro 2026-08-26 23:01:36 +00:00
parent 56008a6c50
commit 8d66c66968
10 changed files with 333 additions and 3 deletions

View file

@ -44,6 +44,7 @@ Target-specific workflows built on the same engine:
strix cloud vulns list --severity critical strix cloud vulns list --severity critical
strix cloud billing topup --credits 20 # buy credits when a scan returns exit code 5 strix cloud billing topup --credits 20 # buy credits when a scan returns exit code 5
``` ```
- Account setup runs from the CLI too: `strix cloud workspaces list|create|use`, `strix cloud org members invite`, `strix cloud billing subscribe --plan strix_pro`, `strix cloud billing portal`, `strix cloud integrations install github`, and `strix cloud domains verify <id>`. The last four end at a person: the command prints a link or a DNS record for the user to open or add, and it never completes the payment, the installation, or the DNS change for them.
- Every REST operation has a `strix cloud <resource> <verb>` command. Run `strix cloud` to list them. Output is JSON when stdout is not a terminal (or with `--json`), and there are no prompts without a TTY. Exit codes: `0` success, `1` error, `2` usage, `4` auth or plan limit, `5` payment required. `--token` or `STRIX_API_TOKEN` overrides the stored sign-in. `--data` adds extra request fields as JSON, and accepts `@file` or `-` for standard input. - Every REST operation has a `strix cloud <resource> <verb>` command. Run `strix cloud` to list them. Output is JSON when stdout is not a terminal (or with `--json`), and there are no prompts without a TTY. Exit codes: `0` success, `1` error, `2` usage, `4` auth or plan limit, `5` payment required. `--token` or `STRIX_API_TOKEN` overrides the stored sign-in. `--data` adds extra request fields as JSON, and accepts `@file` or `-` for standard input.
- The REST API works directly too: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json). - The REST API works directly too: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json).

View file

@ -344,6 +344,20 @@ strix cloud credits # credit balance
strix cloud billing topup --credits 20 # buy credits (agent payment, HTTP 402) strix cloud billing topup --credits 20 # buy credits (agent payment, HTTP 402)
``` ```
Workspaces and account setup also work from the terminal:
```bash
strix cloud workspaces list # your workspaces
strix cloud workspaces create --name "My Team"
strix cloud workspaces use "My Team" # store a token for another workspace
strix cloud billing subscribe --plan strix_pro # opens the hosted checkout page
strix cloud billing portal # opens the billing portal
strix cloud integrations install github # opens the app installation page
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 are agent friendly. Output is JSON when stdout is not a terminal or when you pass `--json`. There are no prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication required, `5` payment required. Set the token with `--token` or `STRIX_API_TOKEN` to skip the stored sign-in. The commands are agent friendly. Output is JSON when stdout is not a terminal or when you pass `--json`. There are no prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication required, `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`: Write commands take request fields as flags, and every write command also accepts one JSON object with `--data`:

View file

@ -43,7 +43,7 @@ Both use the same engine and produce the same validated findings and SARIF, so a
Everything an agent needs is machine-readable: Everything an agent needs is machine-readable:
- **Headless CLI** — `strix -n` runs without the TUI and exits with `0` (clean), `1` (error), or `2` (vulnerabilities found). - **Headless CLI** — `strix -n` runs without the TUI and exits with `0` (clean), `1` (error), or `2` (vulnerabilities found).
- **Cloud CLI** — `strix cloud` prints JSON when stdout is not a terminal (or with `--json`), never prompts without a TTY, and exits with `0` (success), `1` (error), `2` (usage), `4` (authentication required), or `5` (payment required). Credit top-ups pay the Stripe machine-payment challenge with an agent wallet (`strix cloud billing topup --credits N --yes`). - **Cloud CLI** — `strix cloud` prints JSON when stdout is not a terminal (or with `--json`), never prompts without a TTY, and exits with `0` (success), `1` (error), `2` (usage), `4` (authentication required), or `5` (payment required). Credit top-ups pay the Stripe machine-payment challenge with an agent wallet (`strix cloud billing topup --credits N --yes`). Account setup also runs from the CLI: `strix cloud workspaces list|create|use`, `strix cloud org members invite`, `strix cloud billing subscribe`, `strix cloud billing portal`, and `strix cloud integrations install github`. The last three print a hosted link the user opens to finish the payment or approve the installation.
- **REST API** — the managed platform exposes a documented [OpenAPI](https://docs.app.strix.ai/openapi.json) at `https://app.strix.ai/api/v1` (scans, vulnerabilities, assets, PR reviews, schedules, webhooks) with bearer tokens and scopes. - **REST API** — the managed platform exposes a documented [OpenAPI](https://docs.app.strix.ai/openapi.json) at `https://app.strix.ai/api/v1` (scans, vulnerabilities, assets, PR reviews, schedules, webhooks) with bearer tokens and scopes.
- **Structured results** — every run writes `vulnerabilities.json`, `vulnerabilities.csv`, `findings.sarif` (SARIF 2.1.0), and per-finding Markdown under `strix_runs/<run-name>/`; the cloud exposes the same as JSON plus SARIF export. - **Structured results** — every run writes `vulnerabilities.json`, `vulnerabilities.csv`, `findings.sarif` (SARIF 2.1.0), and per-finding Markdown under `strix_runs/<run-name>/`; the cloud exposes the same as JSON plus SARIF export.
- **Budget controls** — `--max-budget` and `--max-turns` give agents hard cost/time caps. - **Budget controls** — `--max-budget` and `--max-turns` give agents hard cost/time caps.

View file

@ -229,6 +229,7 @@ ignore = [
# Test doubles use fixture tokens/passwords and match a callee signature whose # Test doubles use fixture tokens/passwords and match a callee signature whose
# args they intentionally ignore. # args they intentionally ignore.
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"] "tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
"tests/test_cloud_cli.py" = ["S105", "ARG001"]
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"] "tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
# Hatchling loads the build hook by path, not as an importable package. # Hatchling loads the build hook by path, not as an importable package.
"scripts/tui_sidecar_hook.py" = ["INP001"] "scripts/tui_sidecar_hook.py" = ["INP001"]

View file

@ -81,6 +81,32 @@ strix cloud billing auto-topup update --enabled --topup-credits 20 --monthly-cap
An omitted `--monthly-cap-credits` keeps the stored cap. Pass `--no-monthly-cap` to remove the cap. An omitted `--monthly-cap-credits` keeps the stored cap. Pass `--no-monthly-cap` to remove the cap.
### Workspaces and account setup
Manage workspaces with a personal token from `strix cloud login`:
```bash
strix cloud workspaces list # id, name, role, and the active one
strix cloud workspaces create --name "My Team"
strix cloud workspaces use "My Team" # store a token for that workspace
strix cloud org members invite --email dev@example.com --role analyst
```
`workspaces use` mints a new token for a workspace the user already belongs to, and the role in that workspace limits the scopes. Add `--scopes` to request a smaller set.
### Handoffs a person must finish
Four steps end at the user. The command creates the link or the record and prints it. Strix opens the browser only in an interactive terminal. Pass `--no-browser` to print the URL only.
```bash
strix cloud billing subscribe --plan strix_pro # hosted checkout page for a plan
strix cloud billing portal # billing portal for the card and the plan
strix cloud integrations install github # GitHub App or Slack installation page
strix cloud domains verify <domain-id> # DNS record to add, then run it again
```
Give the printed URL or DNS record to the user and wait. Do not claim that the payment, the installation, or the DNS change is complete. Confirm the result afterwards with `strix cloud credits`, `strix cloud integrations list`, or `strix cloud domains list`. All four commands need an admin token, except `domains verify`, which needs `assets:write`.
## 1. Register the target as an asset ## 1. Register the target as an asset
Scans run against **registered assets**, not raw URLs. Register once, then reuse the returned UUID. Scans run against **registered assets**, not raw URLs. Register once, then reuse the returned UUID.

View file

@ -12,6 +12,7 @@ from rich.console import Console
from strix.interface.cloud.runner import resolve, run from strix.interface.cloud.runner import resolve, run
from strix.interface.cloud.spec import DEFAULT_VERBS, GROUP_HELP, SPEC from strix.interface.cloud.spec import DEFAULT_VERBS, GROUP_HELP, SPEC
from strix.interface.cloud.workspaces import run_workspace_use
from strix.interface.platform_cli import run_login from strix.interface.platform_cli import run_login
@ -45,6 +46,8 @@ def run_cloud(argv: list[str]) -> int:
return run_login(session_argv[group]) return run_login(session_argv[group])
if group == "credits": if group == "credits":
group, rest = "billing", ["credits", *rest] group, rest = "billing", ["credits", *rest]
if group == "workspaces" and rest and rest[0] == "use":
return run_workspace_use(rest[1:])
if group not in SPEC: if group not in SPEC:
console.print(f"[red]Unknown command:[/] {group}") console.print(f"[red]Unknown command:[/] {group}")
@ -70,3 +73,5 @@ def _print_verbs(console: Console, group: str) -> None:
console.print(f"[bold]strix cloud {group}[/] verbs:") console.print(f"[bold]strix cloud {group}[/] verbs:")
for verb, cmd in SPEC[group].items(): for verb, cmd in SPEC[group].items():
console.print(f" {verb:<28}{cmd.help}") console.print(f" {verb:<28}{cmd.help}")
if group == "workspaces":
console.print(f" {'use':<28}Switch the stored token to another workspace.")

View file

@ -13,6 +13,7 @@ import shutil
import subprocess import subprocess
import sys import sys
import time import time
import webbrowser
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
@ -123,10 +124,30 @@ def _execute(
result = _poll(console, path, token=token, as_json=as_json) result = _poll(console, path, token=token, as_json=as_json)
elif cmd.wait_path: elif cmd.wait_path:
result = _wait(console, cmd, result, token=token, as_json=as_json) result = _wait(console, cmd, result, token=token, as_json=as_json)
if cmd.link:
return _handoff_link(console, cmd, args, result, as_json=as_json)
emit(console, result, as_json=as_json) emit(console, result, as_json=as_json)
return http.EXIT_OK return http.EXIT_OK
def _handoff_link(
console: Console, cmd: Cmd, args: argparse.Namespace, result: Any, *, as_json: bool
) -> int:
"""Print a hosted URL a person must open, and open the browser when interactive."""
url = result.get(cmd.link) if isinstance(result, dict) else None
if not isinstance(url, str) or not url:
emit(console, result, as_json=as_json)
return http.EXIT_OK
interactive = sys.stdout.isatty() and not getattr(args, "no_browser", False)
if as_json:
emit(console, result, as_json=True)
else:
console.print(f"Open this URL to continue:\n [bold]{url}[/]")
if interactive:
webbrowser.open(url)
return http.EXIT_OK
def _load_data(value: str) -> dict[str, Any]: def _load_data(value: str) -> dict[str, Any]:
"""Read a JSON object from a literal string, a `@file` path, or `-` for stdin.""" """Read a JSON object from a literal string, a `@file` path, or `-` for stdin."""
if value == "-": if value == "-":
@ -179,6 +200,12 @@ def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentPar
) )
if cmd.binary: if cmd.binary:
parser.add_argument("--output", default=None, metavar="FILE", help="Write to this file.") parser.add_argument("--output", default=None, metavar="FILE", help="Write to this file.")
if cmd.link:
parser.add_argument(
"--no-browser",
action="store_true",
help="Do not open the browser. Print the URL only.",
)
if cmd.wait_path or cmd.wait_self: if cmd.wait_path or cmd.wait_self:
parser.add_argument( parser.add_argument(
"--wait", action="store_true", help="Wait until the operation reaches a final state." "--wait", action="store_true", help="Wait until the operation reaches a final state."

View file

@ -36,6 +36,10 @@ class Cmd:
wait_path: str | None = None wait_path: str | None = None
# When true, `--wait` polls GET on this same path until the status is final. # When true, `--wait` polls GET on this same path until the status is final.
wait_self: bool = False wait_self: bool = False
# Response field that holds a URL a person must open, for example a hosted
# checkout page. The runner opens the browser for an interactive terminal
# and always prints the URL.
link: str | None = None
def _q(*names: str) -> tuple[P, ...]: def _q(*names: str) -> tuple[P, ...]:
@ -498,6 +502,27 @@ SPEC: dict[str, dict[str, Cmd]] = {
"Buy credits with an agent payment (HTTP 402 flow).", "Buy credits with an agent payment (HTTP 402 flow).",
body=(P("credits", "int", required=True, help="Number of credits to buy."),), body=(P("credits", "int", required=True, help="Number of credits to buy."),),
), ),
"subscribe": Cmd(
"POST",
"/billing/checkout",
"Create a checkout link for a plan or a credit pack. A person completes the payment.",
body=(
P(
"product",
required=True,
flag="plan",
help="Product to buy, for example strix_pro, strix_cloud, or strix_top_up.",
),
P("success_url", help="Page to open after the payment."),
),
link="checkout_url",
),
"portal": Cmd(
"POST",
"/billing/portal",
"Create a billing portal link. A person manages the card and the plan there.",
link="portal_url",
),
"auto-topup": Cmd("GET", "/billing/auto-topup", "Get the automatic top-up settings."), "auto-topup": Cmd("GET", "/billing/auto-topup", "Get the automatic top-up settings."),
"auto-topup update": Cmd( "auto-topup update": Cmd(
"PUT", "PUT",
@ -709,6 +734,12 @@ SPEC: dict[str, dict[str, Cmd]] = {
"Validate a Git provider token. The provider is gitlab or bitbucket.", "Validate a Git provider token. The provider is gitlab or bitbucket.",
body=_GIT_TOKEN_BODY, body=_GIT_TOKEN_BODY,
), ),
"install": Cmd(
"POST",
"/integrations/{provider}/install-url",
"Create an installation link. The provider is github or slack. A person approves it.",
link="url",
),
"disconnect": Cmd("DELETE", "/integrations/{provider}", "Disconnect an integration."), "disconnect": Cmd("DELETE", "/integrations/{provider}", "Disconnect an integration."),
}, },
"connectors": { "connectors": {
@ -852,11 +883,21 @@ SPEC: dict[str, dict[str, Cmd]] = {
), ),
"delete": Cmd("DELETE", "/uploads/{uploadId}", "Delete an upload."), "delete": Cmd("DELETE", "/uploads/{uploadId}", "Delete an upload."),
}, },
"workspaces": {
"list": Cmd("GET", "/workspaces", "List the workspaces of your account."),
"create": Cmd(
"POST",
"/workspaces",
"Create a workspace and become its admin.",
body=(P("name", required=True, help="Workspace name."),),
),
},
} }
# Default verbs let a bare group name run its most common read command. # Default verbs let a bare group name run its most common read command.
DEFAULT_VERBS: dict[str, str] = { DEFAULT_VERBS: dict[str, str] = {
"workspaces": "list",
"costs": "overview", "costs": "overview",
"audit": "list", "audit": "list",
"license": "show", "license": "show",
@ -876,7 +917,8 @@ GROUP_HELP: dict[str, str] = {
"chat": "Interactive pentest chat sessions", "chat": "Interactive pentest chat sessions",
"knowledge": "Manage the knowledge base", "knowledge": "Manage the knowledge base",
"org": "Manage the organization and its members", "org": "Manage the organization and its members",
"integrations": "Connect Git providers", "integrations": "Connect Git providers and other integrations",
"workspaces": "List, create, and switch workspaces",
"connectors": "Manage network connectors", "connectors": "Manage network connectors",
"webhooks": "Manage webhooks", "webhooks": "Manage webhooks",
"analytics": "Read analytics data", "analytics": "Read analytics data",

View file

@ -0,0 +1,123 @@
"""`strix cloud workspaces use` — switch the stored token to another workspace.
The command lists the workspaces of the account, finds the requested one by
ID or by exact name, asks the platform for a token in that workspace, and
stores the token in the credential file. The role of the account in the new
workspace limits the granted scopes.
"""
from __future__ import annotations
import argparse
from typing import Any, cast
from rich.console import Console
from strix.interface.cloud import http
from strix.interface.cloud.render import emit, json_mode
from strix.interface.platform_cli import AUTH_PATH, read_record, save_record
def run_workspace_use(argv: list[str]) -> int:
"""Entry point for ``strix cloud workspaces use``. Returns an exit code."""
console = Console()
parser = argparse.ArgumentParser(
prog="strix cloud workspaces use",
description="Switch the stored API token to another workspace.",
)
parser.add_argument("workspace", metavar="WORKSPACE", help="Workspace ID or exact name.")
parser.add_argument(
"--scopes",
nargs="+",
metavar="SCOPE",
default=None,
help="API scopes for the new token. Without this option the server grants the defaults.",
)
parser.add_argument("--json", action="store_true", help="Print the raw JSON response.")
parser.add_argument("--token", default=None, help="API token override.")
parser.add_argument("--app-url", default=None, metavar="URL", help="Platform URL override.")
parser.add_argument(
"--timeout", default=None, type=float, metavar="SECONDS", help="Request timeout in seconds."
)
try:
args = parser.parse_args(argv)
except SystemExit as exc:
return exc.code if isinstance(exc.code, int) else 2
as_json = json_mode(flag=bool(args.json))
http.configure(base_url=args.app_url, timeout=args.timeout)
try:
return _use(console, args, as_json=as_json)
except http.CloudError as exc:
if as_json:
emit(console, {"error": str(exc)}, as_json=True)
else:
console.print(f"[red]Error:[/] {exc}")
return exc.exit_code
def _use(console: Console, args: argparse.Namespace, *, as_json: bool) -> int:
workspace = _find_workspace(args.workspace, token=args.token)
body: dict[str, Any] = {}
if args.scopes:
body["scopes"] = args.scopes
minted = http.check(
http.request(
"POST",
f"/workspaces/{workspace['id']}/token",
token=args.token,
body=body or None,
)
)
if not isinstance(minted, dict) or not minted.get("api_token"):
raise http.CloudError("the platform did not return a token.")
minted_record = cast("dict[str, Any]", minted)
record = read_record() or {}
record.update(
{
"api_token": minted_record["api_token"],
"organization_id": minted_record.get("organization_id", workspace["id"]),
"organization_name": minted_record.get("organization_name", workspace.get("name", "")),
"expires_at": minted_record.get("expires_at"),
"scopes": minted_record.get("scopes", []),
}
)
if minted_record.get("email"):
record["email"] = minted_record["email"]
save_record(record)
result = {
"workspace_id": record["organization_id"],
"workspace_name": record["organization_name"],
"scopes": record["scopes"],
}
if as_json:
emit(console, result, as_json=True)
return http.EXIT_OK
console.print(f"[green]✓ Switched to workspace [bold]{record['organization_name']}[/].[/]")
scopes = record.get("scopes")
if isinstance(scopes, list) and scopes:
console.print(f" Scopes: [dim]{' '.join(str(s) for s in scopes)}[/]")
console.print(f" Token: stored in [dim]{AUTH_PATH}[/]")
return http.EXIT_OK
def _find_workspace(selector: str, *, token: str | None) -> dict[str, Any]:
listed = http.check(http.request("GET", "/workspaces", token=token))
items = listed.get("workspaces") if isinstance(listed, dict) else None
workspaces = [cast("dict[str, Any]", item) for item in (items or []) if isinstance(item, dict)]
if not workspaces:
raise http.CloudError("no workspaces found for this account.")
wanted = selector.strip()
by_id = [w for w in workspaces if w.get("id") == wanted]
if by_id:
return by_id[0]
by_name = [w for w in workspaces if str(w.get("name", "")).casefold() == wanted.casefold()]
if len(by_name) == 1:
return by_name[0]
if len(by_name) > 1:
ids = ", ".join(str(w.get("id")) for w in by_name)
raise http.CloudError(f"multiple workspaces are named {wanted!r}. Use an ID: {ids}")
names = ", ".join(str(w.get("name")) for w in workspaces)
raise http.CloudError(f"no workspace matches {wanted!r}. Your workspaces: {names}")

View file

@ -4,13 +4,14 @@ from __future__ import annotations
import io import io
import json import json
import webbrowser
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import pytest import pytest
import requests import requests
from strix.interface import cloud, platform_cli from strix.interface import cloud, platform_cli
from strix.interface.cloud import http, render, runner from strix.interface.cloud import http, render, runner, workspaces
from strix.interface.cloud.spec import SPEC from strix.interface.cloud.spec import SPEC
@ -353,3 +354,93 @@ def test_created_id_reads_resource_id() -> None:
assert runner._created_id({"scan_id": "abc", "status": "pending"}) == "abc" assert runner._created_id({"scan_id": "abc", "status": "pending"}) == "abc"
assert runner._created_id({"id": "xyz"}) == "xyz" assert runner._created_id({"id": "xyz"}) == "xyz"
assert runner._created_id({"status": "pending"}) is None assert runner._created_id({"status": "pending"}) is None
def test_billing_subscribe_prints_checkout_url(
monkeypatch: pytest.MonkeyPatch, capsys: Any
) -> None:
seen: dict[str, Any] = {}
def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse:
seen["method"], seen["path"] = method, path
seen["body"] = kwargs.get("body")
return FakeResponse(status_code=200, payload={"checkout_url": "https://pay.test/session"})
monkeypatch.setattr(http, "request", fake_request)
code = cloud.run_cloud(["billing", "subscribe", "--plan", "strix_pro", "--json"])
assert code == 0
assert seen["method"] == "POST"
assert seen["path"] == "/billing/checkout"
assert seen["body"] == {"product": "strix_pro"}
assert "https://pay.test/session" in capsys.readouterr().out
def test_integration_install_url_does_not_open_browser(
monkeypatch: pytest.MonkeyPatch, capsys: Any
) -> None:
opened: list[str] = []
monkeypatch.setattr(
http,
"request",
lambda *_a, **_k: FakeResponse(status_code=200, payload={"url": "https://github.test/app"}),
)
def fake_open(url: str) -> bool:
opened.append(url)
return True
monkeypatch.setattr(webbrowser, "open", fake_open)
code = cloud.run_cloud(["integrations", "install", "github", "--json"])
assert code == 0
assert opened == []
assert "https://github.test/app" in capsys.readouterr().out
def test_workspaces_use_switches_stored_token(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: Any
) -> None:
auth_path = tmp_path / "platform-auth.json"
monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path)
monkeypatch.setattr(workspaces, "AUTH_PATH", auth_path)
platform_cli.save_record({"api_token": "old", "email": "a@b.test"})
calls: list[tuple[str, str]] = []
def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse:
calls.append((method, path))
if path == "/workspaces":
return FakeResponse(
status_code=200,
payload={"workspaces": [{"id": "org_1", "name": "Team One", "role": "admin"}]},
)
return FakeResponse(
status_code=201,
payload={
"api_token": "new-token",
"organization_id": "org_1",
"organization_name": "Team One",
"scopes": ["scans:read"],
},
)
monkeypatch.setattr(http, "request", fake_request)
code = cloud.run_cloud(["workspaces", "use", "team one", "--json"])
assert code == 0
assert calls == [("GET", "/workspaces"), ("POST", "/workspaces/org_1/token")]
record = platform_cli.read_record()
assert record is not None
assert record["api_token"] == "new-token"
assert record["organization_name"] == "Team One"
assert record["email"] == "a@b.test"
assert "org_1" in capsys.readouterr().out
def test_workspaces_use_reports_unknown_workspace(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
http,
"request",
lambda *_a, **_k: FakeResponse(
status_code=200, payload={"workspaces": [{"id": "org_1", "name": "Team One"}]}
),
)
assert cloud.run_cloud(["workspaces", "use", "missing", "--json"]) == 1