slab-cli 0.24.0__tar.gz → 0.26.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.
Files changed (35) hide show
  1. {slab_cli-0.24.0 → slab_cli-0.26.0}/PKG-INFO +3 -2
  2. {slab_cli-0.24.0 → slab_cli-0.26.0}/README.md +1 -0
  3. {slab_cli-0.24.0 → slab_cli-0.26.0}/pyproject.toml +2 -2
  4. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/client.py +47 -14
  5. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/custom_sets.py +24 -0
  6. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/pricing.py +42 -96
  7. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/registry.py +2 -1
  8. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/config.py +6 -0
  9. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/display.py +74 -72
  10. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/prompts.py +60 -15
  11. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/theme.py +11 -3
  12. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/__init__.py +0 -0
  13. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/banner.py +0 -0
  14. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/browse.py +0 -0
  15. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/__init__.py +0 -0
  16. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/breaks.py +0 -0
  17. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/catalog.py +0 -0
  18. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/collection.py +0 -0
  19. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/export.py +0 -0
  20. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/lots.py +0 -0
  21. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/setup.py +0 -0
  22. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/update.py +0 -0
  23. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/context.py +0 -0
  24. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/flags.py +0 -0
  25. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/help.py +0 -0
  26. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/main.py +0 -0
  27. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/nav.py +0 -0
  28. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/paging.py +0 -0
  29. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/picker.py +0 -0
  30. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/platforms/__init__.py +0 -0
  31. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/platforms/base.py +0 -0
  32. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/platforms/posix.py +0 -0
  33. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/platforms/windows.py +0 -0
  34. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/sources.py +0 -0
  35. {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/updates.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slab-cli
3
- Version: 0.24.0
3
+ Version: 0.26.0
4
4
  Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
5
5
  Author: dev_jeb
6
- Requires-Dist: slab-schemas>=0.41.0
6
+ Requires-Dist: slab-schemas>=0.50.0
7
7
  Requires-Dist: httpx>=0.27
8
8
  Requires-Dist: rich>=13.0
9
9
  Requires-Dist: inquirerpy>=0.3
@@ -61,6 +61,7 @@ to see a group's verbs.
61
61
  | `SLAB_API_KEY` | API key for authentication (**required**) | — |
62
62
  | `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
63
63
  | `SLAB_COLLECTOR` | Active collector UUID, reused per command | — |
64
+ | `SLAB_APP_URL` | Where `slab chase share` links open | `https://collection.slab.dev-jeb.com` |
64
65
  | `SLAB_NO_UPDATE_CHECK` | Set to `1` to silence update checks | — |
65
66
 
66
67
  ## Staying up to date
@@ -49,6 +49,7 @@ to see a group's verbs.
49
49
  | `SLAB_API_KEY` | API key for authentication (**required**) | — |
50
50
  | `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
51
51
  | `SLAB_COLLECTOR` | Active collector UUID, reused per command | — |
52
+ | `SLAB_APP_URL` | Where `slab chase share` links open | `https://collection.slab.dev-jeb.com` |
52
53
  | `SLAB_NO_UPDATE_CHECK` | Set to `1` to silence update checks | — |
53
54
 
54
55
  ## Staying up to date
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.24.0"
3
+ version = "0.26.0"
4
4
  description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
5
5
  readme = "README.md"
6
6
  authors = [{name = "dev_jeb"}]
@@ -12,7 +12,7 @@ dependencies = [
12
12
  # 0.34 reframes the grading desk from verdicts to break-even readings (BreakEvenRegion,
13
13
  # GradingDesk.break_even) — the desk shows data, never tells the user to grade.
14
14
  # 0.35 adds Liquidity/LiquidityLabel, which the market view's Sells column renders.
15
- "slab-schemas>=0.41.0", # CardSerials (slab card serials)
15
+ "slab-schemas>=0.50.0", # metrics registry: served momentum/trend labels
16
16
  "httpx>=0.27",
17
17
  "rich>=13.0",
18
18
  "InquirerPy>=0.3",
@@ -18,6 +18,7 @@ from slab_schemas.cards import (
18
18
  )
19
19
  from slab_schemas.community import CommunityBoard
20
20
  from slab_schemas.serials import CardSerials
21
+ from slab_schemas.snapshots import SnapshotRef
21
22
  from slab_schemas.collection import (
22
23
  BreakCreate,
23
24
  BreakOut,
@@ -27,16 +28,17 @@ from slab_schemas.collection import (
27
28
  LotOut,
28
29
  LotSearchQuery,
29
30
  LotSearchResult,
30
- LotUpdate,
31
31
  CardCopyCostCreate,
32
32
  CardCopyCostOut,
33
33
  CardCopyCostUpdate,
34
34
  CardCopyCreate,
35
35
  CardCopyOut,
36
36
  CardCopyUpdate,
37
+ CollectionGroupQuery,
37
38
  CollectionResult,
38
39
  CollectionSearchQuery,
39
40
  CollectorOut,
41
+ SetGroupResult,
40
42
  )
41
43
  from slab_schemas.custom_sets import (
42
44
  CustomSetCardAdd,
@@ -49,11 +51,12 @@ from slab_schemas.custom_sets import (
49
51
  CustomSetSearchResult,
50
52
  CustomSetUpdate,
51
53
  )
52
- from slab_schemas.dashboard import CatalogStats, DashboardStats
54
+ from slab_schemas.dashboard import DashboardStats
53
55
  from slab_schemas.grading_desk import CollectionGradingDesk, GradingDesk
54
56
  from slab_schemas.pricing import CardComps, CardMarket, SetTopCards
55
57
  from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
56
58
  from slab_schemas.timeseries import CardPriceHistory
59
+ from slab_schemas.vocab import VocabOut
57
60
 
58
61
 
59
62
  class ApiError(Exception):
@@ -82,6 +85,8 @@ class SlabClient:
82
85
  headers["x-api-key"] = api_key
83
86
  self._base_url = base_url
84
87
  self._http = httpx.Client(base_url=base_url, headers=headers, timeout=30)
88
+ self._vocab: VocabOut | None = None
89
+ self._vocab_fetched = False
85
90
 
86
91
  def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
87
92
  try:
@@ -207,6 +212,29 @@ class SlabClient:
207
212
  resp = self._request("GET", f"/sealed/{product_uuid}/price-history", params=params)
208
213
  return SealedPriceHistory.model_validate(resp.json())
209
214
 
215
+ # --- vocabulary (public) ---
216
+
217
+ def get_vocab(self) -> VocabOut:
218
+ """Every enumerable value the API accepts or serves — wire enums, sort grammars, and the
219
+ LIVE catalog dimensions (grading companies, attributes) that grow as sets are seeded.
220
+ Raises like any other call; `vocab()` is the forgiving, cached form pickers use."""
221
+ resp = self._request("GET", "/vocab")
222
+ return VocabOut.model_validate(resp.json())
223
+
224
+ def vocab(self) -> VocabOut | None:
225
+ """The served vocabulary, fetched at most ONCE per process and None when the API can't
226
+ answer (offline, unauthorized, down). Same contract as the portal's `getVocab()`: a
227
+ picker renders the served list when it has one and its static fallback otherwise, so a
228
+ new grading company reaches the CLI with no release — and a dead API never blocks a
229
+ prompt. The miss is cached too, so one failure doesn't retry at every prompt."""
230
+ if not self._vocab_fetched:
231
+ self._vocab_fetched = True
232
+ try:
233
+ self._vocab = self.get_vocab()
234
+ except (ApiError, ApiConnectionError):
235
+ self._vocab = None
236
+ return self._vocab
237
+
210
238
  # --- account / identity ---
211
239
 
212
240
  def get_account_context(self) -> MeOut:
@@ -244,14 +272,6 @@ class SlabClient:
244
272
  resp = self._request("POST", f"/collectors/{collector_uuid}/lots/search", json=body)
245
273
  return LotSearchResult.model_validate(resp.json())
246
274
 
247
- def update_lot(self, collector_uuid: str, lot_uuid: str, payload: LotUpdate) -> LotOut:
248
- resp = self._request(
249
- "PATCH",
250
- f"/collectors/{collector_uuid}/lots/{lot_uuid}",
251
- json=payload.model_dump(mode="json", exclude_unset=True),
252
- )
253
- return LotOut.model_validate(resp.json())
254
-
255
275
  def delete_lot(self, collector_uuid: str, lot_uuid: str) -> None:
256
276
  self._request("DELETE", f"/collectors/{collector_uuid}/lots/{lot_uuid}")
257
277
 
@@ -281,6 +301,17 @@ class SlabClient:
281
301
  resp = self._request("POST", f"/collectors/{collector_uuid}/collection/search", json=body)
282
302
  return CollectionResult.model_validate(resp.json())
283
303
 
304
+ def collection_sets(
305
+ self, collector_uuid: str, query: CollectionGroupQuery | None = None
306
+ ) -> SetGroupResult:
307
+ """The collection rolled up by set — the SERVER's per-set math (copies, distinct cards,
308
+ total value), ranked most valuable first and paged over groups, so a set is never split
309
+ across a page. This is the number the portfolio view renders per set; the CLI must not
310
+ re-derive it from copies (cli/AGENTS.md, "Served numbers, not client math")."""
311
+ body = (query or CollectionGroupQuery()).model_dump(mode="json", exclude_none=True)
312
+ resp = self._request("POST", f"/collectors/{collector_uuid}/collection/sets", json=body)
313
+ return SetGroupResult.model_validate(resp.json())
314
+
284
315
  # --- costs ---
285
316
 
286
317
  def add_cost(self, collector_uuid: str, copy_uuid: str, payload: CardCopyCostCreate) -> CardCopyCostOut:
@@ -315,10 +346,6 @@ class SlabClient:
315
346
  resp = self._request("GET", f"/collectors/{collector_uuid}/sources")
316
347
  return resp.json()
317
348
 
318
- def get_catalog_stats(self) -> CatalogStats:
319
- resp = self._request("GET", "/stats")
320
- return CatalogStats.model_validate(resp.json())
321
-
322
349
  def get_community_board(self, limit: int = 10) -> CommunityBoard:
323
350
  resp = self._request("GET", "/community", params={"limit": limit})
324
351
  return CommunityBoard.model_validate(resp.json())
@@ -331,6 +358,12 @@ class SlabClient:
331
358
  )
332
359
  return CustomSetOut.model_validate(resp.json())
333
360
 
361
+ def create_custom_set_snapshot(self, set_uuid: str, collector_uuid: str | None = None) -> SnapshotRef:
362
+ """Today's tear sheet for a chase — with this collector's have/need when one is given."""
363
+ params = {"collector_uuid": collector_uuid} if collector_uuid else {}
364
+ resp = self._request("POST", f"/custom-sets/{set_uuid}/snapshots", params=params)
365
+ return SnapshotRef.model_validate(resp.json())
366
+
334
367
  def update_custom_set(self, collector_uuid: str, set_uuid: str, payload: CustomSetUpdate) -> CustomSetOut:
335
368
  resp = self._request(
336
369
  "PATCH",
@@ -18,6 +18,7 @@ from slab_schemas.custom_sets import (
18
18
  from slab_schemas.enums import CustomSetType, DynamicMatch, MatchMode, Visibility
19
19
 
20
20
  from ..client import SlabClient
21
+ from ..config import get_app_url
21
22
  from ..context import ctx
22
23
  from ..display import (
23
24
  browse_custom_sets,
@@ -385,6 +386,29 @@ def cmd_chase_view(args: list[str]) -> None:
385
386
  break
386
387
 
387
388
 
389
+ def cmd_chase_share(args: list[str]) -> None:
390
+ """Print a tear sheet: a set's whole checklist frozen as of today, at a public link."""
391
+ collector = ctx.collector
392
+ client = ctx.client
393
+
394
+ if args:
395
+ set_uuid = args[0]
396
+ else:
397
+ cs = _select_custom_set(client, collector, message="Which set to share?")
398
+ if cs is None:
399
+ return
400
+ set_uuid = cs.uuid
401
+
402
+ # Progress is opt-in: the sheet is public, and have/need is the one personal thing on it.
403
+ mine = confirm("Include your progress (which cards you have)? It never shows your name.", default=False)
404
+ ref = client.create_custom_set_snapshot(set_uuid, collector if mine else None)
405
+ link = f"{get_app_url()}/s/{ref.uuid}"
406
+ console.print(f"\n [bold]{link}[/bold]")
407
+ note = "made just now" if ref.created else "already made today — same link"
408
+ console.print(f" [dim]As of {ref.as_of:%b %-d, %Y} · {note} · "
409
+ f"{'with your progress' if ref.includes_progress else 'no progress'}[/dim]\n")
410
+
411
+
388
412
  def cmd_chase_edit(args: list[str]) -> None:
389
413
  """Edit a custom set's name, description, visibility, or (dynamic) match granularity."""
390
414
  collector = ctx.collector
@@ -2,10 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from slab_schemas.cards import SetSearchQuery
6
- from slab_schemas.collection import CollectionSearchQuery, PortfolioSummary
7
-
8
- from slab_schemas.cards import CardOut, SetOut
5
+ from slab_schemas.cards import CardOut, SetOut, SetSearchQuery
6
+ from slab_schemas.collection import (
7
+ CollectionGroupQuery,
8
+ CollectionSearchQuery,
9
+ PortfolioSummary,
10
+ SetGroupOut,
11
+ )
9
12
 
10
13
  from ..context import MissingCollector, ctx
11
14
  from ..display import (
@@ -128,57 +131,34 @@ def cmd_grade_sweep(args: list[str]) -> None:
128
131
  )
129
132
 
130
133
 
131
- def _fetch_all_copies(page: int = 200, cap: int = 5000) -> tuple[list, PortfolioSummary | None]:
132
- """Page through the whole collection for the per-set/mover/break breakdowns, and grab the
133
- server's summary block (full-result aggregates, identical on every page) from the first page —
134
- that's the source of truth for grand totals. `cap` is a safety bound so a runaway collection
135
- can't spin forever."""
136
- copies, first = fetch_all_pages(
137
- lambda limit, offset: ctx.client.search_collection(
138
- ctx.collector, CollectionSearchQuery(sort="-unrealized", limit=limit, offset=offset)
134
+ # How many top movers the portfolio view lists, and the page it reads them from: the server's
135
+ # `-unrealized` sort puts the biggest paper gains first (unpriced copies have no gain and sort
136
+ # last), so one page is the whole answer — no need to pull every copy to find the top ten.
137
+ _MOVERS_SHOWN = 10
138
+ _MOVERS_PAGE = 25
139
+
140
+
141
+ def _fetch_set_groups(page: int = 200, cap: int = 2000) -> list[SetGroupOut]:
142
+ """Every set the collection holds from, as the server's grouped rollup — copies, distinct
143
+ cards, and total value per set, most valuable first. Pages over GROUPS (a set is never split
144
+ across a page boundary) as headers only (`include_copies=False`); `cap` bounds the paging if a
145
+ collection somehow spans thousands of products."""
146
+ groups, _ = fetch_all_pages(
147
+ lambda limit, offset: ctx.client.collection_sets(
148
+ ctx.collector,
149
+ CollectionGroupQuery(
150
+ group_sort="-value", include_copies=False, limit=limit, offset=offset
151
+ ),
139
152
  ),
140
153
  page=page,
141
154
  cap=cap,
142
155
  )
143
- return copies, (first.summary if first else None)
144
-
145
-
146
- def _aggregate_by_set(copies: list) -> list[dict]:
147
- """Roll copies up per set, mirroring the server's headline math so the parts reconcile with the
148
- whole: cost basis is summed per-copy, FMV is quantity-weighted, comp coverage is copy-weighted."""
149
- sets: dict[str, dict] = {}
150
- for cp in copies:
151
- card = cp.card
152
- name = (card.set_name if card else None) or "— Unknown set —"
153
- s = sets.setdefault(name, {
154
- "name": name, "copies": 0, "total_qty": 0, "priced_qty": 0,
155
- "cost_basis": 0.0, "fmv": 0.0, "has_fmv": False,
156
- })
157
- qty = cp.quantity or 1
158
- s["copies"] += 1
159
- s["total_qty"] += qty
160
- if cp.cost_basis is not None:
161
- s["cost_basis"] += float(cp.cost_basis)
162
- if cp.market and cp.market.fair_market_value is not None:
163
- s["fmv"] += float(cp.market.fair_market_value) * qty
164
- s["priced_qty"] += qty
165
- s["has_fmv"] = True
166
-
167
- for s in sets.values():
168
- s["unrealized"] = (s["fmv"] - s["cost_basis"]) if s["has_fmv"] else None
169
- s["comp_pct"] = (s["priced_qty"] / s["total_qty"] * 100) if s["total_qty"] else 0.0
170
-
171
- # Most valuable first; sets with no market data sink to the bottom (sorted by cost basis).
172
- return sorted(
173
- sets.values(),
174
- key=lambda s: (s["has_fmv"], s["fmv"] if s["has_fmv"] else s["cost_basis"]),
175
- reverse=True,
176
- )
156
+ return groups
177
157
 
178
158
 
179
159
  def _portfolio_totals(summary: PortfolioSummary) -> dict:
180
160
  """Grand totals for the valuation panel, straight from the server's summary block — the same
181
- math the dashboard shows, so the two never diverge."""
161
+ numbers `GET .../dashboard` serves, so the two never diverge."""
182
162
  fmv = summary.portfolio_value
183
163
  return {
184
164
  "cost_basis": float(summary.total_cost_basis or 0),
@@ -193,67 +173,33 @@ def _portfolio_totals(summary: PortfolioSummary) -> dict:
193
173
  }
194
174
 
195
175
 
196
- def _most_valuable_break(copies: list, breaks: list) -> dict | None:
197
- """The break whose pulled copies carry the most market value — did opening that box pay off?
198
- FMV is summed from the copies we already priced; cost/metadata comes from the breaks list."""
199
- fmv_by_break: dict[str, float] = {}
200
- priced_by_break: dict[str, int] = {}
201
- for cp in copies:
202
- if cp.break_uuid and cp.market and cp.market.fair_market_value is not None:
203
- qty = cp.quantity or 1
204
- fmv_by_break[cp.break_uuid] = (
205
- fmv_by_break.get(cp.break_uuid, 0.0) + float(cp.market.fair_market_value) * qty
206
- )
207
- priced_by_break[cp.break_uuid] = priced_by_break.get(cp.break_uuid, 0) + qty
208
-
209
- if not fmv_by_break:
210
- return None
211
-
212
- top_uuid = max(fmv_by_break, key=fmv_by_break.get)
213
- brk = next((b for b in breaks if b.uuid == top_uuid), None)
214
- if brk is None:
215
- return None
216
-
217
- cost = float(brk.total_cost)
218
- fmv = fmv_by_break[top_uuid]
219
- return {
220
- "set_name": brk.set_name or "— Unknown set —",
221
- "break_type": brk.break_type,
222
- "break_date": brk.break_date,
223
- "cost": cost,
224
- "fmv": fmv,
225
- "gain": fmv - cost,
226
- "copy_count": brk.copy_count,
227
- "priced": priced_by_break.get(top_uuid, 0),
228
- }
229
-
230
-
231
176
  def cmd_portfolio(args: list[str]) -> None:
232
- """Portfolio-level financial summary — cost basis vs market value, per-set stats, top movers."""
233
- copies, summary = _fetch_all_copies()
234
- if not copies:
177
+ """Portfolio-level financial summary — cost basis vs market value, the per-set rollup, and
178
+ the top movers — every number as the SERVER computed it. Grand totals are the response's
179
+ `summary` block (the dashboard's math), the per-set table is `POST .../collection/sets`, and
180
+ the movers are the first page of the `-unrealized` sort. Nothing is re-derived from copies:
181
+ the old per-set client math paged a capped 5,000 copies and summed them, which both duplicated
182
+ the dashboard's definitions and went quietly wrong past the cap."""
183
+ page = ctx.client.search_collection(
184
+ ctx.collector, CollectionSearchQuery(sort="-unrealized", limit=_MOVERS_PAGE)
185
+ )
186
+ if not page.items:
235
187
  console.print("[dim]No cards in your collection yet.[/dim]")
236
188
  return
237
- if summary is None:
189
+ if page.summary is None:
238
190
  # The API always includes the summary block; its absence is a server bug — say so
239
191
  # rather than invent numbers client-side.
240
192
  console.print("[red]Server response had no portfolio summary — can't value the collection.[/red]")
241
193
  return
242
194
 
243
- by_set = _aggregate_by_set(copies)
244
- totals = _portfolio_totals(summary)
245
-
246
- # Top movers: copies with real unrealized P&L, biggest gain first (already sorted by the fetch).
195
+ # Top movers: copies with real unrealized P&L, biggest gain first (the server's sort).
247
196
  movers = [
248
- cp for cp in copies
197
+ cp for cp in page.items
249
198
  if cp.market and cp.market.unrealized_gain_loss is not None
250
- ][:10] or None
251
-
252
- breaks = ctx.client.search_breaks(ctx.collector).items
253
- mvb = _most_valuable_break(copies, breaks)
199
+ ][:_MOVERS_SHOWN] or None
254
200
 
255
201
  render_portfolio(
256
- len(copies), totals, movers=movers, by_set=by_set, most_valuable_break=mvb
202
+ page.total, _portfolio_totals(page.summary), movers=movers, by_set=_fetch_set_groups()
257
203
  )
258
204
 
259
205
 
@@ -80,7 +80,7 @@ GROUPS: list[Group] = [
80
80
  Command("cost", "Log a cost (grading, shipping) on a copy", collection.cmd_cost),
81
81
  Command("export", "Export a set to a printable checklist", export.cmd_export),
82
82
  Command("dashboard", "Overview: counts, value, composition", collection.cmd_dashboard),
83
- Command("portfolio", "Portfolio: cost basis vs FMV + top movers", pricing.cmd_portfolio),
83
+ Command("portfolio", "Portfolio: cost basis vs FMV, by set, top movers", pricing.cmd_portfolio),
84
84
  Command("grade", "Grading math for every raw copy, best upside first", pricing.cmd_grade_sweep, "[--fee N]"),
85
85
  ]),
86
86
  Group("break", "case / box breaks", [
@@ -99,6 +99,7 @@ GROUPS: list[Group] = [
99
99
  Command("create", "Create a custom set (curated or dynamic)", custom_sets.cmd_chase_create),
100
100
  Command("add", "Add cards or player slots to a curated set", custom_sets.cmd_chase_add),
101
101
  Command("view", "View a set with completion + add missing", custom_sets.cmd_chase_view, "[uuid]"),
102
+ Command("share", "Tear sheet: a dated public link to a set's checklist", custom_sets.cmd_chase_share, "[uuid]"),
102
103
  Command("edit", "Edit name, description, visibility, or granularity", custom_sets.cmd_chase_edit),
103
104
  Command("tune", "Retune one entry — match mode or player-slot qualifiers", custom_sets.cmd_chase_tune),
104
105
  Command("list", "Your sets (created + subscribed)", custom_sets.cmd_chase_list),
@@ -10,6 +10,12 @@ def get_base_url() -> str:
10
10
  return url or "https://api.slab.dev-jeb.com"
11
11
 
12
12
 
13
+ def get_app_url() -> str:
14
+ """The hosted app — where a tear sheet link opens (``/s/<uuid>``)."""
15
+ url = os.environ.get("SLAB_APP_URL", "").strip()
16
+ return (url or "https://collection.slab.dev-jeb.com").rstrip("/")
17
+
18
+
13
19
  def get_api_key() -> str | None:
14
20
  key = os.environ.get("SLAB_API_KEY", "").strip()
15
21
  return key or None
@@ -6,7 +6,7 @@ from rich.console import Group
6
6
  from rich.text import Text
7
7
 
8
8
  from slab_schemas.cards import CardOut, CardSearchResult, SetOut, SetSearchResult
9
- from slab_schemas.collection import BreakSearchResult, CollectionResult, LotSearchResult
9
+ from slab_schemas.collection import BreakSearchResult, CollectionResult, LotSearchResult, SetGroupOut
10
10
  from slab_schemas.community import (
11
11
  CollectedCard,
12
12
  CollectedPlayer,
@@ -22,6 +22,7 @@ from slab_schemas.dashboard import CatalogStats, DashboardStats, HighlightCard,
22
22
  from slab_schemas.grading_desk import CollectionGradingDesk, GradingDesk
23
23
  from slab_schemas.enums import BreakEvenRegion, Grade, LiquidityLabel, SealedFormat
24
24
  from slab_schemas.liquidity import Liquidity
25
+ from slab_schemas.metrics import momentum_label, trend_label
25
26
  from slab_schemas.pricing import CardComps, CardMarket, PortfolioSummary, SetTopCards
26
27
  from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
27
28
  from slab_schemas.serials import CardSerials
@@ -505,10 +506,29 @@ def _pull_table(pulls) -> object:
505
506
  return table
506
507
 
507
508
 
509
+ # A break's or lot's "worth today" is CLIENT math: FMV summed over the copies in the response.
510
+ # There is no server number for it (the API prices copies, not containers), so this is the one
511
+ # place the CLI adds market values up itself — and it says exactly what it added, from this ONE
512
+ # string, on both report cards. It must never read as the dashboard's portfolio math: that is
513
+ # as-of, full-collection, and served (`cli/AGENTS.md`, "Served numbers, not client math").
514
+ PAGE_NET_SCOPE = "priced copies on this page only; unpriced count as $0"
515
+
516
+
517
+ def _net_scope_line(result: CollectionResult, unpriced: int) -> str:
518
+ """The honest footnote under a break/lot money line: what the total covers and what it skips."""
519
+ parts = [f"Worth today = {PAGE_NET_SCOPE}"]
520
+ if unpriced:
521
+ parts.append(f"{unpriced} unpriced here, so the real total can only be higher")
522
+ if result.total > len(result.items):
523
+ parts.append(f"{len(result.items)} of {result.total} copies shown")
524
+ return f" [slab.dim]{' · '.join(parts)}.[/]"
525
+
526
+
508
527
  def render_break_pulls(brk, result: CollectionResult) -> None:
509
528
  """A break's report card: what you paid vs what the pulls are worth today, then the most
510
529
  valuable pulls and the rarest (lowest print run). Every number in plain words — this view
511
- answers "did the box pay for itself?" at a glance."""
530
+ answers "did the box pay for itself?" at a glance. "Worth today" is summed here from the
531
+ page's priced copies (`PAGE_NET_SCOPE`), and labeled so."""
512
532
  console.print()
513
533
  t = Text()
514
534
  t.append(brk.set_name or "Break", style="slab.value")
@@ -535,9 +555,7 @@ def render_break_pulls(brk, result: CollectionResult) -> None:
535
555
  f"\n You paid [slab.value]{money(brk.total_cost)}[/] · your {len(result.items)} pulls are "
536
556
  f"worth [slab.foil]{money(total_fmv)}[/] today → {signed_money(net)} — {verdict}."
537
557
  )
538
- if unpriced:
539
- console.print(f" [slab.dim]{unpriced} pull(s) have no market price yet and count as $0 here — "
540
- f"the real total can only be higher.[/]")
558
+ console.print(_net_scope_line(result, unpriced))
541
559
 
542
560
  top = sorted(priced, key=_fmv, reverse=True)[:10]
543
561
  if top:
@@ -589,7 +607,7 @@ def browse_lots(result: LotSearchResult, *, detail=None) -> None:
589
607
  def render_lot_contents(lot, result: CollectionResult) -> None:
590
608
  """A purchase's report card: what you paid vs what the cards are worth today, then the most
591
609
  valuable and rarest cards in it. Answers "did that bundle come out ahead?" at a glance — the
592
- lot counterpart of `render_break_pulls`."""
610
+ lot counterpart of `render_break_pulls`, same `PAGE_NET_SCOPE` label on its total."""
593
611
  console.print()
594
612
  t = Text()
595
613
  t.append(f"Purchase {lot.uuid[:8]}", style="slab.value")
@@ -614,9 +632,7 @@ def render_lot_contents(lot, result: CollectionResult) -> None:
614
632
  f"\n You paid [slab.value]{money(lot.total_cost)}[/] · its {len(result.items)} cards are "
615
633
  f"worth [slab.foil]{money(total_fmv)}[/] today → {signed_money(net)} — {verdict}."
616
634
  )
617
- if unpriced:
618
- console.print(f" [slab.dim]{unpriced} card(s) have no market price yet and count as $0 here — "
619
- f"the real total can only be higher.[/]")
635
+ console.print(_net_scope_line(result, unpriced))
620
636
 
621
637
  top = sorted(priced, key=_fmv, reverse=True)[:10]
622
638
  if top:
@@ -866,26 +882,39 @@ def _collected_players_table(entries: list[CollectedPlayer]) -> Group | None:
866
882
  return titled("Most Collected Players", table)
867
883
 
868
884
 
869
- def _hot_momentum(cur: int, prev: int) -> str:
870
- """The window-over-window badge in plain terms: 'new' (was silent), ×N up, ×N down, or steady."""
871
- if prev == 0:
885
+ # The hottest-players badges render the label the SERVER computed (`HotPlayer.momentum` /
886
+ # `HotPlayer.trend`). The thresholds that turn a ratio into "up" are the API's metric
887
+ # definitions (`slab_schemas.metrics`), not the CLI's to copy: three clients once each carried
888
+ # their own ±2% band and disagreed. When an older server omits the fields, the fallback is the
889
+ # registry's own functions, imported — the same rule, never a restated literal.
890
+ # `tests/test_hot_labels.py` asserts no threshold number lives in this module.
891
+
892
+
893
+ def _hot_momentum(e: HotPlayer) -> str:
894
+ """The window-over-window badge in plain terms: 'new' (was silent), ×N up, ×N down, or steady.
895
+ The label is served; the ×N beside it is the served counts' ratio, shown for scale."""
896
+ cur, prev = e.sales_30d, e.sales_prev_30d
897
+ label = e.momentum or momentum_label(cur, prev)
898
+ if label == "new":
872
899
  return "[slab.accent]new[/]"
873
- ratio = cur / prev
874
- if ratio >= 1.5:
875
- return f"[slab.accent]↑{ratio:.1f}×[/]"
876
- if ratio <= 0.67:
900
+ if label == "up":
901
+ return f"[slab.accent]↑{cur / prev:.1f}×[/]" if prev else "[slab.accent]↑[/]"
902
+ if label == "down":
877
903
  return f"[slab.dim]↓{prev / cur:.1f}×[/]" if cur else "[slab.dim]↓[/]"
878
904
  return "[slab.dim]steady[/]"
879
905
 
880
906
 
881
- def _hot_trend(pct: float | None) -> str:
882
- """Price direction arrow — how to tell a breakout (selling up) from a sell-off (selling down)."""
883
- if pct is None:
907
+ def _hot_trend(e: HotPlayer) -> str:
908
+ """Price direction arrow — how to tell a breakout (selling up) from a sell-off (selling down).
909
+ The direction is served; the percent beside it is the served `price_trend_pct`."""
910
+ pct = e.price_trend_pct
911
+ label = e.trend or trend_label(pct)
912
+ if pct is None or label is None:
884
913
  return "[slab.dim]—[/]"
885
- if pct > 2:
886
- return f"[slab.gain]↗ +{pct:.0f}%[/]"
887
- if pct < -2:
888
- return f"[slab.loss]↘ {pct:.0f}%[/]"
914
+ if label == "up":
915
+ return f"[slab.gain]↗ {pct:+.0f}%[/]"
916
+ if label == "down":
917
+ return f"[slab.loss]↘ {pct:+.0f}%[/]"
889
918
  return f"[slab.dim]→ {pct:+.0f}%[/]"
890
919
 
891
920
 
@@ -904,10 +933,10 @@ def _hottest_players_table(entries: list[HotPlayer]) -> Group | None:
904
933
  table.add_row(
905
934
  e.name,
906
935
  str(e.sales_30d),
907
- _hot_momentum(e.sales_30d, e.sales_prev_30d),
936
+ _hot_momentum(e),
908
937
  money(e.dollar_volume_30d),
909
938
  str(e.distinct_cards_30d),
910
- _hot_trend(e.price_trend_pct),
939
+ _hot_trend(e),
911
940
  )
912
941
  return titled("🔥 Hottest Players", table)
913
942
 
@@ -1488,67 +1517,41 @@ def render_collection_grading_desk(sweep: CollectionGradingDesk) -> None:
1488
1517
  console.print()
1489
1518
 
1490
1519
 
1491
- def _render_portfolio_by_set(by_set: list | None) -> None:
1492
- """Per-set breakdown, most valuable first. Highlights the top set and shows comp coverage."""
1520
+ def _render_portfolio_by_set(by_set: list[SetGroupOut] | None) -> None:
1521
+ """Per-set breakdown straight from the server's grouped rollup (`POST .../collection/sets`):
1522
+ copies held, distinct cards, and what they're worth, most valuable first. Only SERVED columns —
1523
+ there is no per-set cost basis, gain, or comps coverage here because the API computes none,
1524
+ and a client-side version was a second, drifting definition of the dashboard's math (it
1525
+ summed a capped page of copies and called it the set)."""
1493
1526
  if not by_set:
1494
1527
  return
1495
1528
 
1496
1529
  table = slab_table([
1497
1530
  ("Set", {"style": "slab.value", "no_wrap": False}),
1531
+ ("Copies", {"justify": "right", "style": "slab.dim"}),
1498
1532
  ("Cards", {"justify": "right", "style": "slab.dim"}),
1499
- ("Cost Basis", {"justify": "right"}),
1500
- ("FMV", {"justify": "right"}),
1501
- ("Gain/Loss", {"justify": "right"}),
1502
- ("Comps", {"justify": "right"}),
1533
+ ("Value", {"justify": "right"}),
1503
1534
  ])
1504
1535
 
1505
- for i, st in enumerate(by_set):
1506
- # crown the single most valuable set
1507
- name = f"[slab.foil]★[/] {st['name']}" if i == 0 and st["has_fmv"] else st["name"]
1508
- cards = f"{st['total_qty']:,}"
1509
- basis = money(st["cost_basis"])
1510
- fmv = f"[slab.foil]{money(st['fmv'])}[/]" if st["has_fmv"] else "[slab.dim]—[/]"
1511
- gain = signed_money(st["unrealized"])
1512
- comps = pct_coverage(st["comp_pct"])
1513
-
1514
- table.add_row(name, cards, basis, fmv, gain, comps)
1536
+ for i, g in enumerate(by_set):
1537
+ # crown the single most valuable set (the server ranks by value; a None value is unpriced)
1538
+ name = f"[slab.foil]★[/] {g.name}" if i == 0 and g.total_value is not None else g.name
1539
+ value = f"[slab.foil]{money(g.total_value)}[/]" if g.total_value is not None else "[slab.dim]—[/]"
1540
+ table.add_row(name, f"{g.copy_count:,}", f"{g.card_count:,}", value)
1515
1541
 
1516
- console.print(heading("By Set", "most valuable first"))
1542
+ console.print(heading("By Set", "most valuable first · unpriced copies add nothing to Value"))
1517
1543
  console.print(table)
1518
1544
 
1519
1545
 
1520
- def _render_most_valuable_break(mvb: dict | None) -> None:
1521
- """Highlight the single break whose pulled cards carry the most market value — did it pay off?"""
1522
- if not mvb:
1523
- return
1524
-
1525
- date_str = f" · {mvb['break_date']}" if mvb.get("break_date") else ""
1526
- title = Text()
1527
- title.append(mvb["set_name"], style="slab.value")
1528
- title.append(f" {mvb['break_type'].replace('_', ' ')}{date_str}", style="slab.dim")
1529
-
1530
- g = mvb["gain"]
1531
- verdict = "paid off" if g >= 0 else "underwater"
1532
- roi = f" [slab.dim]({'+' if g >= 0 else '-'}{abs(g) / mvb['cost'] * 100:.0f}%)[/]" if mvb["cost"] else ""
1533
-
1534
- body = kv_grid([
1535
- ("Cost", f"{money(mvb['cost'])} [slab.dim]({_n(mvb['copy_count'], 'card')})[/]"),
1536
- ("Market Value", f"[slab.foil]{money(mvb['fmv'])}[/] [slab.dim]({mvb['priced']} priced)[/]"),
1537
- ("Net vs Cost", f"{signed_money(g)}{roi} [slab.dim]· {verdict}[/]"),
1538
- ])
1539
-
1540
- console.print(heading("Most Valuable Break"))
1541
- console.print(slab_panel(Group(title, Text(""), body), foil=True))
1542
-
1543
-
1544
1546
  def render_portfolio(
1545
- total: int, totals: dict, movers: list | None = None, by_set: list | None = None,
1546
- most_valuable_break: dict | None = None,
1547
+ total: int, totals: dict, movers: list | None = None, by_set: list[SetGroupOut] | None = None,
1547
1548
  ) -> None:
1548
- """Display portfolio-level financial summary, per-set breakdown, and optional top movers.
1549
+ """Display portfolio-level financial summary, the per-set rollup, and optional top movers.
1549
1550
 
1550
- `totals` is the grand-total dict (cost_basis, fmv, unrealized, roi, priced_qty, total_qty)
1551
- built from the server's PortfolioSummary — see `_portfolio_totals`."""
1551
+ Every number here is one the server computed: `totals` is the grand-total dict (cost_basis,
1552
+ fmv, unrealized, roi, priced_qty, total_qty) built from the response's `PortfolioSummary`
1553
+ (see `_portfolio_totals`), `by_set` is the served `SetGroupOut` rollup, and `movers` is the
1554
+ first page of the server's `-unrealized` sort."""
1552
1555
  rows: list[tuple[str, str]] = [("Cost Basis", money(totals["cost_basis"]))]
1553
1556
 
1554
1557
  if totals["fmv"] is not None:
@@ -1575,7 +1578,6 @@ def render_portfolio(
1575
1578
  console.print(slab_panel(kv_grid(rows), foil=True))
1576
1579
 
1577
1580
  _render_portfolio_by_set(by_set)
1578
- _render_most_valuable_break(most_valuable_break)
1579
1581
 
1580
1582
  # Top movers
1581
1583
  if movers:
@@ -12,6 +12,7 @@ from typing import Protocol
12
12
 
13
13
  from InquirerPy.base.control import Choice
14
14
 
15
+ from .context import ctx
15
16
  from .nav import inquirer # the nav proxy: every prompt gains Esc=back / Ctrl-C=quit
16
17
 
17
18
  from slab_schemas.cards import CardOut, SetOut, SetSearchResult
@@ -435,22 +436,69 @@ def select_grade(message: str = "Your grade assessment:") -> str | None:
435
436
  return inquirer.select(message=message, choices=choices, max_height="70%").execute()
436
437
 
437
438
 
439
+ # ---------------------------------------------------------------------------
440
+ # Served-vocab pickers
441
+ # ---------------------------------------------------------------------------
442
+ # These lists come from GET /vocab when the API answers and fall back to the literals below when it
443
+ # doesn't — the portal's `getVocab()` pattern (portal/src/lib/vocab.ts). Grading companies are LIVE
444
+ # catalog rows (a new one appears when a set is seeded), and the wire enums can grow in a schemas
445
+ # release the installed CLI predates; either way the served list is the truth and the fallback is
446
+ # only what this build knew. The label maps are the human wording for the values we know about; an
447
+ # unknown served value still renders (title-cased) rather than being silently dropped.
448
+
449
+ _GRADING_COMPANY_FALLBACK = ["PSA", "BGS", "SGC", "CGC"]
450
+ _GRADING_COMPANY_LABELS = {"BGS": "BGS (Beckett)"}
451
+
452
+ _MATCH_MODE_FALLBACK = ["exact", "any_printing", "exact_serial"]
453
+ _MATCH_MODE_LABELS = {
454
+ "exact": "Exact — this specific printing (parallel/finish)",
455
+ "any_printing": "Any printing — any version of this card slot",
456
+ "exact_serial": "Exact serial — this card with a specific serial number",
457
+ }
458
+
459
+ _VISIBILITY_FALLBACK = ["private", "public"]
460
+ _VISIBILITY_LABELS = {
461
+ "private": "Private — only you can see it",
462
+ "public": "Public — any collector can find and subscribe",
463
+ }
464
+
465
+
466
+ def vocab_values(
467
+ field: str, fallback: list[str], vocab=None, *, exclude: frozenset[str] = frozenset()
468
+ ) -> list[str]:
469
+ """The served list for one vocab field, else `fallback`. Pure: pass `vocab` (a VocabOut, or
470
+ None for "the API didn't answer") to test it; the pickers pass the client's cached one. A
471
+ served list that is empty (a catalog with no grading companies yet) also falls back, so a
472
+ prompt never renders with nothing to pick. `exclude` drops values this picker must not offer
473
+ because another flow owns them (applied to served and fallback alike)."""
474
+ values = getattr(vocab, field, None) if vocab is not None else None
475
+ chosen = list(values) if values else list(fallback)
476
+ return [v for v in chosen if v not in exclude]
477
+
478
+
479
+ def _served_choices(
480
+ field: str, fallback: list[str], labels: dict[str, str], exclude: frozenset[str] = frozenset()
481
+ ) -> list[Choice]:
482
+ values = vocab_values(field, fallback, ctx.client.vocab(), exclude=exclude)
483
+ return [Choice(value=v, name=labels.get(v) or v.replace("_", " ").title()) for v in values]
484
+
485
+
438
486
  def select_grading_company() -> str:
439
- choices = [
440
- Choice(value="PSA", name="PSA"),
441
- Choice(value="BGS", name="BGS (Beckett)"),
442
- Choice(value="SGC", name="SGC"),
443
- Choice(value="CGC", name="CGC"),
444
- ]
487
+ choices = _served_choices("grading_companies", _GRADING_COMPANY_FALLBACK, _GRADING_COMPANY_LABELS)
445
488
  return inquirer.select(message="Grading company:", choices=choices).execute()
446
489
 
447
490
 
491
+ # `any_card` is a PLAYER slot, not a way to match a card: both callers of select_match_mode are
492
+ # tuning a CARD entry, and the player rung is reached through `select_entry_kind` instead. So it
493
+ # is excluded from this picker however the list arrives — offering it here would send a card entry
494
+ # with a subject-shaped mode.
495
+ _CARD_ENTRY_MODE_EXCLUDE = frozenset({"any_card"})
496
+
497
+
448
498
  def select_match_mode() -> str:
449
- choices = [
450
- Choice(value="exact", name="Exact — this specific printing (parallel/finish)"),
451
- Choice(value="any_printing", name="Any printing — any version of this card slot"),
452
- Choice(value="exact_serial", name="Exact serial — this card with a specific serial number"),
453
- ]
499
+ choices = _served_choices(
500
+ "match_modes", _MATCH_MODE_FALLBACK, _MATCH_MODE_LABELS, exclude=_CARD_ENTRY_MODE_EXCLUDE
501
+ )
454
502
  return inquirer.select(message="Match mode:", choices=choices).execute()
455
503
 
456
504
 
@@ -464,10 +512,7 @@ def select_entry_kind() -> str:
464
512
 
465
513
 
466
514
  def select_visibility() -> str:
467
- choices = [
468
- Choice(value="private", name="Private — only you can see it"),
469
- Choice(value="public", name="Public — any collector can find and subscribe"),
470
- ]
515
+ choices = _served_choices("visibilities", _VISIBILITY_FALLBACK, _VISIBILITY_LABELS)
471
516
  return inquirer.select(message="Visibility:", choices=choices).execute()
472
517
 
473
518
 
@@ -16,6 +16,8 @@ Building block cheat-sheet:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import math
20
+
19
21
  from rich import box
20
22
  from rich.console import Console, Group, RenderableType
21
23
  from rich.panel import Panel
@@ -148,9 +150,15 @@ def signed_money(v) -> str:
148
150
 
149
151
 
150
152
  def pct_coverage(pct: float) -> str:
151
- """A coverage/health percentage, colored by band: green ≥75, gold ≥40, red below."""
152
- color = "slab.gain" if pct >= 75 else ("slab.foil" if pct >= 40 else "slab.loss")
153
- return f"[{color}]{pct:.0f}%[/]"
153
+ """A coverage/health percentage, colored by band: green ≥75, gold ≥40, red below.
154
+
155
+ The ratio itself is two served counts (e.g. `priced_count / card_count`), so the only thing a
156
+ client decides is the rounding — and it must round the way the frontend does (`Math.round`,
157
+ half UP: 62.5 → 63), not Python's `format(..., '.0f')` (half to EVEN: 62.5 → 62), or the two
158
+ clients print different percentages for the same set. `floor(x + 0.5)` is `Math.round`."""
159
+ shown = math.floor(pct + 0.5)
160
+ color = "slab.gain" if shown >= 75 else ("slab.foil" if shown >= 40 else "slab.loss")
161
+ return f"[{color}]{shown}%[/]"
154
162
 
155
163
 
156
164
  _SPARK_TICKS = "▁▂▃▄▅▆▇█"
File without changes