slab-cli 0.1.0__py3-none-any.whl

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.
slab_cli/__init__.py ADDED
File without changes
slab_cli/banner.py ADDED
@@ -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()
slab_cli/client.py ADDED
@@ -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."""
@@ -0,0 +1,140 @@
1
+ """Break commands — create and list breaks (opening sealed product).
2
+
3
+ Also provides helpers used by the collection add flow."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from slab_schemas.cards import SetSearchQuery
8
+ from slab_schemas.collection import BreakCreate, BreakOut, BreakSearchQuery, CollectionSearchQuery
9
+ from slab_schemas.enums import BreakType
10
+
11
+ from ..client import SlabClient
12
+ from ..context import ctx
13
+ from ..display import console, print_breaks, render_break_pulls
14
+ from ..prompts import (
15
+ ask_date,
16
+ ask_decimal,
17
+ ask_text,
18
+ confirm,
19
+ select_break,
20
+ select_break_type,
21
+ select_search_set,
22
+ )
23
+ from ..sources import pick_source
24
+
25
+
26
+ def pick_or_create_break(client: SlabClient, collector: str) -> BreakOut | None:
27
+ """Let the user pick an existing break or create a new one. Returns the BreakOut (its set is
28
+ used to scope the box) or None if skipped."""
29
+ breaks = client.search_breaks(collector)
30
+ if breaks.items:
31
+ choice = select_break(breaks, allow_new=True)
32
+ if choice == "new":
33
+ return create_break_interactive(client, collector)
34
+ elif isinstance(choice, BreakOut):
35
+ return choice
36
+ return None # skipped
37
+ else:
38
+ if confirm("No breaks yet. Create one?", default=True):
39
+ return create_break_interactive(client, collector)
40
+ return None
41
+
42
+
43
+ def create_break_interactive(client: SlabClient, collector: str) -> BreakOut | None:
44
+ """Walk the user through creating a break. Returns the new BreakOut or None."""
45
+ console.print(
46
+ '[dim]A break is a sealed product you opened. Find it by name — type part of the '
47
+ 'product, e.g. "Platinum" or "O-Pee-Chee" (real words, not abbreviations like "OPC").[/dim]'
48
+ )
49
+ q = ask_text("Which product did you open?")
50
+ sets = client.search_sets(SetSearchQuery(q=q, limit=15))
51
+ if not sets.items:
52
+ console.print(
53
+ f'[yellow]No products matched "{q}".[/yellow] Try fewer/real words from the name '
54
+ '(e.g. "Platinum", "Series 1", "SP Authentic").'
55
+ )
56
+ return None
57
+
58
+ set_choice = select_search_set(sets)
59
+ if set_choice is None:
60
+ return None
61
+
62
+ break_type = select_break_type()
63
+ break_date = ask_date("When did you open it?")
64
+ total_cost = ask_decimal("Total cost:")
65
+ # Offer the collector's previously-used sources for arrow-key reuse (falls back to a plain
66
+ # text prompt on the first break, when there's nothing to reuse yet).
67
+ source = pick_source(client, collector)
68
+
69
+ brk = client.create_break(
70
+ collector,
71
+ BreakCreate(
72
+ set_uuid=set_choice.uuid,
73
+ break_type=BreakType(break_type),
74
+ break_date=break_date,
75
+ total_cost=total_cost,
76
+ source=source,
77
+ ),
78
+ )
79
+ console.print(f"[green]Break created:[/green] {brk.break_date} · {brk.set_name} — ${brk.total_cost:.2f}")
80
+ console.print(
81
+ "[dim]Why it matters: as you add the cards you pulled to this break, its cost splits "
82
+ "across them — so every card carries what it actually cost you (real profit/loss later).[/dim]"
83
+ )
84
+ return brk
85
+
86
+
87
+ def cmd_break_list(args: list[str]) -> None:
88
+ """`slab break list` — your breaks, then pick one to see what it delivered (value vs cost,
89
+ the top pulls, the rarest pulls). Skippable — the list stays on screen either way."""
90
+ result = ctx.client.search_breaks(ctx.collector, BreakSearchQuery(limit=25))
91
+ print_breaks(result)
92
+ if not result.items:
93
+ return
94
+ choice = select_break(result, allow_new=False)
95
+ if isinstance(choice, BreakOut):
96
+ _show_break_pulls(choice)
97
+
98
+
99
+ def _show_break_pulls(brk: BreakOut) -> None:
100
+ """The break's report card: one collection search scoped to the break — each copy carries its
101
+ own market data — rendered as value-vs-cost + most valuable + rarest pulls."""
102
+ pulls = ctx.client.search_collection(
103
+ ctx.collector, CollectionSearchQuery(break_uuid=brk.uuid, limit=200))
104
+ render_break_pulls(brk, pulls)
105
+
106
+
107
+ def cmd_break_new(args: list[str]) -> None:
108
+ """`slab break new` — record opening a sealed product (its cost splits across the cards you
109
+ add to it, so every pull carries what it actually cost you)."""
110
+ create_break_interactive(ctx.client, ctx.collector)
111
+
112
+
113
+ def cmd_break_remove(args: list[str]) -> None:
114
+ """`slab break remove` — pick a break and remove it. The cards you pulled are NOT deleted;
115
+ they stay in your collection but are detached from the break (their break-derived cost basis
116
+ is lost, since that cost was only ever split across them while linked)."""
117
+ breaks = ctx.client.search_breaks(ctx.collector, BreakSearchQuery(limit=100))
118
+ if not breaks.items:
119
+ console.print("[slab.dim]No breaks to delete.[/]")
120
+ return
121
+
122
+ choice = select_break(breaks, allow_new=False)
123
+ if not isinstance(choice, BreakOut):
124
+ return # skipped / cancelled
125
+
126
+ console.print(
127
+ f"[yellow]Deleting:[/yellow] {choice.break_date} · {choice.set_name} — "
128
+ f"${choice.total_cost:.2f} ({choice.copy_count} card(s))"
129
+ )
130
+ if choice.copy_count:
131
+ console.print(
132
+ f"[dim]Those {choice.copy_count} card(s) stay in your collection but lose the cost "
133
+ "basis they got from this break.[/dim]"
134
+ )
135
+ if not confirm("Delete this break?", default=False):
136
+ console.print("[slab.dim]Cancelled.[/]")
137
+ return
138
+
139
+ ctx.client.delete_break(ctx.collector, choice.uuid)
140
+ console.print("[green]Break deleted.[/green]")