Skip to content

Commit 0f24b95

Browse files
authored
feat: CLI (#117)
* Initial CLI port * Update dependencies * Wrapping first pass on CLI port with github cloning. * Finalizing docs, auth flows, config management * Adapting feedback * relock * Fix typecheck error * Adjust workflow
1 parent 9e11029 commit 0f24b95

26 files changed

Lines changed: 2220 additions & 239 deletions

.github/workflows/test.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ jobs:
5050
run: poetry run ruff check --output-format=github .
5151

5252
- name: Typecheck
53+
if: always()
5354
run: poetry run mypy .
5455

5556
- name: Test
57+
if: always()
5658
run: poetry run pytest

.secrets.baseline

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,16 @@
151151
"filename": "docs/usage/config.mdx",
152152
"hashed_secret": "3f4f9a14a2d4d72a7074c2969dd34c89f2cbe61a",
153153
"is_verified": false,
154-
"line_number": 23
154+
"line_number": 33
155+
},
156+
{
157+
"type": "Secret Keyword",
158+
"filename": "docs/usage/config.mdx",
159+
"hashed_secret": "01eddf49c6b18f99f87ac7ba45e81d4a227e8d3f",
160+
"is_verified": false,
161+
"line_number": 171
155162
}
156163
]
157164
},
158-
"generated_at": "2025-07-14T09:19:13Z"
165+
"generated_at": "2025-07-24T10:02:58Z"
159166
}

docs/intro.mdx

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,26 @@ Which means, in order to evaluate Offensive Security agents, we need to develop
1616

1717
## Basic Example
1818

19+
Before you start, ensure you have the `dreadnode` package installed (see [installation](/install)). You can authenticate to a platform using the CLI, which is the recommended way to get started.
20+
21+
```bash
22+
# Authenticate to platform.dreadnode.io
23+
dreadnode login
24+
25+
# For self-hosted platforms, specify the server URL
26+
dreadnode login --server http://self-hosted
27+
```
28+
29+
<Note>
30+
For complete authentication and configuration guidance, see the [Configuration](/usage/config) documentation.
31+
</Note>
32+
1933
The most basic use of Strikes is a run with some logged data:
2034

2135
```python
2236
import asyncio
2337
import dreadnode
2438

25-
# Initialize with default settings
26-
dreadnode.configure()
27-
2839
NAMES = ["Nick", "Will", "Brad", "Brian"]
2940

3041
# Create a new task
@@ -42,7 +53,7 @@ async def main() -> None:
4253
)
4354

4455
# Log inputs
45-
dn.log_input("names", NAMES)
56+
dreadnode.log_input("names", NAMES)
4657

4758
# Run your tasks
4859
greetings = [
@@ -51,7 +62,7 @@ async def main() -> None:
5162
]
5263

5364
# Save outputs
54-
dn.log_output("greetings", greetings)
65+
dreadnode.log_output("greetings", greetings)
5566

5667
# Track metrics
5768
dreadnode.log_metric("accuracy", 0.65, step=0)
@@ -63,19 +74,6 @@ async def main() -> None:
6374
asyncio.run(main())
6475
```
6576

66-
<Note>
67-
We'll assume you have installed the `dreadnode` package and have your environment variables set up. Make sure you have `DREADNODE_API_KEY=...` set to your Platform API key.
68-
69-
For more information on `dreadnode.configure()`, review the [Configuration](/usage/config) topic.
70-
71-
If you call `dreadnode.configure()` without any token and your environment variables are not set, you'll receive a warning in the console, so keep an eye out! You can still run any of your code without sending data to the Dreadnode Platform.
72-
</Note>
73-
74-
<Tip>
75-
**Server Configuration**
76-
By default, the SDK connects to the hosted Dreadnode platform at `https://platform.dreadnode.io`. If you're using a self-hosted instance, you must configure the server URL explicitly in your `dreadnode.configure()` call or via the `DREADNODE_SERVER` environment variable. See the [Configuration](/usage/config) guide for details.
77-
</Tip>
78-
7977
This code should be very familiar if you've used an ML-experimentation library before, and all the functions you're familiar with work exactly like you would expect.
8078

8179
Under the hood, this code did a few things:
@@ -114,8 +112,6 @@ Runs are the core unit of work in Strikes. They provide the context for all your
114112
```python
115113
import dreadnode
116114

117-
dreadnode.configure()
118-
119115
with dreadnode.run("my-experiment"):
120116
# Everything that happens here is part of the run
121117
# All data collected is associated with this run
@@ -147,8 +143,6 @@ Tasks are units of work within runs. They help you structure your code and provi
147143
```python
148144
import dreadnode
149145

150-
dreadnode.configure()
151-
152146
@dreadnode.task()
153147
async def say_hello(name: str) -> str:
154148
return f"Hello, {name}!"

docs/sdk/api.mdx

Lines changed: 164 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ ApiClient
1212

1313
```python
1414
ApiClient(
15-
base_url: str, api_key: str, *, debug: bool = False
15+
base_url: str,
16+
*,
17+
api_key: str | None = None,
18+
cookies: dict[str, str] | None = None,
19+
debug: bool = False,
1620
)
1721
```
1822

@@ -29,7 +33,9 @@ Initializes the API client.
2933
(`str`)
3034
–The base URL of the Dreadnode API.
3135
* **`api_key`**
32-
(`str`)
36+
(`str`, default:
37+
`None`
38+
)
3339
–The API key for authentication.
3440
* **`debug`**
3541
(`bool`, default:
@@ -42,11 +48,13 @@ Initializes the API client.
4248
def __init__(
4349
self,
4450
base_url: str,
45-
api_key: str,
4651
*,
52+
api_key: str | None = None,
53+
cookies: dict[str, str] | None = None,
4754
debug: bool = False,
4855
):
49-
"""Initializes the API client.
56+
"""
57+
Initializes the API client.
5058
5159
Args:
5260
base_url (str): The base URL of the Dreadnode API.
@@ -57,12 +65,28 @@ def __init__(
5765
if not self._base_url.endswith("/api"):
5866
self._base_url += "/api"
5967

68+
_cookies = httpx.Cookies()
69+
cookie_domain = urlparse(base_url).hostname
70+
if cookie_domain is None:
71+
raise ValueError(f"Invalid URL: {base_url}")
72+
73+
if cookie_domain == "localhost":
74+
cookie_domain = "localhost.local"
75+
76+
for key, value in (cookies or {}).items():
77+
_cookies.set(key, value, domain=cookie_domain)
78+
79+
headers = {
80+
"User-Agent": f"dreadnode-sdk/{VERSION}",
81+
"Accept": "application/json",
82+
}
83+
84+
if api_key:
85+
headers["X-Api-Key"] = api_key
86+
6087
self._client = httpx.Client(
61-
headers={
62-
"User-Agent": f"dreadnode-sdk/{VERSION}",
63-
"Accept": "application/json",
64-
"X-API-Key": api_key,
65-
},
88+
headers=headers,
89+
cookies=_cookies,
6690
base_url=self._base_url,
6791
timeout=30,
6892
)
@@ -133,7 +157,8 @@ def export_metrics(
133157
metrics: list[str] | None = None,
134158
aggregations: list[MetricAggregationType] | None = None,
135159
) -> pd.DataFrame:
136-
"""Exports metric data for a specific project.
160+
"""
161+
Exports metric data for a specific project.
137162
138163
Args:
139164
project: The project identifier.
@@ -224,7 +249,8 @@ def export_parameters(
224249
metrics: list[str] | None = None,
225250
aggregations: list[MetricAggregationType] | None = None,
226251
) -> pd.DataFrame:
227-
"""Exports parameter data for a specific project.
252+
"""
253+
Exports parameter data for a specific project.
228254
229255
Args:
230256
project: The project identifier.
@@ -306,7 +332,8 @@ def export_runs(
306332
status: StatusFilter = "completed",
307333
aggregations: list[MetricAggregationType] | None = None,
308334
) -> pd.DataFrame:
309-
"""Exports run data for a specific project.
335+
"""
336+
Exports run data for a specific project.
310337
311338
Args:
312339
project: The project identifier.
@@ -398,7 +425,8 @@ def export_timeseries(
398425
time_axis: TimeAxisType = "relative",
399426
aggregations: list[TimeAggregationType] | None = None,
400427
) -> pd.DataFrame:
401-
"""Exports timeseries data for a specific project.
428+
"""
429+
Exports timeseries data for a specific project.
402430
403431
Args:
404432
project: The project identifier.
@@ -427,6 +455,47 @@ def export_timeseries(
427455
```
428456

429457

458+
</Accordion>
459+
460+
### get\_device\_codes
461+
462+
```python
463+
get_device_codes() -> DeviceCodeResponse
464+
```
465+
466+
Start the authentication flow by requesting user and device codes.
467+
468+
<Accordion title="Source code in dreadnode/api/client.py" icon="code">
469+
```python
470+
def get_device_codes(self) -> DeviceCodeResponse:
471+
"""Start the authentication flow by requesting user and device codes."""
472+
473+
response = self.request("POST", "/auth/device/code")
474+
return DeviceCodeResponse(**response.json())
475+
```
476+
477+
478+
</Accordion>
479+
480+
### get\_github\_access\_token
481+
482+
```python
483+
get_github_access_token(
484+
repos: list[str],
485+
) -> GithubTokenResponse
486+
```
487+
488+
Try to get a GitHub access token for the given repositories.
489+
490+
<Accordion title="Source code in dreadnode/api/client.py" icon="code">
491+
```python
492+
def get_github_access_token(self, repos: list[str]) -> GithubTokenResponse:
493+
"""Try to get a GitHub access token for the given repositories."""
494+
response = self.request("POST", "/github/token", json_data={"repos": repos})
495+
return GithubTokenResponse(**response.json())
496+
```
497+
498+
430499
</Accordion>
431500

432501
### get\_project
@@ -634,6 +703,26 @@ def get_run_trace(
634703
```
635704

636705

706+
</Accordion>
707+
708+
### get\_user
709+
710+
```python
711+
get_user() -> UserResponse
712+
```
713+
714+
Get the user email and username.
715+
716+
<Accordion title="Source code in dreadnode/api/client.py" icon="code">
717+
```python
718+
def get_user(self) -> UserResponse:
719+
"""Get the user email and username."""
720+
721+
response = self.request("GET", "/user")
722+
return UserResponse(**response.json())
723+
```
724+
725+
637726
</Accordion>
638727

639728
### get\_user\_data\_credentials
@@ -729,6 +818,47 @@ def list_runs(self, project: str) -> list[RunSummary]:
729818
```
730819

731820

821+
</Accordion>
822+
823+
### poll\_for\_token
824+
825+
```python
826+
poll_for_token(
827+
device_code: str,
828+
interval: int = DEFAULT_POLL_INTERVAL,
829+
max_poll_time: int = DEFAULT_MAX_POLL_TIME,
830+
) -> AccessRefreshTokenResponse
831+
```
832+
833+
Poll for the access token with the given device code.
834+
835+
<Accordion title="Source code in dreadnode/api/client.py" icon="code">
836+
```python
837+
def poll_for_token(
838+
self,
839+
device_code: str,
840+
interval: int = DEFAULT_POLL_INTERVAL,
841+
max_poll_time: int = DEFAULT_MAX_POLL_TIME,
842+
) -> AccessRefreshTokenResponse:
843+
"""Poll for the access token with the given device code."""
844+
845+
start_time = datetime.now(timezone.utc)
846+
while (datetime.now(timezone.utc) - start_time).total_seconds() < max_poll_time:
847+
response = self._request(
848+
"POST", "/auth/device/token", json_data={"device_code": device_code}
849+
)
850+
851+
if response.status_code == 200: # noqa: PLR2004
852+
return AccessRefreshTokenResponse(**response.json())
853+
if response.status_code != 401: # noqa: PLR2004
854+
raise RuntimeError(self._get_error_message(response))
855+
856+
time.sleep(interval)
857+
858+
raise RuntimeError("Polling for token timed out")
859+
```
860+
861+
732862
</Accordion>
733863

734864
### request
@@ -782,7 +912,8 @@ def request(
782912
params: dict[str, t.Any] | None = None,
783913
json_data: dict[str, t.Any] | None = None,
784914
) -> httpx.Response:
785-
"""Makes an HTTP request to the API and raises exceptions for errors.
915+
"""
916+
Makes an HTTP request to the API and raises exceptions for errors.
786917
787918
Args:
788919
method (str): The HTTP method (e.g., "GET", "POST").
@@ -808,6 +939,25 @@ def request(
808939
```
809940

810941

942+
</Accordion>
943+
944+
### url\_for\_user\_code
945+
946+
```python
947+
url_for_user_code(user_code: str) -> str
948+
```
949+
950+
Get the URL to verify the user code.
951+
952+
<Accordion title="Source code in dreadnode/api/client.py" icon="code">
953+
```python
954+
def url_for_user_code(self, user_code: str) -> str:
955+
"""Get the URL to verify the user code."""
956+
957+
return f"{self._base_url.removesuffix('/api')}/account/device?code={user_code}"
958+
```
959+
960+
811961
</Accordion>
812962
ExportFormat
813963
------------

0 commit comments

Comments
 (0)