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.
@@ -0,0 +1,114 @@
1
+ """Lot commands — record and list purchases (a single multi-card buy).
2
+
3
+ A lot is the sibling of a break: one price paid for one-or-more cards (an eBay bundle, a card-show
4
+ pickup, a COMC order), split evenly across the cards attached to it — but tied to no set, so its
5
+ cards can span any number of products. Also provides helpers used by the collection add flow.
6
+
7
+ `lot fill` lives in `collection.py` (like `break fill`) because it drives the shared add session."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from slab_schemas.collection import CollectionSearchQuery, LotCreate, LotOut, LotSearchQuery
12
+
13
+ from ..client import SlabClient
14
+ from ..context import ctx
15
+ from ..display import console, print_lots, render_lot_contents
16
+ from ..prompts import ask_date, ask_decimal, confirm, select_lot
17
+ from ..sources import pick_source
18
+
19
+
20
+ def pick_or_create_lot(client: SlabClient, collector: str) -> LotOut | None:
21
+ """Let the user pick an existing purchase or create a new one. Returns the LotOut (whose cost
22
+ splits across the cards attached to it) or None if skipped."""
23
+ lots = client.search_lots(collector)
24
+ if lots.items:
25
+ choice = select_lot(lots, allow_new=True)
26
+ if choice == "new":
27
+ return create_lot_interactive(client, collector)
28
+ elif isinstance(choice, LotOut):
29
+ return choice
30
+ return None # skipped
31
+ else:
32
+ if confirm("No purchases yet. Record one?", default=True):
33
+ return create_lot_interactive(client, collector)
34
+ return None
35
+
36
+
37
+ def create_lot_interactive(client: SlabClient, collector: str) -> LotOut | None:
38
+ """Walk the user through recording a purchase. Returns the new LotOut or None."""
39
+ console.print(
40
+ "[dim]A purchase is one price paid for one-or-more cards — an eBay bundle, a show pickup. "
41
+ "Its cost splits across the cards you attach, so each carries what it actually cost you.[/dim]"
42
+ )
43
+ total_cost = ask_decimal("Total paid:")
44
+ lot_date = ask_date("When did you buy it?")
45
+ # Offer the collector's previously-used sources for arrow-key reuse (falls back to a plain
46
+ # text prompt on the first purchase, when there's nothing to reuse yet).
47
+ source = pick_source(client, collector)
48
+
49
+ lot = client.create_lot(
50
+ collector,
51
+ LotCreate(total_cost=total_cost, lot_date=lot_date, source=source),
52
+ )
53
+ console.print(f"[green]Purchase recorded:[/green] {lot.lot_date} — ${lot.total_cost:.2f}"
54
+ + (f" · {lot.source}" if lot.source else ""))
55
+ console.print(
56
+ "[dim]Why it matters: as you add the cards from this purchase, its cost splits across "
57
+ "them — so every card carries what it actually cost you (real profit/loss later).[/dim]"
58
+ )
59
+ return lot
60
+
61
+
62
+ def cmd_lot_list(args: list[str]) -> None:
63
+ """`slab lot list` — your purchases, then pick one to see value vs cost + its cards."""
64
+ result = ctx.client.search_lots(ctx.collector, LotSearchQuery(limit=25))
65
+ print_lots(result)
66
+ if not result.items:
67
+ return
68
+ choice = select_lot(result, allow_new=False)
69
+ if isinstance(choice, LotOut):
70
+ _show_lot_contents(choice)
71
+
72
+
73
+ def _show_lot_contents(lot: LotOut) -> None:
74
+ """The purchase's report card: one collection search scoped to the lot — each copy carries its
75
+ own market data — rendered as value-vs-cost + most valuable + rarest."""
76
+ cards = ctx.client.search_collection(
77
+ ctx.collector, CollectionSearchQuery(lot_uuid=lot.uuid, limit=200))
78
+ render_lot_contents(lot, cards)
79
+
80
+
81
+ def cmd_lot_new(args: list[str]) -> None:
82
+ """`slab lot new` — record a purchase (its cost splits across the cards you add to it, so every
83
+ card carries what it actually cost you)."""
84
+ create_lot_interactive(ctx.client, ctx.collector)
85
+
86
+
87
+ def cmd_lot_remove(args: list[str]) -> None:
88
+ """`slab lot remove` — pick a purchase and remove it. The cards stay in your collection but are
89
+ detached from the purchase (their split-derived cost basis is lost, since that cost was only
90
+ ever split across them while linked)."""
91
+ lots = ctx.client.search_lots(ctx.collector, LotSearchQuery(limit=100))
92
+ if not lots.items:
93
+ console.print("[slab.dim]No purchases to delete.[/]")
94
+ return
95
+
96
+ choice = select_lot(lots, allow_new=False)
97
+ if not isinstance(choice, LotOut):
98
+ return # skipped / cancelled
99
+
100
+ console.print(
101
+ f"[yellow]Deleting:[/yellow] {choice.lot_date} — ${choice.total_cost:.2f} "
102
+ f"({choice.copy_count} card(s))"
103
+ )
104
+ if choice.copy_count:
105
+ console.print(
106
+ f"[dim]Those {choice.copy_count} card(s) stay in your collection but lose the cost "
107
+ "basis they got from this purchase.[/dim]"
108
+ )
109
+ if not confirm("Delete this purchase?", default=False):
110
+ console.print("[slab.dim]Cancelled.[/]")
111
+ return
112
+
113
+ ctx.client.delete_lot(ctx.collector, choice.uuid)
114
+ console.print("[green]Purchase deleted.[/green]")
@@ -0,0 +1,386 @@
1
+ """Pricing commands — market price lookups and portfolio valuation."""
2
+
3
+ from __future__ import annotations
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
9
+
10
+ from ..context import MissingCollector, ctx
11
+ from ..display import (
12
+ console,
13
+ render_card_market,
14
+ render_comps,
15
+ render_portfolio,
16
+ render_price_history,
17
+ render_sealed_price_history,
18
+ render_set_market,
19
+ )
20
+ from ..paging import fetch_all_pages
21
+ from ..picker import pick_card, search_and_pick_subject
22
+ from ..prompts import (
23
+ select_priced_card,
24
+ select_set,
25
+ select_set_filter,
26
+ )
27
+
28
+
29
+ def _optional_collector() -> str | None:
30
+ """The active collector if one is resolvable, else None — pricing is catalog-level and works
31
+ without ownership, but if we have a collector we annotate each row with owned quantity."""
32
+ try:
33
+ return ctx.collector
34
+ except MissingCollector:
35
+ return None
36
+
37
+
38
+ def _pick_card(args: list[str]) -> CardOut | None:
39
+ """Price/comps selection: player → set (or all) → a card from the FMV-tagged list. Shares the
40
+ subject-first core (`search_and_pick_subject`) with every other picker, then adds the two
41
+ pricing-specific steps: the enriched search (each row carries its headline FMV + owned tag) and
42
+ a set-narrowing step before the FMV list. Returns the chosen card, or None if the user backed
43
+ out or nothing matched."""
44
+ picked = search_and_pick_subject(
45
+ " ".join(args) if args else None,
46
+ collector=_optional_collector(),
47
+ include_market=True,
48
+ )
49
+ if picked is None:
50
+ return None
51
+ cards, subject = picked
52
+
53
+ # Narrow to a set (auto-skips when the player only appears in one set).
54
+ slug = select_set_filter(cards)
55
+ if slug:
56
+ cards = [c for c in cards if c.set_slug == slug]
57
+
58
+ return select_priced_card(cards, subject, show_set=not slug)
59
+
60
+
61
+ def cmd_price(args: list[str]) -> None:
62
+ """Price a player's cards: pick the player, narrow to a set (or all), see every printing with
63
+ its FMV (● = you own it), then drill into one for the full market view. Catalog-level — no
64
+ ownership required, but owned copies are tagged when a collector is configured."""
65
+ card = _pick_card(args)
66
+ if card is None:
67
+ return
68
+ render_card_market(ctx.client.get_card_market(card.uuid))
69
+
70
+
71
+ def cmd_comps(args: list[str]) -> None:
72
+ """See the recent comps (raw observed sales) behind a card's price. Same player → set → card
73
+ flow as `price`, then lists the individual sales that FMV is built from."""
74
+ card = _pick_card(args)
75
+ if card is None:
76
+ return
77
+ render_comps(ctx.client.get_card_comps(card.uuid))
78
+
79
+
80
+ def _fetch_all_copies(page: int = 200, cap: int = 5000) -> tuple[list, PortfolioSummary | None]:
81
+ """Page through the whole collection for the per-set/mover/break breakdowns, and grab the
82
+ server's summary block (full-result aggregates, identical on every page) from the first page —
83
+ that's the source of truth for grand totals. `cap` is a safety bound so a runaway collection
84
+ can't spin forever."""
85
+ copies, first = fetch_all_pages(
86
+ lambda limit, offset: ctx.client.search_collection(
87
+ ctx.collector, CollectionSearchQuery(sort="-unrealized", limit=limit, offset=offset)
88
+ ),
89
+ page=page,
90
+ cap=cap,
91
+ )
92
+ return copies, (first.summary if first else None)
93
+
94
+
95
+ def _aggregate_by_set(copies: list) -> list[dict]:
96
+ """Roll copies up per set, mirroring the server's headline math so the parts reconcile with the
97
+ whole: cost basis is summed per-copy, FMV is quantity-weighted, comp coverage is copy-weighted."""
98
+ sets: dict[str, dict] = {}
99
+ for cp in copies:
100
+ card = cp.card
101
+ name = (card.set_name if card else None) or "— Unknown set —"
102
+ s = sets.setdefault(name, {
103
+ "name": name, "copies": 0, "total_qty": 0, "priced_qty": 0,
104
+ "cost_basis": 0.0, "fmv": 0.0, "has_fmv": False,
105
+ })
106
+ qty = cp.quantity or 1
107
+ s["copies"] += 1
108
+ s["total_qty"] += qty
109
+ if cp.cost_basis is not None:
110
+ s["cost_basis"] += float(cp.cost_basis)
111
+ if cp.market and cp.market.fair_market_value is not None:
112
+ s["fmv"] += float(cp.market.fair_market_value) * qty
113
+ s["priced_qty"] += qty
114
+ s["has_fmv"] = True
115
+
116
+ for s in sets.values():
117
+ s["unrealized"] = (s["fmv"] - s["cost_basis"]) if s["has_fmv"] else None
118
+ s["comp_pct"] = (s["priced_qty"] / s["total_qty"] * 100) if s["total_qty"] else 0.0
119
+
120
+ # Most valuable first; sets with no market data sink to the bottom (sorted by cost basis).
121
+ return sorted(
122
+ sets.values(),
123
+ key=lambda s: (s["has_fmv"], s["fmv"] if s["has_fmv"] else s["cost_basis"]),
124
+ reverse=True,
125
+ )
126
+
127
+
128
+ def _portfolio_totals(summary: PortfolioSummary) -> dict:
129
+ """Grand totals for the valuation panel, straight from the server's summary block — the same
130
+ math the dashboard shows, so the two never diverge."""
131
+ fmv = summary.portfolio_value
132
+ return {
133
+ "cost_basis": float(summary.total_cost_basis or 0),
134
+ "fmv": float(fmv) if fmv is not None else None,
135
+ "unrealized": (
136
+ float(summary.total_unrealized_gain_loss)
137
+ if summary.total_unrealized_gain_loss is not None else None
138
+ ),
139
+ "roi": float(summary.portfolio_roi) if summary.portfolio_roi is not None else None,
140
+ "priced_qty": summary.priced_copies,
141
+ "total_qty": summary.priced_copies + summary.unpriced_copies,
142
+ }
143
+
144
+
145
+ def _most_valuable_break(copies: list, breaks: list) -> dict | None:
146
+ """The break whose pulled copies carry the most market value — did opening that box pay off?
147
+ FMV is summed from the copies we already priced; cost/metadata comes from the breaks list."""
148
+ fmv_by_break: dict[str, float] = {}
149
+ priced_by_break: dict[str, int] = {}
150
+ for cp in copies:
151
+ if cp.break_uuid and cp.market and cp.market.fair_market_value is not None:
152
+ qty = cp.quantity or 1
153
+ fmv_by_break[cp.break_uuid] = (
154
+ fmv_by_break.get(cp.break_uuid, 0.0) + float(cp.market.fair_market_value) * qty
155
+ )
156
+ priced_by_break[cp.break_uuid] = priced_by_break.get(cp.break_uuid, 0) + qty
157
+
158
+ if not fmv_by_break:
159
+ return None
160
+
161
+ top_uuid = max(fmv_by_break, key=fmv_by_break.get)
162
+ brk = next((b for b in breaks if b.uuid == top_uuid), None)
163
+ if brk is None:
164
+ return None
165
+
166
+ cost = float(brk.total_cost)
167
+ fmv = fmv_by_break[top_uuid]
168
+ return {
169
+ "set_name": brk.set_name or "— Unknown set —",
170
+ "break_type": brk.break_type,
171
+ "break_date": brk.break_date,
172
+ "cost": cost,
173
+ "fmv": fmv,
174
+ "gain": fmv - cost,
175
+ "copy_count": brk.copy_count,
176
+ "priced": priced_by_break.get(top_uuid, 0),
177
+ }
178
+
179
+
180
+ def cmd_portfolio(args: list[str]) -> None:
181
+ """Portfolio-level financial summary — cost basis vs market value, per-set stats, top movers."""
182
+ copies, summary = _fetch_all_copies()
183
+ if not copies:
184
+ console.print("[dim]No cards in your collection yet.[/dim]")
185
+ return
186
+ if summary is None:
187
+ # The API always includes the summary block; its absence is a server bug — say so
188
+ # rather than invent numbers client-side.
189
+ console.print("[red]Server response had no portfolio summary — can't value the collection.[/red]")
190
+ return
191
+
192
+ by_set = _aggregate_by_set(copies)
193
+ totals = _portfolio_totals(summary)
194
+
195
+ # Top movers: copies with real unrealized P&L, biggest gain first (already sorted by the fetch).
196
+ movers = [
197
+ cp for cp in copies
198
+ if cp.market and cp.market.unrealized_gain_loss is not None
199
+ ][:10] or None
200
+
201
+ breaks = ctx.client.search_breaks(ctx.collector).items
202
+ mvb = _most_valuable_break(copies, breaks)
203
+
204
+ render_portfolio(
205
+ len(copies), totals, movers=movers, by_set=by_set, most_valuable_break=mvb
206
+ )
207
+
208
+
209
+ def _fetch_all_sets(page: int = 200, cap: int = 1000) -> list[SetOut]:
210
+ """The whole set catalog for client-side fuzzy picking. Sets number in the dozens today, so
211
+ this is one request; `cap` bounds the paging if the catalog grows past reason."""
212
+ sets, _ = fetch_all_pages(
213
+ lambda limit, offset: ctx.client.search_sets(SetSearchQuery(limit=limit, offset=offset)),
214
+ page=page,
215
+ cap=cap,
216
+ )
217
+ return sets
218
+
219
+
220
+ def cmd_set_market(args: list[str]) -> None:
221
+ """The set-level money view: type a set name, fuzzy-pick the match, then see every sealed
222
+ SKU's FMV (case/box/blaster) next to the most expensive cards inside it — what the wax costs
223
+ vs what you're chasing. Catalog-level, no ownership required."""
224
+ query = " ".join(args) if args else None
225
+
226
+ sets = _fetch_all_sets()
227
+ if not sets:
228
+ console.print("[yellow]No sets in the catalog yet.[/yellow]")
229
+ return
230
+
231
+ chosen = select_set(sets, initial=query)
232
+ if chosen is None:
233
+ return
234
+
235
+ sealed = ctx.client.get_set_sealed(chosen.uuid)
236
+ top = ctx.client.get_set_top_cards(chosen.uuid, limit=10)
237
+ render_set_market(chosen, sealed, top)
238
+
239
+
240
+ # Above this many daily points, an unflagged history is re-fetched weekly — a two-year series
241
+ # should read as a trend, not a 400-row dump. --daily always forces every point.
242
+ _AUTO_WEEKLY_OVER = 90
243
+
244
+
245
+ def _history_options(args: list[str]) -> tuple[list[str], str | None, int | None]:
246
+ """(query_args, interval|None, days|None) from --daily/--weekly/--monthly/--days N.
247
+ interval None = auto (daily, bucketed to weekly when the series is long)."""
248
+ query: list[str] = []
249
+ interval: str | None = None
250
+ days: int | None = None
251
+ i = 0
252
+ while i < len(args):
253
+ a = args[i]
254
+ if a in ("--daily", "--weekly", "--monthly"):
255
+ interval = a.lstrip("-")
256
+ elif a == "--days":
257
+ if i + 1 < len(args):
258
+ i += 1
259
+ try:
260
+ days = max(1, int(args[i]))
261
+ except ValueError:
262
+ console.print(f"[yellow]--days expects a number, got {args[i]!r} — ignored.[/yellow]")
263
+ else:
264
+ console.print("[yellow]--days expects a number — ignored.[/yellow]")
265
+ else:
266
+ query.append(a)
267
+ i += 1
268
+ return query, interval, days
269
+
270
+
271
+ def _start_from_days(days: int | None) -> str | None:
272
+ if days is None:
273
+ return None
274
+ from datetime import date, timedelta
275
+
276
+ return (date.today() - timedelta(days=days)).isoformat()
277
+
278
+
279
+ def _fetch_history(fetch, interval: str | None, days: int | None):
280
+ """Fetch with the resolved interval: an explicit flag is obeyed; otherwise daily, re-fetched
281
+ weekly when the series is long (with a note so the densest view is one flag away)."""
282
+ start = _start_from_days(days)
283
+ h = fetch(start=start, interval=interval or "daily")
284
+ if interval is None and len(h.points) > _AUTO_WEEKLY_OVER:
285
+ h = fetch(start=start, interval="weekly")
286
+ console.print(
287
+ f"[slab.dim]Long series — showing weekly buckets ({len(h.points)} points). "
288
+ f"Use --daily for every point.[/]"
289
+ )
290
+ return h
291
+
292
+
293
+ def cmd_history(args: list[str]) -> None:
294
+ """Price history for a card — how its value has changed over time.
295
+
296
+ Flags:
297
+ --daily / --weekly / --monthly bucket size (default: daily, auto-weekly for long series)
298
+ --days N only the last N days
299
+
300
+ Examples:
301
+ slab card history bedard
302
+ slab card history bedard --days 90 --daily
303
+ slab card history mcdavid --monthly"""
304
+ from InquirerPy import inquirer
305
+ from InquirerPy.base.control import Choice
306
+
307
+ query, interval, days = _history_options(args)
308
+ card = pick_card(" ".join(query) if query else None)
309
+ if card is None:
310
+ return
311
+
312
+ # Get available grade buckets from the card's market data
313
+ market = ctx.client.get_card_market(card.uuid)
314
+ if not market.price_points:
315
+ console.print("[yellow]No pricing data available for this card.[/yellow]")
316
+ return
317
+
318
+ grade_keys = sorted({pp.grade_key for pp in market.price_points})
319
+ if len(grade_keys) == 1:
320
+ gk = grade_keys[0]
321
+ else:
322
+ gk = inquirer.select(
323
+ message="Grade bucket:",
324
+ choices=[Choice(gk, name=gk) for gk in grade_keys],
325
+ ).execute()
326
+
327
+ history = _fetch_history(
328
+ lambda start, interval: ctx.client.get_card_price_history(
329
+ card.uuid, grade_key=gk, start=start, interval=interval),
330
+ interval, days,
331
+ )
332
+ render_price_history(history)
333
+
334
+
335
+ def cmd_set_history(args: list[str]) -> None:
336
+ """Price history for a sealed product — how a box/case/blaster's value has changed over time.
337
+
338
+ Flags:
339
+ --daily / --weekly / --monthly bucket size (default: daily, auto-weekly for long series)
340
+ --days N only the last N days
341
+
342
+ Examples:
343
+ slab set history allure
344
+ slab set history "series 1" --days 180 --weekly"""
345
+ from InquirerPy import inquirer
346
+ from InquirerPy.base.control import Choice
347
+
348
+ query, interval, days = _history_options(args)
349
+
350
+ sets = _fetch_all_sets()
351
+ if not sets:
352
+ console.print("[yellow]No sets in the catalog yet.[/yellow]")
353
+ return
354
+
355
+ chosen = select_set(sets, initial=" ".join(query) if query else None)
356
+ if chosen is None:
357
+ return
358
+
359
+ sealed = ctx.client.get_set_sealed(chosen.uuid)
360
+ if not sealed:
361
+ console.print("[yellow]No sealed products catalogued for this set.[/yellow]")
362
+ return
363
+
364
+ priced = [p for p in sealed if p.price_median is not None]
365
+ if not priced:
366
+ console.print("[yellow]No sealed pricing for this set yet.[/yellow]")
367
+ return
368
+ if len(priced) == 1:
369
+ product = priced[0]
370
+ else:
371
+ labels = {
372
+ p.uuid: f"{p.format.value.replace('_', ' ').title()} · FMV ${p.price_median:,.2f}"
373
+ for p in priced
374
+ }
375
+ uuid = inquirer.select(
376
+ message="Sealed product:",
377
+ choices=[Choice(p.uuid, name=labels[p.uuid]) for p in priced],
378
+ ).execute()
379
+ product = next(p for p in priced if p.uuid == uuid)
380
+
381
+ history = _fetch_history(
382
+ lambda start, interval: ctx.client.get_sealed_price_history(
383
+ product.uuid, start=start, interval=interval),
384
+ interval, days,
385
+ )
386
+ render_sealed_price_history(history)
@@ -0,0 +1,140 @@
1
+ """The command registry — the ONE source of truth for the slab CLI's command surface.
2
+
3
+ Every command is declared here exactly once as a `Command` under a `Group`. This single list drives
4
+ BOTH dispatch (main.py routes `slab <group> <verb>` through it) AND the help screens (`slab`,
5
+ `slab <group>`), so the help you read and the commands that run can never disagree. Adding a command
6
+ = adding one line here; the help updates itself. This is deliberately the canonical reference for
7
+ what the CLI can do — external docs should point here rather than restate it.
8
+
9
+ Structure is pure noun-verb (like `git`, `docker`, `gh`): a `<group>` noun, then a `<verb>`. Learn a
10
+ noun and you can guess its verbs; verbs stay consistent across nouns (`search`, `add`, `remove`,
11
+ `dashboard`, `price`…). Everything searchable uses the verb `search` with the SHARED flag grammar
12
+ (`slab_cli.flags`) — the same `--team`/`--year`/… work on `card`, `set`, and `collection` search."""
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from difflib import get_close_matches
18
+ from typing import Callable
19
+
20
+ from . import breaks, catalog, collection, custom_sets, export, lots, pricing, setup
21
+
22
+ Handler = Callable[[list[str]], None]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Command:
27
+ """One `slab <group> <verb>` command: the verb, a one-line summary, its args hint, and the
28
+ function that runs it. Summary + args render straight into the help — keep them tight."""
29
+
30
+ verb: str
31
+ summary: str
32
+ handler: Handler
33
+ args: str = ""
34
+
35
+ def usage(self, group: str) -> str:
36
+ """`slab card search <query>` — the full invocation, args included."""
37
+ base = f"slab {group} {self.verb}"
38
+ return f"{base} {self.args}" if self.args else base
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Group:
43
+ """A noun (`card`, `collection`, …) and the verbs under it, plus a one-line blurb for the header.
44
+
45
+ `default` names a verb to run when the group is invoked bare (`slab community` → its board);
46
+ groups without one show their help instead — the right behavior for multi-verb nouns."""
47
+
48
+ name: str
49
+ blurb: str
50
+ commands: list[Command] = field(default_factory=list)
51
+ default: str | None = None
52
+
53
+
54
+ # --- The command surface -----------------------------------------------------------------------
55
+ # Each group is a noun; each Command a verb. This list IS the CLI. Edit here, help follows.
56
+
57
+ GROUPS: list[Group] = [
58
+ Group("card", "look up a card in the catalog", [
59
+ Command("search", "Search the catalog (player, set, team…)", catalog.cmd_search, "[query] [flags]"),
60
+ Command("parallels", "Every parallel (the rainbow) of a card", catalog.cmd_rainbow, "<query>"),
61
+ Command("price", "Market price + 7d/30d trend", pricing.cmd_price, "<query>"),
62
+ Command("comps", "Recent raw sales behind the price", pricing.cmd_comps, "<query>"),
63
+ Command("history", "Price history over time", pricing.cmd_history, "<query>"),
64
+ ]),
65
+ Group("set", "browse and price sealed products", [
66
+ Command("search", "Browse sets / products", catalog.cmd_sets, "[query] [flags]"),
67
+ Command("price", "Set market: sealed FMV (case/box/blaster) + top cards", pricing.cmd_set_market, "[query]"),
68
+ Command("history", "Sealed price history over time (box/case/blaster)", pricing.cmd_set_history, "[query]"),
69
+ ]),
70
+ Group("community", "everyone's catalog activity", [
71
+ Command("board", "Community board: catalog stats, activity, leaderboards", catalog.cmd_community),
72
+ ], default="board"),
73
+ Group("collection", "your inventory", [
74
+ Command("add", "Add card(s) you own", collection.cmd_add),
75
+ Command("search", "Search your collection (same flags as card search)", collection.cmd_search, "[query] [flags]"),
76
+ Command("sell", "Record a sale (mark sold with price)", collection.cmd_sell),
77
+ Command("remove", "Remove a copy", collection.cmd_remove),
78
+ Command("cost", "Log a cost (grading, shipping) on a copy", collection.cmd_cost),
79
+ Command("export", "Export a set to a printable checklist", export.cmd_export),
80
+ Command("dashboard", "Overview: counts, value, composition", collection.cmd_dashboard),
81
+ Command("portfolio", "Portfolio: cost basis vs FMV + top movers", pricing.cmd_portfolio),
82
+ ]),
83
+ Group("break", "case / box breaks", [
84
+ Command("list", "Your breaks — pick one to see value vs cost + top pulls", breaks.cmd_break_list),
85
+ Command("new", "Record a break", breaks.cmd_break_new),
86
+ Command("fill", "Walk a break's checklist, adding what you pulled", collection.cmd_break_fill),
87
+ Command("remove", "Delete a break (pulled cards stay in your collection)", breaks.cmd_break_remove),
88
+ ]),
89
+ Group("lot", "multi-card purchases (eBay bundles)", [
90
+ Command("list", "Your purchases — pick one to see value vs cost + its cards", lots.cmd_lot_list),
91
+ Command("new", "Record a purchase (cost splits across its cards)", lots.cmd_lot_new),
92
+ Command("fill", "Add the cards from one purchase back-to-back", collection.cmd_lot_fill),
93
+ Command("remove", "Delete a purchase (its cards stay in your collection)", lots.cmd_lot_remove),
94
+ ]),
95
+ Group("chase", "custom sets & set-completion", [
96
+ Command("create", "Create a custom set (curated or dynamic)", custom_sets.cmd_chase_create),
97
+ Command("add", "Add cards to a curated set", custom_sets.cmd_chase_add),
98
+ Command("view", "View a set with completion + add missing", custom_sets.cmd_chase_view, "[uuid]"),
99
+ Command("edit", "Edit name, description, or visibility", custom_sets.cmd_chase_edit),
100
+ Command("list", "Your sets (created + subscribed)", custom_sets.cmd_chase_list),
101
+ Command("find", "Discover public sets by name", custom_sets.cmd_chase_find, "[query]"),
102
+ Command("subscribe", "Subscribe to / unsubscribe from a public set", custom_sets.cmd_chase_subscribe, "[query]"),
103
+ Command("remove", "Remove a card from a set", custom_sets.cmd_chase_remove),
104
+ Command("delete", "Delete a set you created", custom_sets.cmd_chase_delete),
105
+ ]),
106
+ Group("collector", "your identity", [
107
+ Command("create", "Create a collector", setup.cmd_register, "<name>"),
108
+ Command("current", "Show the active collector", setup.cmd_whoami),
109
+ ]),
110
+ ]
111
+
112
+ # --- Lookup + suggestion (used by dispatch) ----------------------------------------------------
113
+
114
+ _GROUPS_BY_NAME: dict[str, Group] = {g.name: g for g in GROUPS}
115
+ _COMMANDS: dict[tuple[str, str], Command] = {
116
+ (g.name, c.verb): c for g in GROUPS for c in g.commands
117
+ }
118
+
119
+
120
+ def get_group(name: str) -> Group | None:
121
+ return _GROUPS_BY_NAME.get(name)
122
+
123
+
124
+ def get_command(group: str, verb: str) -> Command | None:
125
+ return _COMMANDS.get((group, verb))
126
+
127
+
128
+ def suggest_group(name: str) -> str | None:
129
+ """Closest group name to a typo, or None — powers 'did you mean …?'."""
130
+ m = get_close_matches(name, list(_GROUPS_BY_NAME), n=1, cutoff=0.6)
131
+ return m[0] if m else None
132
+
133
+
134
+ def suggest_verb(group: str, verb: str) -> str | None:
135
+ """Closest verb within a group to a typo, or None."""
136
+ g = get_group(group)
137
+ if not g:
138
+ return None
139
+ m = get_close_matches(verb, [c.verb for c in g.commands], n=1, cutoff=0.6)
140
+ return m[0] if m else None
@@ -0,0 +1,24 @@
1
+ """Setup commands — register a collector, check identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..config import get_collector_uuid
6
+ from ..context import ctx
7
+ from ..display import console
8
+ from ..prompts import ask_text
9
+
10
+
11
+ def cmd_register(args: list[str]) -> None:
12
+ name = " ".join(args) if args else ask_text("Collector name:")
13
+ collector = ctx.client.create_collector(name)
14
+ console.print(f"\n[green]Collector created:[/green] {collector.name}")
15
+ console.print(f" UUID: {collector.uuid}")
16
+ console.print(f"\n Now run: [bold]export SLAB_COLLECTOR={collector.uuid}[/bold]")
17
+
18
+
19
+ def cmd_whoami(args: list[str]) -> None:
20
+ uuid = get_collector_uuid()
21
+ if uuid:
22
+ console.print(f"Active collector: [bold]{uuid}[/bold]")
23
+ else:
24
+ console.print("[dim]No collector set. Run: slab collector create <name>[/dim]")
slab_cli/config.py ADDED
@@ -0,0 +1,21 @@
1
+ """CLI configuration — resolved from env vars or defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+
8
+ def get_base_url() -> str:
9
+ url = os.environ.get("SLAB_API_URL", "").strip()
10
+ return url or "https://api.slab.dev-jeb.com"
11
+
12
+
13
+ def get_api_key() -> str | None:
14
+ key = os.environ.get("SLAB_API_KEY", "").strip()
15
+ return key or None
16
+
17
+
18
+ def get_collector_uuid() -> str | None:
19
+ """The active collector UUID. Set once, reused across commands."""
20
+ uuid = os.environ.get("SLAB_COLLECTOR", "").strip()
21
+ return uuid or None