brava-attack-simulation 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brava_attack_simulation-0.1.0/.github/workflows/ci.yml +35 -0
- brava_attack_simulation-0.1.0/.github/workflows/publish.yml +31 -0
- brava_attack_simulation-0.1.0/.gitignore +5 -0
- brava_attack_simulation-0.1.0/.python-version +1 -0
- brava_attack_simulation-0.1.0/AGENTS.md +75 -0
- brava_attack_simulation-0.1.0/CLAUDE.md +1 -0
- brava_attack_simulation-0.1.0/PKG-INFO +9 -0
- brava_attack_simulation-0.1.0/README.md +0 -0
- brava_attack_simulation-0.1.0/pyproject.toml +32 -0
- brava_attack_simulation-0.1.0/src/simulation_cli/__init__.py +0 -0
- brava_attack_simulation-0.1.0/src/simulation_cli/__main__.py +6 -0
- brava_attack_simulation-0.1.0/src/simulation_cli/app.py +437 -0
- brava_attack_simulation-0.1.0/src/simulation_cli/auth.py +37 -0
- brava_attack_simulation-0.1.0/src/simulation_cli/client.py +139 -0
- brava_attack_simulation-0.1.0/src/simulation_cli/context.py +129 -0
- brava_attack_simulation-0.1.0/src/simulation_cli/types.py +10 -0
- brava_attack_simulation-0.1.0/uv.lock +263 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
check:
|
|
11
|
+
name: Lint and type check
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Install uv
|
|
18
|
+
uses: astral-sh/setup-uv@v5
|
|
19
|
+
|
|
20
|
+
- name: Set up Python
|
|
21
|
+
uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.14"
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: uv sync
|
|
27
|
+
|
|
28
|
+
- name: Lint
|
|
29
|
+
run: uv run ruff check src/
|
|
30
|
+
|
|
31
|
+
- name: Format check
|
|
32
|
+
run: uv run ruff format --check src/
|
|
33
|
+
|
|
34
|
+
- name: Type check
|
|
35
|
+
run: uv run pyright src/
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
name: Build and publish to PyPI
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
id-token: write
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Install uv
|
|
20
|
+
uses: astral-sh/setup-uv@v5
|
|
21
|
+
|
|
22
|
+
- name: Set up Python
|
|
23
|
+
uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.14"
|
|
26
|
+
|
|
27
|
+
- name: Build package
|
|
28
|
+
run: uv build
|
|
29
|
+
|
|
30
|
+
- name: Publish to PyPI
|
|
31
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Attack Simulation CLI
|
|
2
|
+
|
|
3
|
+
CLI frontend for the attack simulation manager REST API. Wraps the manager's HTTP endpoints in an argparse-based command interface.
|
|
4
|
+
|
|
5
|
+
- **Language:** Python 3.14+, Hatchling build
|
|
6
|
+
- **HTTP client:** httpx
|
|
7
|
+
- **Entry point:** `src/simulation_cli/__main__.py`
|
|
8
|
+
- **Installed command:** `simulation-cli`
|
|
9
|
+
|
|
10
|
+
## Build & Run
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uv sync # install deps
|
|
14
|
+
uv run simulation-cli --help
|
|
15
|
+
|
|
16
|
+
# Auth via flags
|
|
17
|
+
uv run simulation-cli --url http://localhost:3000 --token <JWT> catalog list
|
|
18
|
+
|
|
19
|
+
# Auth via env / .env file
|
|
20
|
+
export MANAGER_URL=http://localhost:3000
|
|
21
|
+
export MANAGER_API_TOKEN=<JWT>
|
|
22
|
+
uv run simulation-cli catalog list
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Lint & Typecheck
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv run ruff check src/ # lint
|
|
29
|
+
uv run ruff format src/ # format
|
|
30
|
+
uv run pyright src/ # strict mode
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Pyright is configured as `strict` in `pyproject.toml`. All code must pass with zero errors.
|
|
34
|
+
|
|
35
|
+
## Architecture
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
src/simulation_cli/
|
|
39
|
+
__main__.py # argparse entry point, dispatch
|
|
40
|
+
client.py # ManagerClient — thin httpx wrapper over manager REST API
|
|
41
|
+
types.py # Snapshot type alias (TODO: replace with brava package models)
|
|
42
|
+
commands/
|
|
43
|
+
catalog.py # catalog list/get — browse simulation metadata
|
|
44
|
+
run.py # run simulation create/list/get, run goal create/list/get
|
|
45
|
+
reporting/
|
|
46
|
+
protocol.py # IResultPrinter — output format protocol
|
|
47
|
+
json_printer.py # JsonPrinter — indented JSON to stdout
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Command structure
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
simulation-cli catalog list [--search X] [--offset N] [--limit N]
|
|
54
|
+
simulation-cli catalog get <id>
|
|
55
|
+
simulation-cli run simulation create --simulation-id SIM1 --account-id UUID --region us-east-1 [--params '{}']
|
|
56
|
+
simulation-cli run simulation list [--account-id UUID] [--offset N] [--limit N]
|
|
57
|
+
simulation-cli run simulation get <id>
|
|
58
|
+
simulation-cli run goal create --goal "text" --account-id UUID --region us-east-1 [--profile-identifier ARN]
|
|
59
|
+
simulation-cli run goal list [--account-id UUID] [--offset N] [--limit N]
|
|
60
|
+
simulation-cli run goal get <id>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Key patterns
|
|
64
|
+
|
|
65
|
+
- **Printing is behind a protocol.** `IResultPrinter` in `reporting/protocol.py` defines the output contract. `JsonPrinter` is the current implementation. New formats (table, YAML, etc.) implement the same protocol.
|
|
66
|
+
- **`Snapshot` is a temporary type alias** (`dict[str, Any] | list[Any]`). It will be replaced by typed Pydantic models from `brava-attack-simulation-manager` once the manager CI generates and publishes them.
|
|
67
|
+
- **Client methods never raise on HTTP errors.** They call `sys.exit()` with a message. Command modules don't need try/except around client calls.
|
|
68
|
+
- **`--params` accepts a JSON string**, parsed and validated in `commands/run.py` before forwarding to the client.
|
|
69
|
+
|
|
70
|
+
## Conventions
|
|
71
|
+
|
|
72
|
+
- Follow the coding principles in the [root AGENTS.md](../AGENTS.md) — sad-path defaults, minimal nesting, focused helpers.
|
|
73
|
+
- New commands go in `commands/` — each module exports `register(subparsers)` and `execute(args, client, printer)`.
|
|
74
|
+
- All client methods return `Snapshot` and live in `client.py` grouped by manager resource.
|
|
75
|
+
- Use `jj` for version control (colocated jj+git repo).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
AGENTS.md
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brava-attack-simulation
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Brava Security CLI for attack simulations and security testing
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Requires-Dist: httpx>=0.28.0
|
|
7
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: typer>=0.15.0
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "brava-attack-simulation"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Brava Security CLI for attack simulations and security testing"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"httpx>=0.28.0",
|
|
9
|
+
"python-dotenv>=1.0.0",
|
|
10
|
+
"typer>=0.15.0",
|
|
11
|
+
"rich>=13.0.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
brava = "simulation_cli.app:app"
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pyright>=1.1.408",
|
|
20
|
+
"ruff>=0.15.9",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.pyright]
|
|
28
|
+
typeCheckingMode = "strict"
|
|
29
|
+
pythonVersion = "3.14"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/simulation_cli"]
|
|
File without changes
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Brava CLI — enterprise attack simulation management."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
from simulation_cli import auth
|
|
11
|
+
from simulation_cli.client import BravaClient
|
|
12
|
+
from simulation_cli.context import CliContext
|
|
13
|
+
|
|
14
|
+
load_dotenv()
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="brava",
|
|
18
|
+
help="Brava Security — attack simulation CLI",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
auth_app = typer.Typer(help="Manage authentication credentials")
|
|
23
|
+
catalog_app = typer.Typer(help="Browse the simulation catalog")
|
|
24
|
+
run_app = typer.Typer(help="Manage simulation and goal runs")
|
|
25
|
+
sim_app = typer.Typer(help="Simulation run commands")
|
|
26
|
+
goal_app = typer.Typer(help="Describe-goal run commands")
|
|
27
|
+
doctor_app = typer.Typer(help="Check CLI prerequisites")
|
|
28
|
+
|
|
29
|
+
app.add_typer(auth_app, name="auth")
|
|
30
|
+
app.add_typer(catalog_app, name="catalog")
|
|
31
|
+
app.add_typer(run_app, name="run")
|
|
32
|
+
app.add_typer(doctor_app, name="doctor")
|
|
33
|
+
run_app.add_typer(sim_app, name="simulation")
|
|
34
|
+
run_app.add_typer(goal_app, name="goal")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_context(json_mode: bool = False) -> CliContext:
|
|
38
|
+
"""Resolve URL and token from args, env, or stored credentials."""
|
|
39
|
+
url = os.environ.get("BRAVA_URL")
|
|
40
|
+
token = os.environ.get("BRAVA_API_TOKEN")
|
|
41
|
+
|
|
42
|
+
if not url or not token:
|
|
43
|
+
stored = auth.load_credentials()
|
|
44
|
+
if stored:
|
|
45
|
+
token = token or stored.token
|
|
46
|
+
url = url or stored.url
|
|
47
|
+
|
|
48
|
+
if not url:
|
|
49
|
+
typer.echo(
|
|
50
|
+
"Error: No Brava URL configured. Run `brava auth login` or set BRAVA_URL.",
|
|
51
|
+
err=True,
|
|
52
|
+
)
|
|
53
|
+
raise typer.Exit(1)
|
|
54
|
+
|
|
55
|
+
if not token:
|
|
56
|
+
typer.echo(
|
|
57
|
+
"Error: Not authenticated. Run `brava auth login --token <PAT>` or set BRAVA_API_TOKEN.",
|
|
58
|
+
err=True,
|
|
59
|
+
)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
client = BravaClient(url=url, token=token)
|
|
63
|
+
return CliContext(client=client, json_mode=json_mode)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# auth commands
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@auth_app.command("login")
|
|
72
|
+
def auth_login(
|
|
73
|
+
token: Annotated[
|
|
74
|
+
str, typer.Option("--token", "-t", help="Personal access token (PAT)")
|
|
75
|
+
],
|
|
76
|
+
url: Annotated[
|
|
77
|
+
str, typer.Option("--url", help="Brava platform URL")
|
|
78
|
+
] = "https://console.brava.security",
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Store credentials for the Brava platform."""
|
|
81
|
+
auth.store_credentials(token, url)
|
|
82
|
+
typer.echo(f"Credentials saved. Platform URL: {url}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@auth_app.command("logout")
|
|
86
|
+
def auth_logout() -> None:
|
|
87
|
+
"""Remove stored credentials."""
|
|
88
|
+
auth.clear_credentials()
|
|
89
|
+
typer.echo("Credentials removed.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@auth_app.command("status")
|
|
93
|
+
def auth_status() -> None:
|
|
94
|
+
"""Show current authentication status."""
|
|
95
|
+
env_token = os.environ.get("BRAVA_API_TOKEN")
|
|
96
|
+
stored = auth.load_credentials()
|
|
97
|
+
|
|
98
|
+
if env_token:
|
|
99
|
+
typer.echo(
|
|
100
|
+
f"Authenticated via BRAVA_API_TOKEN env var (prefix: {env_token[:12]}...)"
|
|
101
|
+
)
|
|
102
|
+
elif stored:
|
|
103
|
+
token, url = stored
|
|
104
|
+
typer.echo(f"Authenticated via stored credentials (prefix: {token[:12]}...)")
|
|
105
|
+
typer.echo(f" Platform URL: {url}")
|
|
106
|
+
else:
|
|
107
|
+
typer.echo(
|
|
108
|
+
"Not authenticated. Run `brava auth login --token <PAT>` or set BRAVA_API_TOKEN."
|
|
109
|
+
)
|
|
110
|
+
raise typer.Exit(1)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# catalog commands
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@catalog_app.command("list")
|
|
119
|
+
def catalog_list(
|
|
120
|
+
search: Annotated[
|
|
121
|
+
Optional[str], typer.Option(help="Filter by name/description")
|
|
122
|
+
] = None,
|
|
123
|
+
limit: Annotated[Optional[int], typer.Option(help="Max results")] = None,
|
|
124
|
+
offset: Annotated[Optional[int], typer.Option(help="Skip N results")] = None,
|
|
125
|
+
json: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""List available simulations from the catalog."""
|
|
128
|
+
ctx = _resolve_context(json)
|
|
129
|
+
result = ctx.client.list_simulations(search=search, limit=limit, offset=offset)
|
|
130
|
+
ctx.print(result)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@catalog_app.command("get")
|
|
134
|
+
def catalog_get(
|
|
135
|
+
id: Annotated[str, typer.Argument(help="Simulation ID (e.g. SIM1)")],
|
|
136
|
+
json: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Get simulation details by ID."""
|
|
139
|
+
ctx = _resolve_context(json)
|
|
140
|
+
result = ctx.client.get_simulation(id)
|
|
141
|
+
ctx.print(result)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# run simulation commands
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@sim_app.command("create")
|
|
150
|
+
def sim_create(
|
|
151
|
+
simulation_id: Annotated[
|
|
152
|
+
str, typer.Option("--simulation-id", help="Simulation ID (e.g. SIM1)")
|
|
153
|
+
],
|
|
154
|
+
account_id: Annotated[
|
|
155
|
+
str, typer.Option("--account-id", help="Brava environment UUID")
|
|
156
|
+
],
|
|
157
|
+
region: Annotated[str, typer.Option("--region", help="Target AWS region")],
|
|
158
|
+
params: Annotated[
|
|
159
|
+
Optional[str], typer.Option(help="Runtime params as JSON string")
|
|
160
|
+
] = None,
|
|
161
|
+
json: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Create a new simulation run."""
|
|
164
|
+
import json as json_mod
|
|
165
|
+
|
|
166
|
+
parsed_params = None
|
|
167
|
+
if params:
|
|
168
|
+
try:
|
|
169
|
+
parsed_params = json_mod.loads(params)
|
|
170
|
+
except json_mod.JSONDecodeError as exc:
|
|
171
|
+
typer.echo(f"Error: Invalid --params JSON: {exc}", err=True)
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
|
|
174
|
+
ctx = _resolve_context(json)
|
|
175
|
+
result = ctx.client.create_run(
|
|
176
|
+
simulation_id=simulation_id,
|
|
177
|
+
account_id=account_id,
|
|
178
|
+
region=region,
|
|
179
|
+
params=parsed_params,
|
|
180
|
+
)
|
|
181
|
+
ctx.print(result)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@sim_app.command("list")
|
|
185
|
+
def sim_list(
|
|
186
|
+
account_id: Annotated[
|
|
187
|
+
Optional[str], typer.Option("--account-id", help="Filter by environment")
|
|
188
|
+
] = None,
|
|
189
|
+
limit: Annotated[Optional[int], typer.Option(help="Max results")] = None,
|
|
190
|
+
offset: Annotated[Optional[int], typer.Option(help="Skip N results")] = None,
|
|
191
|
+
json: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""List simulation runs."""
|
|
194
|
+
ctx = _resolve_context(json)
|
|
195
|
+
result = ctx.client.list_runs(account_id=account_id, limit=limit, offset=offset)
|
|
196
|
+
ctx.print(result)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@sim_app.command("get")
|
|
200
|
+
def sim_get(
|
|
201
|
+
id: Annotated[str, typer.Argument(help="Simulation run UUID")],
|
|
202
|
+
json: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Get simulation run details."""
|
|
205
|
+
ctx = _resolve_context(json)
|
|
206
|
+
result = ctx.client.get_run(id)
|
|
207
|
+
ctx.print(result)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@sim_app.command("watch")
|
|
211
|
+
def sim_watch(
|
|
212
|
+
id: Annotated[str, typer.Argument(help="Simulation run UUID")],
|
|
213
|
+
interval: Annotated[int, typer.Option(help="Poll interval in seconds")] = 5,
|
|
214
|
+
json: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Watch a simulation run until completion."""
|
|
217
|
+
import time
|
|
218
|
+
from rich.live import Live
|
|
219
|
+
from rich.text import Text
|
|
220
|
+
|
|
221
|
+
ctx = _resolve_context(json)
|
|
222
|
+
terminal_statuses = {
|
|
223
|
+
"COMPLETED",
|
|
224
|
+
"FAILED",
|
|
225
|
+
"TIMED_OUT",
|
|
226
|
+
"completed",
|
|
227
|
+
"failed",
|
|
228
|
+
"timed_out",
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if ctx.json_mode:
|
|
232
|
+
while True:
|
|
233
|
+
data = ctx.client.get_run(id)
|
|
234
|
+
status = data.get("status", "") if isinstance(data, dict) else ""
|
|
235
|
+
ctx.print(data)
|
|
236
|
+
if status in terminal_statuses:
|
|
237
|
+
raise typer.Exit(0 if status.lower() == "completed" else 1)
|
|
238
|
+
time.sleep(interval)
|
|
239
|
+
else:
|
|
240
|
+
with Live(Text("Polling..."), refresh_per_second=1) as live:
|
|
241
|
+
while True:
|
|
242
|
+
data = ctx.client.get_run(id)
|
|
243
|
+
status = data.get("status", "") if isinstance(data, dict) else ""
|
|
244
|
+
live.update(ctx.format(data))
|
|
245
|
+
if status in terminal_statuses:
|
|
246
|
+
ctx.print(data)
|
|
247
|
+
raise typer.Exit(0 if status.lower() == "completed" else 1)
|
|
248
|
+
time.sleep(interval)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# run goal commands
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@goal_app.command("create")
|
|
257
|
+
def goal_create(
|
|
258
|
+
goal: Annotated[str, typer.Option("--goal", help="Free-text research goal")],
|
|
259
|
+
account_id: Annotated[
|
|
260
|
+
str, typer.Option("--account-id", help="Brava environment UUID")
|
|
261
|
+
],
|
|
262
|
+
region: Annotated[str, typer.Option("--region", help="Target AWS region")],
|
|
263
|
+
profile_identifier: Annotated[
|
|
264
|
+
Optional[str], typer.Option("--profile", help="Profile identifier (role ARN)")
|
|
265
|
+
] = None,
|
|
266
|
+
json: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Create a new describe-goal run."""
|
|
269
|
+
ctx = _resolve_context(json)
|
|
270
|
+
result = ctx.client.create_goal_run(
|
|
271
|
+
goal=goal,
|
|
272
|
+
account_id=account_id,
|
|
273
|
+
region=region,
|
|
274
|
+
profile_identifier=profile_identifier,
|
|
275
|
+
)
|
|
276
|
+
ctx.print(result)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@goal_app.command("list")
|
|
280
|
+
def goal_list(
|
|
281
|
+
account_id: Annotated[
|
|
282
|
+
Optional[str], typer.Option("--account-id", help="Filter by environment")
|
|
283
|
+
] = None,
|
|
284
|
+
limit: Annotated[Optional[int], typer.Option(help="Max results")] = None,
|
|
285
|
+
offset: Annotated[Optional[int], typer.Option(help="Skip N results")] = None,
|
|
286
|
+
json: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""List describe-goal runs."""
|
|
289
|
+
ctx = _resolve_context(json)
|
|
290
|
+
result = ctx.client.list_goal_runs(
|
|
291
|
+
account_id=account_id, limit=limit, offset=offset
|
|
292
|
+
)
|
|
293
|
+
ctx.print(result)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@goal_app.command("get")
|
|
297
|
+
def goal_get(
|
|
298
|
+
id: Annotated[str, typer.Argument(help="Describe-goal run UUID")],
|
|
299
|
+
json: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Get describe-goal run details."""
|
|
302
|
+
ctx = _resolve_context(json)
|
|
303
|
+
result = ctx.client.get_goal_run(id)
|
|
304
|
+
ctx.print(result)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@goal_app.command("watch")
|
|
308
|
+
def goal_watch(
|
|
309
|
+
id: Annotated[str, typer.Argument(help="Describe-goal run UUID")],
|
|
310
|
+
interval: Annotated[int, typer.Option(help="Poll interval in seconds")] = 5,
|
|
311
|
+
json: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Watch a describe-goal run until completion."""
|
|
314
|
+
import time
|
|
315
|
+
from rich.live import Live
|
|
316
|
+
from rich.text import Text
|
|
317
|
+
|
|
318
|
+
ctx = _resolve_context(json)
|
|
319
|
+
terminal_statuses = {"complete", "failed", "COMPLETED", "FAILED"}
|
|
320
|
+
|
|
321
|
+
if ctx.json_mode:
|
|
322
|
+
while True:
|
|
323
|
+
data = ctx.client.get_goal_run(id)
|
|
324
|
+
status = data.get("status", "") if isinstance(data, dict) else ""
|
|
325
|
+
ctx.print(data)
|
|
326
|
+
if status in terminal_statuses:
|
|
327
|
+
raise typer.Exit(0 if status.lower() == "complete" else 1)
|
|
328
|
+
time.sleep(interval)
|
|
329
|
+
else:
|
|
330
|
+
with Live(Text("Polling..."), refresh_per_second=1) as live:
|
|
331
|
+
while True:
|
|
332
|
+
data = ctx.client.get_goal_run(id)
|
|
333
|
+
status = data.get("status", "") if isinstance(data, dict) else ""
|
|
334
|
+
live.update(ctx.format(data))
|
|
335
|
+
if status in terminal_statuses:
|
|
336
|
+
ctx.print(data)
|
|
337
|
+
raise typer.Exit(0 if status.lower() == "complete" else 1)
|
|
338
|
+
time.sleep(interval)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
# doctor
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@doctor_app.callback(invoke_without_command=True)
|
|
347
|
+
def doctor() -> None:
|
|
348
|
+
"""Check CLI prerequisites and connectivity."""
|
|
349
|
+
from rich.console import Console
|
|
350
|
+
from rich.table import Table
|
|
351
|
+
|
|
352
|
+
console = Console()
|
|
353
|
+
checks: list[tuple[str, bool, str]] = []
|
|
354
|
+
|
|
355
|
+
py = sys.version_info
|
|
356
|
+
checks.append(
|
|
357
|
+
("Python >= 3.12", py >= (3, 12), f"{py.major}.{py.minor}.{py.micro}")
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
env_token = os.environ.get("BRAVA_API_TOKEN")
|
|
361
|
+
stored = auth.load_credentials()
|
|
362
|
+
auth_ok = bool(env_token or stored)
|
|
363
|
+
if env_token:
|
|
364
|
+
detail = "via BRAVA_API_TOKEN env var"
|
|
365
|
+
elif stored:
|
|
366
|
+
detail = f"stored credentials ({stored.url})"
|
|
367
|
+
else:
|
|
368
|
+
detail = "not authenticated"
|
|
369
|
+
checks.append(("Authentication", auth_ok, detail))
|
|
370
|
+
|
|
371
|
+
url = os.environ.get("BRAVA_URL") or (stored.url if stored else None)
|
|
372
|
+
token = env_token or (stored.token if stored else None)
|
|
373
|
+
if url:
|
|
374
|
+
try:
|
|
375
|
+
import httpx
|
|
376
|
+
|
|
377
|
+
r = httpx.get(f"{url}/api/v1/health", timeout=5.0)
|
|
378
|
+
checks.append(
|
|
379
|
+
(
|
|
380
|
+
"Platform connectivity",
|
|
381
|
+
r.status_code == 200,
|
|
382
|
+
f"{url} -> HTTP {r.status_code}",
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
except Exception as exc:
|
|
386
|
+
checks.append(("Platform connectivity", False, str(exc)[:80]))
|
|
387
|
+
else:
|
|
388
|
+
checks.append(("Platform connectivity", False, "no URL configured"))
|
|
389
|
+
|
|
390
|
+
if url and token:
|
|
391
|
+
try:
|
|
392
|
+
import httpx
|
|
393
|
+
|
|
394
|
+
r = httpx.get(
|
|
395
|
+
f"{url}/api/v1/attack-simulation/simulation",
|
|
396
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
397
|
+
params={"limit": "1"},
|
|
398
|
+
timeout=5.0,
|
|
399
|
+
)
|
|
400
|
+
if r.status_code == 200:
|
|
401
|
+
checks.append(("Token validation", True, "token accepted by platform"))
|
|
402
|
+
elif r.status_code == 401:
|
|
403
|
+
checks.append(("Token validation", False, "invalid or expired token"))
|
|
404
|
+
auth_ok = False
|
|
405
|
+
else:
|
|
406
|
+
checks.append(
|
|
407
|
+
("Token validation", False, f"unexpected HTTP {r.status_code}")
|
|
408
|
+
)
|
|
409
|
+
except Exception as exc:
|
|
410
|
+
checks.append(("Token validation", False, str(exc)[:80]))
|
|
411
|
+
elif auth_ok:
|
|
412
|
+
checks.append(("Token validation", False, "skipped (no URL)"))
|
|
413
|
+
|
|
414
|
+
table = Table(title="brava doctor")
|
|
415
|
+
table.add_column("Check", style="bold")
|
|
416
|
+
table.add_column("Status")
|
|
417
|
+
table.add_column("Details")
|
|
418
|
+
|
|
419
|
+
all_ok = True
|
|
420
|
+
for name, ok, detail in checks:
|
|
421
|
+
status = "[green]OK[/green]" if ok else "[red]FAIL[/red]"
|
|
422
|
+
if not ok:
|
|
423
|
+
all_ok = False
|
|
424
|
+
table.add_row(name, status, detail)
|
|
425
|
+
|
|
426
|
+
console.print()
|
|
427
|
+
console.print(table)
|
|
428
|
+
console.print()
|
|
429
|
+
console.print(
|
|
430
|
+
"[green]All checks passed.[/green]"
|
|
431
|
+
if all_ok
|
|
432
|
+
else "[yellow]Some checks failed.[/yellow]"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
if __name__ == "__main__":
|
|
437
|
+
app()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Credential storage and retrieval for the Brava CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
CONFIG_DIR = Path.home() / ".config" / "brava-cli"
|
|
8
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StoredCredentials(NamedTuple):
|
|
12
|
+
token: str
|
|
13
|
+
url: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def store_credentials(token: str, url: str) -> None:
|
|
17
|
+
"""Persist API token and URL to disk."""
|
|
18
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
CREDENTIALS_FILE.write_text(json.dumps({"token": token, "url": url}))
|
|
20
|
+
CREDENTIALS_FILE.chmod(0o600)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_credentials() -> StoredCredentials | None:
|
|
24
|
+
"""Load stored credentials, or ``None`` if not found."""
|
|
25
|
+
if not CREDENTIALS_FILE.exists():
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(CREDENTIALS_FILE.read_text())
|
|
29
|
+
return StoredCredentials(token=data["token"], url=data["url"])
|
|
30
|
+
except json.JSONDecodeError, KeyError:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clear_credentials() -> None:
|
|
35
|
+
"""Remove stored credentials."""
|
|
36
|
+
if CREDENTIALS_FILE.exists():
|
|
37
|
+
CREDENTIALS_FILE.unlink()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""HTTP client for the Brava platform API (via BFF)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from simulation_cli.types import Snapshot
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BravaClient:
|
|
12
|
+
"""Authenticated HTTP client for the Brava BFF.
|
|
13
|
+
|
|
14
|
+
Routes requests through the backend-for-frontend which validates
|
|
15
|
+
the PAT via auth-service and proxies to downstream microservices.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, url: str, token: str) -> None:
|
|
19
|
+
self._http = httpx.Client(
|
|
20
|
+
base_url=url.rstrip("/"),
|
|
21
|
+
headers={
|
|
22
|
+
"Authorization": f"Bearer {token}",
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
},
|
|
25
|
+
timeout=30.0,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Snapshot:
|
|
29
|
+
try:
|
|
30
|
+
response = self._http.request(method, path, **kwargs)
|
|
31
|
+
response.raise_for_status()
|
|
32
|
+
except httpx.HTTPStatusError as exc:
|
|
33
|
+
sys.exit(f"HTTP {exc.response.status_code}: {exc.response.text}")
|
|
34
|
+
except httpx.RequestError as exc:
|
|
35
|
+
sys.exit(f"Request failed: {exc}")
|
|
36
|
+
return response.json()
|
|
37
|
+
|
|
38
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> Snapshot:
|
|
39
|
+
return self._request("GET", path, params=params)
|
|
40
|
+
|
|
41
|
+
def _post(self, path: str, body: dict[str, Any]) -> Snapshot:
|
|
42
|
+
return self._request("POST", path, json=body)
|
|
43
|
+
|
|
44
|
+
# --- simulation catalog (via BFF) ---
|
|
45
|
+
|
|
46
|
+
def list_simulations(
|
|
47
|
+
self,
|
|
48
|
+
search: str | None = None,
|
|
49
|
+
offset: int | None = None,
|
|
50
|
+
limit: int | None = None,
|
|
51
|
+
) -> Snapshot:
|
|
52
|
+
params: dict[str, Any] = {}
|
|
53
|
+
if search is not None:
|
|
54
|
+
params["search"] = search
|
|
55
|
+
if offset is not None:
|
|
56
|
+
params["offset"] = offset
|
|
57
|
+
if limit is not None:
|
|
58
|
+
params["limit"] = limit
|
|
59
|
+
return self._get("/api/v1/attack-simulation/simulation", params or None)
|
|
60
|
+
|
|
61
|
+
def get_simulation(self, id: str) -> Snapshot:
|
|
62
|
+
return self._get(f"/api/v1/attack-simulation/simulation/{id}")
|
|
63
|
+
|
|
64
|
+
# --- simulation runs (via BFF) ---
|
|
65
|
+
|
|
66
|
+
def create_run(
|
|
67
|
+
self,
|
|
68
|
+
simulation_id: str,
|
|
69
|
+
account_id: str,
|
|
70
|
+
region: str,
|
|
71
|
+
params: dict[str, Any] | None = None,
|
|
72
|
+
) -> Snapshot:
|
|
73
|
+
body: dict[str, Any] = {
|
|
74
|
+
"simulationMetadataId": simulation_id,
|
|
75
|
+
"targetAccountId": account_id,
|
|
76
|
+
"targetRegion": region,
|
|
77
|
+
}
|
|
78
|
+
if params is not None:
|
|
79
|
+
body["params"] = params
|
|
80
|
+
return self._post("/api/v1/attack-simulation/simulation-run", body)
|
|
81
|
+
|
|
82
|
+
def list_runs(
|
|
83
|
+
self,
|
|
84
|
+
account_id: str | None = None,
|
|
85
|
+
offset: int | None = None,
|
|
86
|
+
limit: int | None = None,
|
|
87
|
+
) -> Snapshot:
|
|
88
|
+
params: dict[str, Any] = {}
|
|
89
|
+
if account_id is not None:
|
|
90
|
+
params["accountId"] = account_id
|
|
91
|
+
if offset is not None:
|
|
92
|
+
params["offset"] = offset
|
|
93
|
+
if limit is not None:
|
|
94
|
+
params["limit"] = limit
|
|
95
|
+
return self._get("/api/v1/attack-simulation/simulation-run", params or None)
|
|
96
|
+
|
|
97
|
+
def get_run(self, id: str) -> Snapshot:
|
|
98
|
+
return self._get(f"/api/v1/attack-simulation/simulation-run/{id}")
|
|
99
|
+
|
|
100
|
+
# --- describe-goal runs (via BFF) ---
|
|
101
|
+
|
|
102
|
+
def create_goal_run(
|
|
103
|
+
self,
|
|
104
|
+
goal: str,
|
|
105
|
+
account_id: str,
|
|
106
|
+
region: str,
|
|
107
|
+
profile_identifier: str | None = None,
|
|
108
|
+
) -> Snapshot:
|
|
109
|
+
body: dict[str, Any] = {
|
|
110
|
+
"goal": goal,
|
|
111
|
+
"targetAccountId": account_id,
|
|
112
|
+
"targetRegion": region,
|
|
113
|
+
}
|
|
114
|
+
if profile_identifier is not None:
|
|
115
|
+
body["profileIdentifier"] = profile_identifier
|
|
116
|
+
return self._post("/api/v1/attack-simulation/describe-goal-run", body)
|
|
117
|
+
|
|
118
|
+
def list_goal_runs(
|
|
119
|
+
self,
|
|
120
|
+
account_id: str | None = None,
|
|
121
|
+
offset: int | None = None,
|
|
122
|
+
limit: int | None = None,
|
|
123
|
+
) -> Snapshot:
|
|
124
|
+
params: dict[str, Any] = {}
|
|
125
|
+
if account_id is not None:
|
|
126
|
+
params["accountId"] = account_id
|
|
127
|
+
if offset is not None:
|
|
128
|
+
params["offset"] = offset
|
|
129
|
+
if limit is not None:
|
|
130
|
+
params["limit"] = limit
|
|
131
|
+
return self._get("/api/v1/attack-simulation/describe-goal-run", params or None)
|
|
132
|
+
|
|
133
|
+
def get_goal_run(self, id: str) -> Snapshot:
|
|
134
|
+
return self._get(f"/api/v1/attack-simulation/describe-goal-run/{id}")
|
|
135
|
+
|
|
136
|
+
# --- health ---
|
|
137
|
+
|
|
138
|
+
def health(self) -> Snapshot:
|
|
139
|
+
return self._get("/api/v1/health")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""CLI context with dual-mode output (Rich for TTY, JSON for CI)."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownMemberType=false
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from rich.console import Console, RenderableType
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from simulation_cli.client import BravaClient
|
|
13
|
+
from simulation_cli.types import Snapshot
|
|
14
|
+
|
|
15
|
+
STATUS_COLORS: dict[str, str] = {
|
|
16
|
+
"planning": "yellow",
|
|
17
|
+
"executing": "blue",
|
|
18
|
+
"collecting": "cyan",
|
|
19
|
+
"complete": "green",
|
|
20
|
+
"completed": "green",
|
|
21
|
+
"failed": "red",
|
|
22
|
+
"timed_out": "red",
|
|
23
|
+
"PLANNING": "yellow",
|
|
24
|
+
"EXECUTING": "blue",
|
|
25
|
+
"COLLECTING": "cyan",
|
|
26
|
+
"COMPLETED": "green",
|
|
27
|
+
"FAILED": "red",
|
|
28
|
+
"TIMED_OUT": "red",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
PREFERRED_COLUMNS: list[str] = [
|
|
32
|
+
"id",
|
|
33
|
+
"cid",
|
|
34
|
+
"name",
|
|
35
|
+
"status",
|
|
36
|
+
"goal",
|
|
37
|
+
"simulationMetadataId",
|
|
38
|
+
"targetAccountId",
|
|
39
|
+
"targetRegion",
|
|
40
|
+
"provider",
|
|
41
|
+
"createdAt",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CliContext:
|
|
46
|
+
"""Holds the authenticated client and output mode."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, client: BravaClient, json_mode: bool = False) -> None:
|
|
49
|
+
self.client = client
|
|
50
|
+
self.json_mode = json_mode or not sys.stdout.isatty()
|
|
51
|
+
self.console = Console()
|
|
52
|
+
|
|
53
|
+
def print(self, data: Snapshot) -> None:
|
|
54
|
+
if self.json_mode:
|
|
55
|
+
print(json.dumps(data, indent=2, default=str))
|
|
56
|
+
else:
|
|
57
|
+
self.console.print(self.format(data))
|
|
58
|
+
|
|
59
|
+
def format(self, data: Snapshot) -> RenderableType:
|
|
60
|
+
if isinstance(data, list):
|
|
61
|
+
return _format_list(data)
|
|
62
|
+
if "data" in data and isinstance(data["data"], list):
|
|
63
|
+
return _format_paginated(data)
|
|
64
|
+
return _format_dict(data)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _format_list(items: list[dict[str, object] | object]) -> Table:
|
|
68
|
+
if not items:
|
|
69
|
+
return Table(title="No results")
|
|
70
|
+
|
|
71
|
+
first = items[0]
|
|
72
|
+
if not isinstance(first, dict):
|
|
73
|
+
table = Table(show_lines=False)
|
|
74
|
+
table.add_column("value")
|
|
75
|
+
for item in items:
|
|
76
|
+
table.add_row(str(item))
|
|
77
|
+
return table
|
|
78
|
+
|
|
79
|
+
all_keys: list[str] = list(first.keys())
|
|
80
|
+
keys = [k for k in PREFERRED_COLUMNS if k in all_keys]
|
|
81
|
+
if not keys:
|
|
82
|
+
keys = [k for k in all_keys if not isinstance(first.get(k), (dict, list))][:8]
|
|
83
|
+
|
|
84
|
+
table = Table(show_lines=False)
|
|
85
|
+
for key in keys:
|
|
86
|
+
table.add_column(key, style="bold" if key in ("id", "cid", "name") else "")
|
|
87
|
+
|
|
88
|
+
for item in items:
|
|
89
|
+
if not isinstance(item, dict):
|
|
90
|
+
continue
|
|
91
|
+
row: list[str] = []
|
|
92
|
+
for key in keys:
|
|
93
|
+
val = item.get(key, "")
|
|
94
|
+
if key == "status":
|
|
95
|
+
color = STATUS_COLORS.get(str(val), "white")
|
|
96
|
+
row.append(f"[{color}]{val}[/{color}]")
|
|
97
|
+
elif key == "createdAt" and isinstance(val, str) and len(val) > 10:
|
|
98
|
+
row.append(val[:10])
|
|
99
|
+
else:
|
|
100
|
+
s = str(val) if val is not None else ""
|
|
101
|
+
row.append(s[:60] + "..." if len(s) > 60 else s)
|
|
102
|
+
table.add_row(*row)
|
|
103
|
+
return table
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format_paginated(data: dict[str, object]) -> Table:
|
|
107
|
+
items = data["data"]
|
|
108
|
+
assert isinstance(items, list)
|
|
109
|
+
total = data.get("total", len(items))
|
|
110
|
+
table = _format_list(items)
|
|
111
|
+
table.caption = f"Showing {len(items)} of {total}"
|
|
112
|
+
return table
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _format_dict(data: dict[str, object]) -> Panel:
|
|
116
|
+
lines: list[str] = []
|
|
117
|
+
for key, val in data.items():
|
|
118
|
+
if key == "status":
|
|
119
|
+
color = STATUS_COLORS.get(str(val), "white")
|
|
120
|
+
lines.append(f"[bold]{key}:[/bold] [{color}]{val}[/{color}]")
|
|
121
|
+
elif isinstance(val, list) and len(val) > 3:
|
|
122
|
+
lines.append(f"[bold]{key}:[/bold] ({len(val)} items)")
|
|
123
|
+
elif isinstance(val, dict):
|
|
124
|
+
lines.append(f"[bold]{key}:[/bold] {{...}}")
|
|
125
|
+
else:
|
|
126
|
+
lines.append(f"[bold]{key}:[/bold] {val}")
|
|
127
|
+
|
|
128
|
+
title = str(data.get("id", data.get("name", "Result")))
|
|
129
|
+
return Panel("\n".join(lines), title=title, expand=False)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Shared type aliases for simulation_cli.
|
|
2
|
+
|
|
3
|
+
TODO: Replace Snapshot with typed Pydantic DTOs from brava-attack-simulation-manager
|
|
4
|
+
once generate-pydantic is wired into the manager CI pipeline and the package
|
|
5
|
+
is republished with REST API response models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
Snapshot = dict[str, Any] | list[Any]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.14"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "annotated-doc"
|
|
7
|
+
version = "0.0.4"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "anyio"
|
|
16
|
+
version = "4.13.0"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
dependencies = [
|
|
19
|
+
{ name = "idna" },
|
|
20
|
+
]
|
|
21
|
+
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
|
22
|
+
wheels = [
|
|
23
|
+
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[[package]]
|
|
27
|
+
name = "brava-cli"
|
|
28
|
+
version = "0.1.0"
|
|
29
|
+
source = { editable = "." }
|
|
30
|
+
dependencies = [
|
|
31
|
+
{ name = "httpx" },
|
|
32
|
+
{ name = "python-dotenv" },
|
|
33
|
+
{ name = "rich" },
|
|
34
|
+
{ name = "typer" },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[package.dev-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
{ name = "pyright" },
|
|
40
|
+
{ name = "ruff" },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[package.metadata]
|
|
44
|
+
requires-dist = [
|
|
45
|
+
{ name = "httpx", specifier = ">=0.28.0" },
|
|
46
|
+
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
|
47
|
+
{ name = "rich", specifier = ">=13.0.0" },
|
|
48
|
+
{ name = "typer", specifier = ">=0.15.0" },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[package.metadata.requires-dev]
|
|
52
|
+
dev = [
|
|
53
|
+
{ name = "pyright", specifier = ">=1.1.408" },
|
|
54
|
+
{ name = "ruff", specifier = ">=0.15.9" },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[[package]]
|
|
58
|
+
name = "certifi"
|
|
59
|
+
version = "2026.2.25"
|
|
60
|
+
source = { registry = "https://pypi.org/simple" }
|
|
61
|
+
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
|
62
|
+
wheels = [
|
|
63
|
+
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[[package]]
|
|
67
|
+
name = "click"
|
|
68
|
+
version = "8.3.2"
|
|
69
|
+
source = { registry = "https://pypi.org/simple" }
|
|
70
|
+
dependencies = [
|
|
71
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
72
|
+
]
|
|
73
|
+
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
|
|
74
|
+
wheels = [
|
|
75
|
+
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[[package]]
|
|
79
|
+
name = "colorama"
|
|
80
|
+
version = "0.4.6"
|
|
81
|
+
source = { registry = "https://pypi.org/simple" }
|
|
82
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
83
|
+
wheels = [
|
|
84
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
[[package]]
|
|
88
|
+
name = "h11"
|
|
89
|
+
version = "0.16.0"
|
|
90
|
+
source = { registry = "https://pypi.org/simple" }
|
|
91
|
+
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
|
92
|
+
wheels = [
|
|
93
|
+
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
[[package]]
|
|
97
|
+
name = "httpcore"
|
|
98
|
+
version = "1.0.9"
|
|
99
|
+
source = { registry = "https://pypi.org/simple" }
|
|
100
|
+
dependencies = [
|
|
101
|
+
{ name = "certifi" },
|
|
102
|
+
{ name = "h11" },
|
|
103
|
+
]
|
|
104
|
+
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
|
105
|
+
wheels = [
|
|
106
|
+
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
[[package]]
|
|
110
|
+
name = "httpx"
|
|
111
|
+
version = "0.28.1"
|
|
112
|
+
source = { registry = "https://pypi.org/simple" }
|
|
113
|
+
dependencies = [
|
|
114
|
+
{ name = "anyio" },
|
|
115
|
+
{ name = "certifi" },
|
|
116
|
+
{ name = "httpcore" },
|
|
117
|
+
{ name = "idna" },
|
|
118
|
+
]
|
|
119
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
|
120
|
+
wheels = [
|
|
121
|
+
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
[[package]]
|
|
125
|
+
name = "idna"
|
|
126
|
+
version = "3.11"
|
|
127
|
+
source = { registry = "https://pypi.org/simple" }
|
|
128
|
+
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
|
129
|
+
wheels = [
|
|
130
|
+
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
[[package]]
|
|
134
|
+
name = "markdown-it-py"
|
|
135
|
+
version = "4.0.0"
|
|
136
|
+
source = { registry = "https://pypi.org/simple" }
|
|
137
|
+
dependencies = [
|
|
138
|
+
{ name = "mdurl" },
|
|
139
|
+
]
|
|
140
|
+
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
|
141
|
+
wheels = [
|
|
142
|
+
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
[[package]]
|
|
146
|
+
name = "mdurl"
|
|
147
|
+
version = "0.1.2"
|
|
148
|
+
source = { registry = "https://pypi.org/simple" }
|
|
149
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
|
150
|
+
wheels = [
|
|
151
|
+
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
[[package]]
|
|
155
|
+
name = "nodeenv"
|
|
156
|
+
version = "1.10.0"
|
|
157
|
+
source = { registry = "https://pypi.org/simple" }
|
|
158
|
+
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
|
159
|
+
wheels = [
|
|
160
|
+
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
[[package]]
|
|
164
|
+
name = "pygments"
|
|
165
|
+
version = "2.20.0"
|
|
166
|
+
source = { registry = "https://pypi.org/simple" }
|
|
167
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
|
168
|
+
wheels = [
|
|
169
|
+
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
[[package]]
|
|
173
|
+
name = "pyright"
|
|
174
|
+
version = "1.1.408"
|
|
175
|
+
source = { registry = "https://pypi.org/simple" }
|
|
176
|
+
dependencies = [
|
|
177
|
+
{ name = "nodeenv" },
|
|
178
|
+
{ name = "typing-extensions" },
|
|
179
|
+
]
|
|
180
|
+
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
|
|
181
|
+
wheels = [
|
|
182
|
+
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
[[package]]
|
|
186
|
+
name = "python-dotenv"
|
|
187
|
+
version = "1.2.2"
|
|
188
|
+
source = { registry = "https://pypi.org/simple" }
|
|
189
|
+
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
|
190
|
+
wheels = [
|
|
191
|
+
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
[[package]]
|
|
195
|
+
name = "rich"
|
|
196
|
+
version = "14.3.3"
|
|
197
|
+
source = { registry = "https://pypi.org/simple" }
|
|
198
|
+
dependencies = [
|
|
199
|
+
{ name = "markdown-it-py" },
|
|
200
|
+
{ name = "pygments" },
|
|
201
|
+
]
|
|
202
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
|
203
|
+
wheels = [
|
|
204
|
+
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
[[package]]
|
|
208
|
+
name = "ruff"
|
|
209
|
+
version = "0.15.9"
|
|
210
|
+
source = { registry = "https://pypi.org/simple" }
|
|
211
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" }
|
|
212
|
+
wheels = [
|
|
213
|
+
{ url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" },
|
|
214
|
+
{ url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" },
|
|
215
|
+
{ url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" },
|
|
216
|
+
{ url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" },
|
|
217
|
+
{ url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" },
|
|
218
|
+
{ url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" },
|
|
219
|
+
{ url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" },
|
|
220
|
+
{ url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" },
|
|
221
|
+
{ url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" },
|
|
222
|
+
{ url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" },
|
|
223
|
+
{ url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" },
|
|
224
|
+
{ url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" },
|
|
225
|
+
{ url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" },
|
|
226
|
+
{ url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" },
|
|
227
|
+
{ url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" },
|
|
228
|
+
{ url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" },
|
|
229
|
+
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
[[package]]
|
|
233
|
+
name = "shellingham"
|
|
234
|
+
version = "1.5.4"
|
|
235
|
+
source = { registry = "https://pypi.org/simple" }
|
|
236
|
+
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
|
237
|
+
wheels = [
|
|
238
|
+
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
[[package]]
|
|
242
|
+
name = "typer"
|
|
243
|
+
version = "0.24.1"
|
|
244
|
+
source = { registry = "https://pypi.org/simple" }
|
|
245
|
+
dependencies = [
|
|
246
|
+
{ name = "annotated-doc" },
|
|
247
|
+
{ name = "click" },
|
|
248
|
+
{ name = "rich" },
|
|
249
|
+
{ name = "shellingham" },
|
|
250
|
+
]
|
|
251
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
|
|
252
|
+
wheels = [
|
|
253
|
+
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
[[package]]
|
|
257
|
+
name = "typing-extensions"
|
|
258
|
+
version = "4.15.0"
|
|
259
|
+
source = { registry = "https://pypi.org/simple" }
|
|
260
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
|
261
|
+
wheels = [
|
|
262
|
+
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
|
263
|
+
]
|