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.
@@ -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,5 @@
1
+ __pycache__/
2
+ .venv/
3
+ .env
4
+ .ruff_cache/
5
+ *.code-workspace
@@ -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"]
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m simulation_cli``."""
2
+
3
+ from simulation_cli.app import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -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
+ ]