slab-cli 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,70 @@
1
+ Metadata-Version: 2.3
2
+ Name: slab-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
5
+ Author: dev_jeb
6
+ Requires-Dist: slab-schemas>=0.1.0
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: rich>=13.0
9
+ Requires-Dist: inquirerpy>=0.3
10
+ Requires-Python: >=3.13
11
+ Description-Content-Type: text/markdown
12
+
13
+ # slab-cli
14
+
15
+ A terminal client for the [slab](https://api.slab.dev-jeb.com) trading-card API — browse the
16
+ catalog, price cards against real sales comps, and track your own collection from the command line.
17
+
18
+ ```bash
19
+ pip install slab-cli
20
+ ```
21
+
22
+ This installs the `slab` command.
23
+
24
+ ## Setup
25
+
26
+ The CLI talks to the hosted slab API. You need an **API key** — mint one from the
27
+ [slab portal](https://app.slab.dev-jeb.com) (sign in → **API Keys** → create). Then:
28
+
29
+ ```bash
30
+ export SLAB_API_KEY=sk_live_... # your key (required)
31
+ # optional:
32
+ export SLAB_API_URL=https://api.slab.dev-jeb.com # default; override for a self-hosted API
33
+ export SLAB_COLLECTOR=<collector-uuid> # your active collector, reused across commands
34
+ ```
35
+
36
+ ## Quickstart
37
+
38
+ ```bash
39
+ # Catalog + pricing (no collector needed)
40
+ slab card search --subject "Connor Bedard" # find cards
41
+ slab card price <card-uuid> # fair-market value from sales comps
42
+ slab card comps <card-uuid> # the underlying recent sales
43
+ slab set "OPC Platinum" # a set's sealed FMV + top cards
44
+
45
+ # Your collection
46
+ slab collector create # one-time: create your collector profile
47
+ slab collection add # add an owned copy (grade, cost, acquisition)
48
+ slab collection cost # log grading/shipping costs on a copy
49
+ slab break new # record a box/case break
50
+ slab collection dashboard # portfolio value, cost basis, P&L
51
+ slab collection search # search + filter your copies
52
+ ```
53
+
54
+ Run `slab` with no arguments for the full command list, or `slab <group>` (e.g. `slab collection`)
55
+ to see a group's verbs.
56
+
57
+ ## Configuration reference
58
+
59
+ | Env var | Purpose | Default |
60
+ | --------------- | ------------------------------------------ | -------------------------------- |
61
+ | `SLAB_API_KEY` | API key for authentication (**required**) | — |
62
+ | `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
63
+ | `SLAB_COLLECTOR`| Active collector UUID, reused per command | — |
64
+
65
+ ## What it depends on
66
+
67
+ Requests and responses are typed against [`slab-schemas`](https://pypi.org/project/slab-schemas/) —
68
+ the same pydantic wire contract the API itself uses — so the CLI and API can never drift.
69
+
70
+ Requires Python 3.13+.
@@ -0,0 +1,58 @@
1
+ # slab-cli
2
+
3
+ A terminal client for the [slab](https://api.slab.dev-jeb.com) trading-card API — browse the
4
+ catalog, price cards against real sales comps, and track your own collection from the command line.
5
+
6
+ ```bash
7
+ pip install slab-cli
8
+ ```
9
+
10
+ This installs the `slab` command.
11
+
12
+ ## Setup
13
+
14
+ The CLI talks to the hosted slab API. You need an **API key** — mint one from the
15
+ [slab portal](https://app.slab.dev-jeb.com) (sign in → **API Keys** → create). Then:
16
+
17
+ ```bash
18
+ export SLAB_API_KEY=sk_live_... # your key (required)
19
+ # optional:
20
+ export SLAB_API_URL=https://api.slab.dev-jeb.com # default; override for a self-hosted API
21
+ export SLAB_COLLECTOR=<collector-uuid> # your active collector, reused across commands
22
+ ```
23
+
24
+ ## Quickstart
25
+
26
+ ```bash
27
+ # Catalog + pricing (no collector needed)
28
+ slab card search --subject "Connor Bedard" # find cards
29
+ slab card price <card-uuid> # fair-market value from sales comps
30
+ slab card comps <card-uuid> # the underlying recent sales
31
+ slab set "OPC Platinum" # a set's sealed FMV + top cards
32
+
33
+ # Your collection
34
+ slab collector create # one-time: create your collector profile
35
+ slab collection add # add an owned copy (grade, cost, acquisition)
36
+ slab collection cost # log grading/shipping costs on a copy
37
+ slab break new # record a box/case break
38
+ slab collection dashboard # portfolio value, cost basis, P&L
39
+ slab collection search # search + filter your copies
40
+ ```
41
+
42
+ Run `slab` with no arguments for the full command list, or `slab <group>` (e.g. `slab collection`)
43
+ to see a group's verbs.
44
+
45
+ ## Configuration reference
46
+
47
+ | Env var | Purpose | Default |
48
+ | --------------- | ------------------------------------------ | -------------------------------- |
49
+ | `SLAB_API_KEY` | API key for authentication (**required**) | — |
50
+ | `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
51
+ | `SLAB_COLLECTOR`| Active collector UUID, reused per command | — |
52
+
53
+ ## What it depends on
54
+
55
+ Requests and responses are typed against [`slab-schemas`](https://pypi.org/project/slab-schemas/) —
56
+ the same pydantic wire contract the API itself uses — so the CLI and API can never drift.
57
+
58
+ Requires Python 3.13+.
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "slab-cli"
3
+ version = "0.1.0"
4
+ description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
5
+ readme = "README.md"
6
+ authors = [{name = "dev_jeb"}]
7
+ requires-python = ">=3.13"
8
+ dependencies = [
9
+ "slab-schemas>=0.1.0",
10
+ "httpx>=0.27",
11
+ "rich>=13.0",
12
+ "InquirerPy>=0.3",
13
+ ]
14
+
15
+ [dependency-groups]
16
+ dev = ["pytest>=8.0"]
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.9.5,<0.10.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [[tool.uv.index]]
23
+ name = "slab-registry"
24
+ url = "https://slab-package-domain-682033488592.d.codeartifact.us-east-1.amazonaws.com/pypi/slab-package-repository/simple/"
25
+
26
+ [project.scripts]
27
+ slab = "slab_cli.main:main"
File without changes
@@ -0,0 +1,92 @@
1
+ """The slab banner — the wordmark rendered as the thing a slab IS: a graded card in its case.
2
+
3
+ A rounded foil-edged case (the same card silhouette every panel in the CLI uses), a grade label
4
+ across the top — because obviously this CLI grades a perfect GEM MT 10 — and the SLAB wordmark
5
+ printed in full gold foil: the letter faces run a top-to-bottom gradient between the portal's two
6
+ foil tokens (bright gold catching the light, fading toward dark gold), and the drop-shadow strokes
7
+ sit in dark gold so every letterform — the B's bowls especially — stays crisp instead of dissolving
8
+ into the background. Both endpoint colors come from `theme.py`, so the banner restyles with the
9
+ rest of the CLI."""
10
+
11
+ from __future__ import annotations
12
+
13
+ from rich.console import Group, RenderableType
14
+ from rich.panel import Panel
15
+ from rich.text import Text
16
+
17
+ from .theme import CARD_BOX, FOIL, FOIL_DIM
18
+
19
+ # ANSI-shadow letterform: solid blocks are the foil face, box-drawing chars the drop shadow.
20
+ _WORDMARK = """\
21
+ ███████╗██╗ █████╗ ██████╗
22
+ ██╔════╝██║ ██╔══██╗██╔══██╗
23
+ ███████╗██║ ███████║██████╔╝
24
+ ╚════██║██║ ██╔══██║██╔══██╗
25
+ ███████║███████╗██║ ██║██████╔╝
26
+ ╚══════╝╚══════╝╚═╝ ╚═╝╚═════╝"""
27
+
28
+ _SHADOW = set("╔╗╚╝═║")
29
+
30
+
31
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
32
+ h = hex_color.lstrip("#")
33
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
34
+
35
+
36
+ def _lerp_hex(a: str, b: str, t: float) -> str:
37
+ """Blend between two theme hex colors — the in-between shades of the foil gradient."""
38
+ ra, ga, ba = _hex_to_rgb(a)
39
+ rb, gb, bb = _hex_to_rgb(b)
40
+ return f"#{round(ra + (rb - ra) * t):02x}{round(ga + (gb - ga) * t):02x}{round(ba + (bb - ba) * t):02x}"
41
+
42
+
43
+ def _foil_print(art: str) -> Text:
44
+ """Print the letterform in foil: gradient gold faces, dark-gold shadow strokes.
45
+
46
+ The face gradient stops short of FOIL_DIM (t ≤ 0.6) so even the bottom row of the face stays
47
+ visibly brighter than the shadow — face and shadow must never blend or letter shapes smear."""
48
+ lines = art.splitlines()
49
+ width = max(len(line) for line in lines)
50
+ last = len(lines) - 1
51
+ t = Text()
52
+ for i, line in enumerate(lines):
53
+ face = f"bold {_lerp_hex(FOIL, FOIL_DIM, 0.6 * (i / last))}"
54
+ for ch in line.ljust(width):
55
+ t.append(ch, style=face if ch == "█" else f"{FOIL_DIM}" if ch in _SHADOW else None)
56
+ if i < last:
57
+ t.append("\n")
58
+ return t
59
+
60
+
61
+ def _grade_label(width: int) -> Text:
62
+ """The slab's grade label: what it holds on the left, the verdict in gold on the right."""
63
+ left, right = "CATALOG & COLLECTION", "GEM MT 10"
64
+ t = Text()
65
+ t.append(left, style="slab.label")
66
+ t.append(" " * max(1, width - len(left) - len(right)))
67
+ t.append(right, style="slab.foil")
68
+ return t
69
+
70
+
71
+ def _build() -> RenderableType:
72
+ width = max(len(line) for line in _WORDMARK.splitlines())
73
+ case = Panel(
74
+ Group(
75
+ _grade_label(width),
76
+ Text("─" * width, style="slab.foil_dim"),
77
+ _foil_print(_WORDMARK),
78
+ ),
79
+ box=CARD_BOX,
80
+ border_style="slab.foil_dim",
81
+ padding=(0, 2),
82
+ expand=False,
83
+ )
84
+ return Group(
85
+ Text(),
86
+ case,
87
+ Text(" trading card catalog & collection tracker", style="slab.dim"),
88
+ Text(),
89
+ )
90
+
91
+
92
+ BANNER: RenderableType = _build()
@@ -0,0 +1,386 @@
1
+ """Typed HTTP client for the slab API. Talks to the API via httpx, returns schema DTOs.
2
+
3
+ Usage:
4
+ client = SlabClient("https://api.slab.dev-jeb.com", api_key="sk_live_...")
5
+ results = client.search_cards(CardSearchQuery(subject="McDavid", rookie=True))
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+ from slab_schemas.account import MeOut
12
+ from slab_schemas.cards import (
13
+ CardOut,
14
+ CardSearchQuery,
15
+ CardSearchResult,
16
+ SetSearchQuery,
17
+ SetSearchResult,
18
+ )
19
+ from slab_schemas.community import CommunityBoard
20
+ from slab_schemas.collection import (
21
+ BreakCreate,
22
+ BreakOut,
23
+ BreakSearchQuery,
24
+ BreakSearchResult,
25
+ BreakUpdate,
26
+ LotCreate,
27
+ LotOut,
28
+ LotSearchQuery,
29
+ LotSearchResult,
30
+ LotUpdate,
31
+ CardCopyCostCreate,
32
+ CardCopyCostOut,
33
+ CardCopyCostUpdate,
34
+ CardCopyCreate,
35
+ CardCopyOut,
36
+ CardCopyUpdate,
37
+ CollectionResult,
38
+ CollectionSearchQuery,
39
+ CollectorCreate,
40
+ CollectorOut,
41
+ CollectorUpdate,
42
+ )
43
+ from slab_schemas.custom_sets import (
44
+ CustomSetCardAdd,
45
+ CustomSetCardOut,
46
+ CustomSetCreate,
47
+ CustomSetDetail,
48
+ CustomSetOut,
49
+ CustomSetSearchQuery,
50
+ CustomSetSearchResult,
51
+ CustomSetUpdate,
52
+ )
53
+ from slab_schemas.dashboard import CatalogStats, DashboardStats
54
+ from slab_schemas.pricing import CardComps, CardMarket, SetTopCards
55
+ from slab_schemas.sealed import SealedMarket, SealedPriceHistory, SealedProductOut
56
+ from slab_schemas.timeseries import CardPriceHistory, PortfolioHistory
57
+
58
+
59
+ class ApiError(Exception):
60
+ """An error returned by the API (4xx/5xx)."""
61
+
62
+ def __init__(self, status: int, detail: str):
63
+ self.status = status
64
+ self.detail = detail
65
+ super().__init__(f"[{status}] {detail}")
66
+
67
+
68
+ class ApiConnectionError(Exception):
69
+ """Cannot reach the API."""
70
+
71
+ def __init__(self, url: str, reason: str):
72
+ self.url = url
73
+ super().__init__(f"Cannot connect to {url}: {reason}")
74
+
75
+
76
+ class SlabClient:
77
+ """Typed client for the slab API."""
78
+
79
+ def __init__(self, base_url: str, api_key: str | None = None):
80
+ headers = {}
81
+ if api_key:
82
+ headers["x-api-key"] = api_key
83
+ self._base_url = base_url
84
+ self._http = httpx.Client(base_url=base_url, headers=headers, timeout=30)
85
+
86
+ def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
87
+ try:
88
+ resp = self._http.request(method, path, **kwargs)
89
+ except httpx.ConnectError:
90
+ raise ApiConnectionError(self._base_url, "connection refused — is the API running?")
91
+ except httpx.TimeoutException:
92
+ raise ApiConnectionError(self._base_url, "request timed out")
93
+ except httpx.HTTPError as e:
94
+ raise ApiConnectionError(self._base_url, str(e))
95
+
96
+ if resp.status_code >= 400:
97
+ detail = resp.text
98
+ try:
99
+ body = resp.json()
100
+ if isinstance(body, dict) and "detail" in body:
101
+ detail = body["detail"]
102
+ except Exception:
103
+ pass
104
+ raise ApiError(resp.status_code, detail)
105
+ return resp
106
+
107
+ # --- catalog ---
108
+
109
+ def search_cards(self, query: CardSearchQuery) -> CardSearchResult:
110
+ resp = self._request("POST", "/cards/search", json=query.model_dump(mode="json", exclude_none=True))
111
+ return CardSearchResult.model_validate(resp.json())
112
+
113
+ def get_parallels(self, card_uuid: str) -> list[CardOut]:
114
+ resp = self._request("GET", f"/cards/{card_uuid}/parallels")
115
+ return [CardOut.model_validate(c) for c in resp.json()]
116
+
117
+ def get_card_market(self, card_uuid: str) -> CardMarket:
118
+ resp = self._request("GET", f"/cards/{card_uuid}/market")
119
+ return CardMarket.model_validate(resp.json())
120
+
121
+ def get_card_comps(
122
+ self, card_uuid: str, grade_key: str | None = None, limit: int = 50
123
+ ) -> CardComps:
124
+ params: dict[str, str] = {"limit": str(limit)}
125
+ if grade_key:
126
+ params["grade_key"] = grade_key
127
+ resp = self._request("GET", f"/cards/{card_uuid}/comps", params=params)
128
+ return CardComps.model_validate(resp.json())
129
+
130
+ def get_card_price_history(
131
+ self,
132
+ card_uuid: str,
133
+ grade_key: str = "RAW",
134
+ finish: str | None = None,
135
+ start: str | None = None,
136
+ end: str | None = None,
137
+ interval: str = "daily",
138
+ ) -> CardPriceHistory:
139
+ params: dict[str, str] = {"grade_key": grade_key, "interval": interval}
140
+ if finish:
141
+ params["finish"] = finish
142
+ if start:
143
+ params["start"] = start
144
+ if end:
145
+ params["end"] = end
146
+ resp = self._request("GET", f"/cards/{card_uuid}/price-history", params=params)
147
+ return CardPriceHistory.model_validate(resp.json())
148
+
149
+ def get_portfolio_history(
150
+ self,
151
+ collector_uuid: str,
152
+ start: str | None = None,
153
+ end: str | None = None,
154
+ interval: str = "daily",
155
+ ) -> PortfolioHistory:
156
+ params: dict[str, str] = {"interval": interval}
157
+ if start:
158
+ params["start"] = start
159
+ if end:
160
+ params["end"] = end
161
+ resp = self._request("GET", f"/collectors/{collector_uuid}/portfolio/history", params=params)
162
+ return PortfolioHistory.model_validate(resp.json())
163
+
164
+ def search_sets(self, query: SetSearchQuery) -> SetSearchResult:
165
+ resp = self._request("POST", "/sets/search", json=query.model_dump(mode="json", exclude_none=True))
166
+ return SetSearchResult.model_validate(resp.json())
167
+
168
+ # --- sealed / set market ---
169
+
170
+ def get_set_sealed(self, set_uuid: str) -> list[SealedProductOut]:
171
+ """The set's sealed SKUs, each with its headline (latest) market value."""
172
+ resp = self._request("GET", f"/sets/{set_uuid}/sealed")
173
+ return [SealedProductOut.model_validate(p) for p in resp.json()]
174
+
175
+ def get_set_top_cards(self, set_uuid: str, limit: int = 10) -> SetTopCards:
176
+ resp = self._request("GET", f"/sets/{set_uuid}/top-cards", params={"limit": str(limit)})
177
+ return SetTopCards.model_validate(resp.json())
178
+
179
+ def get_sealed_market(self, product_uuid: str, comps_limit: int = 25) -> SealedMarket:
180
+ resp = self._request(
181
+ "GET", f"/sealed/{product_uuid}/market", params={"comps_limit": str(comps_limit)}
182
+ )
183
+ return SealedMarket.model_validate(resp.json())
184
+
185
+ def get_sealed_price_history(
186
+ self,
187
+ product_uuid: str,
188
+ start: str | None = None,
189
+ end: str | None = None,
190
+ interval: str = "daily",
191
+ ) -> SealedPriceHistory:
192
+ params: dict[str, str] = {"interval": interval}
193
+ if start:
194
+ params["start"] = start
195
+ if end:
196
+ params["end"] = end
197
+ resp = self._request("GET", f"/sealed/{product_uuid}/price-history", params=params)
198
+ return SealedPriceHistory.model_validate(resp.json())
199
+
200
+ # --- account / identity ---
201
+
202
+ def get_account_context(self) -> MeOut:
203
+ """Resolve the account behind this API key — its collectors and default collector. Used to
204
+ pick a default collector when SLAB_COLLECTOR is unset."""
205
+ resp = self._request("GET", "/account")
206
+ return MeOut.model_validate(resp.json())
207
+
208
+ # --- collectors ---
209
+
210
+ def create_collector(self, name: str) -> CollectorOut:
211
+ resp = self._request("POST", "/collectors", json=CollectorCreate(name=name).model_dump(mode="json"))
212
+ return CollectorOut.model_validate(resp.json())
213
+
214
+ def rename_collector(self, collector_uuid: str, name: str) -> CollectorOut:
215
+ resp = self._request(
216
+ "PATCH", f"/collectors/{collector_uuid}", json=CollectorUpdate(name=name).model_dump(mode="json")
217
+ )
218
+ return CollectorOut.model_validate(resp.json())
219
+
220
+ # --- breaks ---
221
+
222
+ def create_break(self, collector_uuid: str, payload: BreakCreate) -> BreakOut:
223
+ resp = self._request(
224
+ "POST", f"/collectors/{collector_uuid}/breaks", json=payload.model_dump(mode="json", exclude_none=True)
225
+ )
226
+ return BreakOut.model_validate(resp.json())
227
+
228
+ def search_breaks(self, collector_uuid: str, query: BreakSearchQuery | None = None) -> BreakSearchResult:
229
+ body = (query or BreakSearchQuery()).model_dump(mode="json", exclude_none=True)
230
+ resp = self._request("POST", f"/collectors/{collector_uuid}/breaks/search", json=body)
231
+ return BreakSearchResult.model_validate(resp.json())
232
+
233
+ def update_break(self, collector_uuid: str, break_uuid: str, payload: BreakUpdate) -> BreakOut:
234
+ resp = self._request(
235
+ "PATCH",
236
+ f"/collectors/{collector_uuid}/breaks/{break_uuid}",
237
+ json=payload.model_dump(mode="json", exclude_unset=True),
238
+ )
239
+ return BreakOut.model_validate(resp.json())
240
+
241
+ def delete_break(self, collector_uuid: str, break_uuid: str) -> None:
242
+ self._request("DELETE", f"/collectors/{collector_uuid}/breaks/{break_uuid}")
243
+
244
+ # --- lots (a single multi-card purchase) ---
245
+
246
+ def create_lot(self, collector_uuid: str, payload: LotCreate) -> LotOut:
247
+ resp = self._request(
248
+ "POST", f"/collectors/{collector_uuid}/lots", json=payload.model_dump(mode="json", exclude_none=True)
249
+ )
250
+ return LotOut.model_validate(resp.json())
251
+
252
+ def search_lots(self, collector_uuid: str, query: LotSearchQuery | None = None) -> LotSearchResult:
253
+ body = (query or LotSearchQuery()).model_dump(mode="json", exclude_none=True)
254
+ resp = self._request("POST", f"/collectors/{collector_uuid}/lots/search", json=body)
255
+ return LotSearchResult.model_validate(resp.json())
256
+
257
+ def update_lot(self, collector_uuid: str, lot_uuid: str, payload: LotUpdate) -> LotOut:
258
+ resp = self._request(
259
+ "PATCH",
260
+ f"/collectors/{collector_uuid}/lots/{lot_uuid}",
261
+ json=payload.model_dump(mode="json", exclude_unset=True),
262
+ )
263
+ return LotOut.model_validate(resp.json())
264
+
265
+ def delete_lot(self, collector_uuid: str, lot_uuid: str) -> None:
266
+ self._request("DELETE", f"/collectors/{collector_uuid}/lots/{lot_uuid}")
267
+
268
+ # --- copies ---
269
+
270
+ def add_copy(self, collector_uuid: str, payload: CardCopyCreate) -> CardCopyOut:
271
+ resp = self._request(
272
+ "POST", f"/collectors/{collector_uuid}/copies", json=payload.model_dump(mode="json", exclude_none=True)
273
+ )
274
+ return CardCopyOut.model_validate(resp.json())
275
+
276
+ def update_copy(self, collector_uuid: str, copy_uuid: str, payload: CardCopyUpdate) -> CardCopyOut:
277
+ resp = self._request(
278
+ "PATCH",
279
+ f"/collectors/{collector_uuid}/copies/{copy_uuid}",
280
+ json=payload.model_dump(mode="json", exclude_unset=True),
281
+ )
282
+ return CardCopyOut.model_validate(resp.json())
283
+
284
+ def delete_copy(self, collector_uuid: str, copy_uuid: str) -> None:
285
+ self._request("DELETE", f"/collectors/{collector_uuid}/copies/{copy_uuid}")
286
+
287
+ # --- collection search ---
288
+
289
+ def search_collection(self, collector_uuid: str, query: CollectionSearchQuery | None = None) -> CollectionResult:
290
+ body = (query or CollectionSearchQuery()).model_dump(mode="json", exclude_none=True)
291
+ resp = self._request("POST", f"/collectors/{collector_uuid}/collection/search", json=body)
292
+ return CollectionResult.model_validate(resp.json())
293
+
294
+ # --- costs ---
295
+
296
+ def add_cost(self, collector_uuid: str, copy_uuid: str, payload: CardCopyCostCreate) -> CardCopyCostOut:
297
+ resp = self._request(
298
+ "POST",
299
+ f"/collectors/{collector_uuid}/copies/{copy_uuid}/costs",
300
+ json=payload.model_dump(mode="json", exclude_none=True),
301
+ )
302
+ return CardCopyCostOut.model_validate(resp.json())
303
+
304
+ def update_cost(
305
+ self, collector_uuid: str, copy_uuid: str, cost_uuid: str, payload: CardCopyCostUpdate
306
+ ) -> CardCopyCostOut:
307
+ resp = self._request(
308
+ "PATCH",
309
+ f"/collectors/{collector_uuid}/copies/{copy_uuid}/costs/{cost_uuid}",
310
+ json=payload.model_dump(mode="json", exclude_unset=True),
311
+ )
312
+ return CardCopyCostOut.model_validate(resp.json())
313
+
314
+ def delete_cost(self, collector_uuid: str, copy_uuid: str, cost_uuid: str) -> None:
315
+ self._request("DELETE", f"/collectors/{collector_uuid}/copies/{copy_uuid}/costs/{cost_uuid}")
316
+
317
+ # --- dashboards ---
318
+
319
+ def get_dashboard(self, collector_uuid: str) -> DashboardStats:
320
+ resp = self._request("GET", f"/collectors/{collector_uuid}/dashboard")
321
+ return DashboardStats.model_validate(resp.json())
322
+
323
+ def get_sources(self, collector_uuid: str) -> list[str]:
324
+ """Distinct acquisition sources this collector has used (breaks + copies)."""
325
+ resp = self._request("GET", f"/collectors/{collector_uuid}/sources")
326
+ return resp.json()
327
+
328
+ def get_catalog_stats(self) -> CatalogStats:
329
+ resp = self._request("GET", "/stats")
330
+ return CatalogStats.model_validate(resp.json())
331
+
332
+ def get_community_board(self, limit: int = 10) -> CommunityBoard:
333
+ resp = self._request("GET", "/community", params={"limit": limit})
334
+ return CommunityBoard.model_validate(resp.json())
335
+
336
+ # --- custom sets (collector-scoped) ---
337
+
338
+ def create_custom_set(self, collector_uuid: str, payload: CustomSetCreate) -> CustomSetOut:
339
+ resp = self._request(
340
+ "POST", f"/collectors/{collector_uuid}/custom-sets", json=payload.model_dump(mode="json", exclude_none=True)
341
+ )
342
+ return CustomSetOut.model_validate(resp.json())
343
+
344
+ def update_custom_set(self, collector_uuid: str, set_uuid: str, payload: CustomSetUpdate) -> CustomSetOut:
345
+ resp = self._request(
346
+ "PATCH",
347
+ f"/collectors/{collector_uuid}/custom-sets/{set_uuid}",
348
+ json=payload.model_dump(mode="json", exclude_unset=True),
349
+ )
350
+ return CustomSetOut.model_validate(resp.json())
351
+
352
+ def delete_custom_set(self, collector_uuid: str, set_uuid: str) -> None:
353
+ self._request("DELETE", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}")
354
+
355
+ def add_set_card(self, collector_uuid: str, set_uuid: str, payload: CustomSetCardAdd) -> CustomSetCardOut:
356
+ resp = self._request(
357
+ "POST",
358
+ f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/cards",
359
+ json=payload.model_dump(mode="json", exclude_none=True),
360
+ )
361
+ return CustomSetCardOut.model_validate(resp.json())
362
+
363
+ def delete_set_card(self, collector_uuid: str, set_uuid: str, entry_uuid: str) -> None:
364
+ self._request("DELETE", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/cards/{entry_uuid}")
365
+
366
+ def subscribe(self, collector_uuid: str, set_uuid: str) -> None:
367
+ self._request("POST", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/subscribe")
368
+
369
+ def unsubscribe(self, collector_uuid: str, set_uuid: str) -> None:
370
+ self._request("DELETE", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/subscribe")
371
+
372
+ def collector_custom_sets(self, collector_uuid: str) -> list[CustomSetOut]:
373
+ resp = self._request("GET", f"/collectors/{collector_uuid}/custom-sets")
374
+ return [CustomSetOut.model_validate(s) for s in resp.json()]
375
+
376
+ # --- custom sets (unscoped — discovery/viewing) ---
377
+
378
+ def search_custom_sets(self, query: CustomSetSearchQuery | None = None) -> CustomSetSearchResult:
379
+ body = (query or CustomSetSearchQuery()).model_dump(mode="json", exclude_none=True)
380
+ resp = self._request("POST", "/custom-sets/search", json=body)
381
+ return CustomSetSearchResult.model_validate(resp.json())
382
+
383
+ def get_custom_set(self, set_uuid: str, collector_uuid: str | None = None) -> CustomSetDetail:
384
+ params = {"collector_uuid": collector_uuid} if collector_uuid else {}
385
+ resp = self._request("GET", f"/custom-sets/{set_uuid}", params=params)
386
+ return CustomSetDetail.model_validate(resp.json())
@@ -0,0 +1,4 @@
1
+ """Command implementations, split by domain.
2
+
3
+ The command SURFACE (what maps to what) lives in `registry.py` — the single source of truth that
4
+ drives both dispatch and the help screens. These modules hold the handlers it points at."""