slidecold 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,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
12
+ .DS_Store
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ - `SlideCold` and `AsyncSlideCold` clients covering all 49 operations of the SlideCold API v1, grouped by
8
+ resource (`campaigns`, `audiences`, `conversations`, `accounts`, `proxies`, `folders`), generated from
9
+ `openapi.yaml`, with TypedDicts for models and request bodies.
10
+ - Typed errors (`AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `RateLimitError`, `APIError`,
11
+ `APIConnectionError`), GET retries with exponential backoff, `wait_for_login_job`.
12
+ - `verify_webhook_signature` for `X-SlideCold-Signature`.
13
+ - `slidecold` CLI with `login` / `logout` and one command per operation.
14
+ - `slidecold-mcp` stdio MCP server (`pip install 'slidecold[mcp]'`) with one tool per operation.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SlideCold
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,249 @@
1
+ Metadata-Version: 2.5
2
+ Name: slidecold
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK, CLI and MCP server for the SlideCold API (Instagram cold-DM outreach)
5
+ Project-URL: Homepage, https://slidecold.com
6
+ Project-URL: Documentation, https://slidecold.com/docs
7
+ Project-URL: Repository, https://github.com/slidecold/slidecold-python
8
+ Project-URL: Changelog, https://github.com/slidecold/slidecold-python/blob/main/CHANGELOG.md
9
+ Author: SlideCold
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: cli,cold-dm,instagram,mcp,outreach,sdk,slidecold
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: httpx<1,>=0.23
26
+ Provides-Extra: mcp
27
+ Requires-Dist: mcp<3,>=2.1; (python_version >= '3.10') and extra == 'mcp'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # SlideCold Python SDK, CLI and MCP server
31
+
32
+ The official Python client for the [SlideCold](https://slidecold.com) API (Instagram cold-DM outreach).
33
+ One package gives you:
34
+
35
+ - **SDK**: `SlideCold` and `AsyncSlideCold` clients with typed methods for every API operation.
36
+ - **CLI**: `slidecold campaigns list`, `slidecold audiences export <id>`, and so on.
37
+ - **MCP server**: `slidecold-mcp`, which gives Claude, Cursor and other MCP clients one tool per API operation.
38
+
39
+ Everything is generated from the [OpenAPI spec](openapi.yaml), so the SDK, the CLI and the MCP tools always match the API.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install slidecold # SDK and CLI (Python 3.9+, the only dependency is httpx)
45
+ pip install 'slidecold[mcp]' # also the MCP server (Python 3.10+)
46
+ ```
47
+
48
+ Create an API key at <https://slidecold.com/settings>. Keys look like `cs_...`.
49
+
50
+ ## Authentication
51
+
52
+ The API key is resolved in this order:
53
+
54
+ 1. the `api_key=` argument (or `--api-key` on the CLI)
55
+ 2. the `SLIDECOLD_API_KEY` environment variable
56
+ 3. the config file written by `slidecold login`: `~/.config/slidecold/config.json`
57
+ (`$XDG_CONFIG_HOME/slidecold/config.json` if set, `%APPDATA%\slidecold\config.json` on Windows)
58
+
59
+ The base URL follows the same order (`base_url=`, `SLIDECOLD_BASE_URL`, config `baseUrl`), then defaults to
60
+ `https://slidecold.com/api/v1`. The config file is `{"apiKey": "cs_...", "baseUrl": "..."}`, the same format
61
+ the SlideCold Node and Go CLIs use.
62
+
63
+ ## SDK quickstart
64
+
65
+ ```python
66
+ from slidecold import SlideCold
67
+
68
+ with SlideCold() as client: # or SlideCold(api_key="cs_...")
69
+ for campaign in client.campaigns.list():
70
+ print(campaign["id"], campaign["name"], campaign["status"])
71
+
72
+ campaign = client.campaigns.create(
73
+ name="Spring outreach",
74
+ audienceIds=["aud_123"],
75
+ accountIds=["acc_456"],
76
+ templates=[{"body": "Hey, quick question about your page"}],
77
+ )
78
+ print(client.campaigns.stats(campaign["id"]))
79
+
80
+ csv_text = client.audiences.export("aud_123") # str
81
+ members = client.audiences.list_members("aud_123", min_followers=1000, limit=50)
82
+ client.conversations.send_reply("conv_789", message="Thanks, sending details now")
83
+ client.conversations.send_photo("conv_789", file="offer.jpg")
84
+ ```
85
+
86
+ Request bodies use the API's field names. Pass them as keyword arguments, as a dict, or both:
87
+ `client.campaigns.update(id, {"status": "ACTIVE"})` is the same as `client.campaigns.update(id, status="ACTIVE")`.
88
+ Path parameters are positional and query parameters are snake_case keyword arguments. Every method's
89
+ docstring lists the scope it needs. Responses are plain dicts and lists, typed with the TypedDicts in
90
+ `slidecold.types`.
91
+
92
+ ### Async
93
+
94
+ ```python
95
+ import asyncio
96
+ from slidecold import AsyncSlideCold
97
+
98
+
99
+ async def main():
100
+ async with AsyncSlideCold() as client:
101
+ accounts, campaigns = await asyncio.gather(client.accounts.list(), client.campaigns.list())
102
+
103
+
104
+ asyncio.run(main())
105
+ ```
106
+
107
+ ### Instagram login jobs
108
+
109
+ Adding an account, re-logging in and session checks run in the background and return `{"jobId": ...}`.
110
+ `wait_for_login_job` polls `accounts.login_status` until the job finishes:
111
+
112
+ ```python
113
+ job = client.accounts.create(username="mybrand", password="...", proxyId="prx_1")
114
+ status = client.wait_for_login_job(job["jobId"], interval=2, timeout=120)
115
+ if status["state"] == "failed":
116
+ print(status.get("error"))
117
+ ```
118
+
119
+ It returns the final status (`completed`, `failed` or `unknown`) and raises `TimeoutError` after `timeout` seconds.
120
+ `AsyncSlideCold.wait_for_login_job` is the async version.
121
+
122
+ ### Options
123
+
124
+ `SlideCold(api_key=None, base_url=None, timeout=30, max_retries=2)`. GET requests are retried on 429, 5xx and
125
+ connection errors with exponential backoff (honouring `Retry-After`). Other methods are never retried, so a
126
+ DM is never sent twice. `client.request("operationId", path=..., query=..., body=...)` calls any operation by
127
+ its OpenAPI `operationId`.
128
+
129
+ ## Scopes
130
+
131
+ Each API key has scopes of the form `<resource>:<read|write>` for `accounts`, `audiences`, `campaigns` and
132
+ `conversations`. A `:write` scope also grants `:read`. Proxies use the `accounts` scopes. The scope for each
133
+ operation is shown in method docstrings, in `slidecold <resource> <action> --help`, and in each MCP tool's
134
+ description.
135
+
136
+ ## Errors
137
+
138
+ All errors derive from `slidecold.SlideColdError` and carry `status`, `message` (the API's `error` string)
139
+ and `body` (the parsed response).
140
+
141
+ | Exception | When |
142
+ | --- | --- |
143
+ | `AuthenticationError` | 401: the key is missing or invalid |
144
+ | `PermissionDeniedError` | 403: the key lacks a scope (`This API key is missing the campaigns:write scope`) or a plan limit was hit |
145
+ | `NotFoundError` | 404 |
146
+ | `RateLimitError` | 429 |
147
+ | `APIError` | any other error status (base class of the four above) |
148
+ | `APIConnectionError` | no response: DNS, connection or timeout |
149
+
150
+ ```python
151
+ from slidecold import PermissionDeniedError
152
+
153
+ try:
154
+ client.campaigns.delete("cmp_1")
155
+ except PermissionDeniedError as e:
156
+ print(e.status, e.message)
157
+ ```
158
+
159
+ ## Webhooks
160
+
161
+ SlideCold signs each webhook with an `X-SlideCold-Signature` header: the hex HMAC-SHA256 of the raw JSON
162
+ body, keyed with the endpoint secret (`whsec_...`). Verify it against the raw bytes, before parsing:
163
+
164
+ ```python
165
+ from slidecold import verify_webhook_signature
166
+
167
+
168
+ @app.post("/webhooks/slidecold") # Flask
169
+ def slidecold_webhook():
170
+ if not verify_webhook_signature(
171
+ request.get_data(), request.headers.get("X-SlideCold-Signature", ""), WHSEC
172
+ ):
173
+ abort(401)
174
+ event = request.get_json()
175
+ ...
176
+ ```
177
+
178
+ ## CLI
179
+
180
+ ```bash
181
+ slidecold login # prompts for the key, checks it, saves it (0600)
182
+ slidecold login --key cs_... # non-interactive
183
+ slidecold logout
184
+
185
+ slidecold campaigns list
186
+ slidecold campaigns list --archived true
187
+ slidecold campaigns get cmp_123
188
+ slidecold campaigns stats cmp_123
189
+ slidecold campaigns create --data '{"name": "Spring", "audienceIds": ["aud_1"], "accountIds": ["acc_1"], "templates": [{"body": "Hi!"}]}'
190
+ slidecold campaigns update cmp_123 --data @patch.json
191
+ slidecold audiences list-members aud_1 --min-followers 1000 --limit 50
192
+ slidecold audiences export aud_1 > members.csv
193
+ slidecold audiences import-members aud_1 --file members.csv
194
+ slidecold conversations send-photo conv_1 --file offer.jpg
195
+ slidecold accounts login-status --job-id job_1
196
+ ```
197
+
198
+ The shape is `slidecold <resource> <action> [ids] [--param value] [--data JSON | --data @file.json] [--file path]`.
199
+ Run `slidecold --help`, `slidecold campaigns --help` or `slidecold campaigns create --help` (which shows the
200
+ required scope). Output is pretty-printed JSON, or raw CSV for exports. Errors go to stderr with exit code 1.
201
+ `--api-key` and `--base-url` override the key and URL for one command.
202
+
203
+ ## MCP server
204
+
205
+ `slidecold-mcp` is a stdio MCP server with one tool per API operation (`list_campaigns`,
206
+ `get_campaign_stats`, `send_conversation_reply`, ...). Read-only tools are marked `readOnlyHint`, and deletes
207
+ and DM sends are marked `destructiveHint`. Upload tools take a local `file_path`.
208
+
209
+ **Claude Code**
210
+
211
+ ```bash
212
+ claude mcp add slidecold -e SLIDECOLD_API_KEY=cs_... -- uvx --from 'slidecold[mcp]' slidecold-mcp
213
+ ```
214
+
215
+ **Claude Desktop** (`claude_desktop_config.json`)
216
+
217
+ ```json
218
+ {
219
+ "mcpServers": {
220
+ "slidecold": {
221
+ "command": "uvx",
222
+ "args": ["--from", "slidecold[mcp]", "slidecold-mcp"],
223
+ "env": { "SLIDECOLD_API_KEY": "cs_..." }
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ **Cursor** (`~/.cursor/mcp.json` or `.cursor/mcp.json` in a project) uses the same `mcpServers` block as
230
+ Claude Desktop.
231
+
232
+ If you ran `slidecold login`, you can leave out `SLIDECOLD_API_KEY` and the server will use the saved key.
233
+ Give the server a key with only the scopes you want the assistant to have, for example `campaigns:read` and
234
+ `conversations:read` for a read-only assistant.
235
+
236
+ ## Development
237
+
238
+ ```bash
239
+ uv sync --all-extras
240
+ uv run python scripts/generate.py # regenerate from openapi.yaml
241
+ uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest
242
+ ```
243
+
244
+ `src/slidecold/_operations.py`, `_resources.py` and `types.py` are generated from `openapi.yaml` and committed.
245
+ CI fails if they are stale.
246
+
247
+ ## License
248
+
249
+ MIT
@@ -0,0 +1,220 @@
1
+ # SlideCold Python SDK, CLI and MCP server
2
+
3
+ The official Python client for the [SlideCold](https://slidecold.com) API (Instagram cold-DM outreach).
4
+ One package gives you:
5
+
6
+ - **SDK**: `SlideCold` and `AsyncSlideCold` clients with typed methods for every API operation.
7
+ - **CLI**: `slidecold campaigns list`, `slidecold audiences export <id>`, and so on.
8
+ - **MCP server**: `slidecold-mcp`, which gives Claude, Cursor and other MCP clients one tool per API operation.
9
+
10
+ Everything is generated from the [OpenAPI spec](openapi.yaml), so the SDK, the CLI and the MCP tools always match the API.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install slidecold # SDK and CLI (Python 3.9+, the only dependency is httpx)
16
+ pip install 'slidecold[mcp]' # also the MCP server (Python 3.10+)
17
+ ```
18
+
19
+ Create an API key at <https://slidecold.com/settings>. Keys look like `cs_...`.
20
+
21
+ ## Authentication
22
+
23
+ The API key is resolved in this order:
24
+
25
+ 1. the `api_key=` argument (or `--api-key` on the CLI)
26
+ 2. the `SLIDECOLD_API_KEY` environment variable
27
+ 3. the config file written by `slidecold login`: `~/.config/slidecold/config.json`
28
+ (`$XDG_CONFIG_HOME/slidecold/config.json` if set, `%APPDATA%\slidecold\config.json` on Windows)
29
+
30
+ The base URL follows the same order (`base_url=`, `SLIDECOLD_BASE_URL`, config `baseUrl`), then defaults to
31
+ `https://slidecold.com/api/v1`. The config file is `{"apiKey": "cs_...", "baseUrl": "..."}`, the same format
32
+ the SlideCold Node and Go CLIs use.
33
+
34
+ ## SDK quickstart
35
+
36
+ ```python
37
+ from slidecold import SlideCold
38
+
39
+ with SlideCold() as client: # or SlideCold(api_key="cs_...")
40
+ for campaign in client.campaigns.list():
41
+ print(campaign["id"], campaign["name"], campaign["status"])
42
+
43
+ campaign = client.campaigns.create(
44
+ name="Spring outreach",
45
+ audienceIds=["aud_123"],
46
+ accountIds=["acc_456"],
47
+ templates=[{"body": "Hey, quick question about your page"}],
48
+ )
49
+ print(client.campaigns.stats(campaign["id"]))
50
+
51
+ csv_text = client.audiences.export("aud_123") # str
52
+ members = client.audiences.list_members("aud_123", min_followers=1000, limit=50)
53
+ client.conversations.send_reply("conv_789", message="Thanks, sending details now")
54
+ client.conversations.send_photo("conv_789", file="offer.jpg")
55
+ ```
56
+
57
+ Request bodies use the API's field names. Pass them as keyword arguments, as a dict, or both:
58
+ `client.campaigns.update(id, {"status": "ACTIVE"})` is the same as `client.campaigns.update(id, status="ACTIVE")`.
59
+ Path parameters are positional and query parameters are snake_case keyword arguments. Every method's
60
+ docstring lists the scope it needs. Responses are plain dicts and lists, typed with the TypedDicts in
61
+ `slidecold.types`.
62
+
63
+ ### Async
64
+
65
+ ```python
66
+ import asyncio
67
+ from slidecold import AsyncSlideCold
68
+
69
+
70
+ async def main():
71
+ async with AsyncSlideCold() as client:
72
+ accounts, campaigns = await asyncio.gather(client.accounts.list(), client.campaigns.list())
73
+
74
+
75
+ asyncio.run(main())
76
+ ```
77
+
78
+ ### Instagram login jobs
79
+
80
+ Adding an account, re-logging in and session checks run in the background and return `{"jobId": ...}`.
81
+ `wait_for_login_job` polls `accounts.login_status` until the job finishes:
82
+
83
+ ```python
84
+ job = client.accounts.create(username="mybrand", password="...", proxyId="prx_1")
85
+ status = client.wait_for_login_job(job["jobId"], interval=2, timeout=120)
86
+ if status["state"] == "failed":
87
+ print(status.get("error"))
88
+ ```
89
+
90
+ It returns the final status (`completed`, `failed` or `unknown`) and raises `TimeoutError` after `timeout` seconds.
91
+ `AsyncSlideCold.wait_for_login_job` is the async version.
92
+
93
+ ### Options
94
+
95
+ `SlideCold(api_key=None, base_url=None, timeout=30, max_retries=2)`. GET requests are retried on 429, 5xx and
96
+ connection errors with exponential backoff (honouring `Retry-After`). Other methods are never retried, so a
97
+ DM is never sent twice. `client.request("operationId", path=..., query=..., body=...)` calls any operation by
98
+ its OpenAPI `operationId`.
99
+
100
+ ## Scopes
101
+
102
+ Each API key has scopes of the form `<resource>:<read|write>` for `accounts`, `audiences`, `campaigns` and
103
+ `conversations`. A `:write` scope also grants `:read`. Proxies use the `accounts` scopes. The scope for each
104
+ operation is shown in method docstrings, in `slidecold <resource> <action> --help`, and in each MCP tool's
105
+ description.
106
+
107
+ ## Errors
108
+
109
+ All errors derive from `slidecold.SlideColdError` and carry `status`, `message` (the API's `error` string)
110
+ and `body` (the parsed response).
111
+
112
+ | Exception | When |
113
+ | --- | --- |
114
+ | `AuthenticationError` | 401: the key is missing or invalid |
115
+ | `PermissionDeniedError` | 403: the key lacks a scope (`This API key is missing the campaigns:write scope`) or a plan limit was hit |
116
+ | `NotFoundError` | 404 |
117
+ | `RateLimitError` | 429 |
118
+ | `APIError` | any other error status (base class of the four above) |
119
+ | `APIConnectionError` | no response: DNS, connection or timeout |
120
+
121
+ ```python
122
+ from slidecold import PermissionDeniedError
123
+
124
+ try:
125
+ client.campaigns.delete("cmp_1")
126
+ except PermissionDeniedError as e:
127
+ print(e.status, e.message)
128
+ ```
129
+
130
+ ## Webhooks
131
+
132
+ SlideCold signs each webhook with an `X-SlideCold-Signature` header: the hex HMAC-SHA256 of the raw JSON
133
+ body, keyed with the endpoint secret (`whsec_...`). Verify it against the raw bytes, before parsing:
134
+
135
+ ```python
136
+ from slidecold import verify_webhook_signature
137
+
138
+
139
+ @app.post("/webhooks/slidecold") # Flask
140
+ def slidecold_webhook():
141
+ if not verify_webhook_signature(
142
+ request.get_data(), request.headers.get("X-SlideCold-Signature", ""), WHSEC
143
+ ):
144
+ abort(401)
145
+ event = request.get_json()
146
+ ...
147
+ ```
148
+
149
+ ## CLI
150
+
151
+ ```bash
152
+ slidecold login # prompts for the key, checks it, saves it (0600)
153
+ slidecold login --key cs_... # non-interactive
154
+ slidecold logout
155
+
156
+ slidecold campaigns list
157
+ slidecold campaigns list --archived true
158
+ slidecold campaigns get cmp_123
159
+ slidecold campaigns stats cmp_123
160
+ slidecold campaigns create --data '{"name": "Spring", "audienceIds": ["aud_1"], "accountIds": ["acc_1"], "templates": [{"body": "Hi!"}]}'
161
+ slidecold campaigns update cmp_123 --data @patch.json
162
+ slidecold audiences list-members aud_1 --min-followers 1000 --limit 50
163
+ slidecold audiences export aud_1 > members.csv
164
+ slidecold audiences import-members aud_1 --file members.csv
165
+ slidecold conversations send-photo conv_1 --file offer.jpg
166
+ slidecold accounts login-status --job-id job_1
167
+ ```
168
+
169
+ The shape is `slidecold <resource> <action> [ids] [--param value] [--data JSON | --data @file.json] [--file path]`.
170
+ Run `slidecold --help`, `slidecold campaigns --help` or `slidecold campaigns create --help` (which shows the
171
+ required scope). Output is pretty-printed JSON, or raw CSV for exports. Errors go to stderr with exit code 1.
172
+ `--api-key` and `--base-url` override the key and URL for one command.
173
+
174
+ ## MCP server
175
+
176
+ `slidecold-mcp` is a stdio MCP server with one tool per API operation (`list_campaigns`,
177
+ `get_campaign_stats`, `send_conversation_reply`, ...). Read-only tools are marked `readOnlyHint`, and deletes
178
+ and DM sends are marked `destructiveHint`. Upload tools take a local `file_path`.
179
+
180
+ **Claude Code**
181
+
182
+ ```bash
183
+ claude mcp add slidecold -e SLIDECOLD_API_KEY=cs_... -- uvx --from 'slidecold[mcp]' slidecold-mcp
184
+ ```
185
+
186
+ **Claude Desktop** (`claude_desktop_config.json`)
187
+
188
+ ```json
189
+ {
190
+ "mcpServers": {
191
+ "slidecold": {
192
+ "command": "uvx",
193
+ "args": ["--from", "slidecold[mcp]", "slidecold-mcp"],
194
+ "env": { "SLIDECOLD_API_KEY": "cs_..." }
195
+ }
196
+ }
197
+ }
198
+ ```
199
+
200
+ **Cursor** (`~/.cursor/mcp.json` or `.cursor/mcp.json` in a project) uses the same `mcpServers` block as
201
+ Claude Desktop.
202
+
203
+ If you ran `slidecold login`, you can leave out `SLIDECOLD_API_KEY` and the server will use the saved key.
204
+ Give the server a key with only the scopes you want the assistant to have, for example `campaigns:read` and
205
+ `conversations:read` for a read-only assistant.
206
+
207
+ ## Development
208
+
209
+ ```bash
210
+ uv sync --all-extras
211
+ uv run python scripts/generate.py # regenerate from openapi.yaml
212
+ uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest
213
+ ```
214
+
215
+ `src/slidecold/_operations.py`, `_resources.py` and `types.py` are generated from `openapi.yaml` and committed.
216
+ CI fails if they are stale.
217
+
218
+ ## License
219
+
220
+ MIT