@seanyao/roll 3.609.2 → 3.610.2

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 (103) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +5 -6
  3. package/dist/roll.mjs +16146 -16186
  4. package/package.json +3 -2
  5. package/skills/README.md +14 -1
  6. package/skills/docs/skill-authoring.md +66 -0
  7. package/skills/reports/skill-audit-summary.md +53 -0
  8. package/skills/roll-.changelog/SKILL.md +25 -443
  9. package/skills/roll-.changelog/references/full-contract.md +462 -0
  10. package/skills/roll-.clarify/SKILL.md +6 -4
  11. package/skills/roll-.dream/SKILL.md +26 -353
  12. package/skills/roll-.dream/references/full-contract.md +365 -0
  13. package/skills/roll-.echo/SKILL.md +6 -4
  14. package/skills/roll-.qa/SKILL.md +25 -236
  15. package/skills/roll-.qa/references/full-contract.md +256 -0
  16. package/skills/roll-.review/SKILL.md +6 -2
  17. package/skills/roll-brief/SKILL.md +6 -8
  18. package/skills/roll-build/SKILL.md +28 -864
  19. package/skills/roll-build/references/full-contract.md +883 -0
  20. package/skills/roll-debug/SKILL.md +26 -585
  21. package/skills/roll-debug/references/full-contract.md +607 -0
  22. package/skills/roll-design/SKILL.md +28 -903
  23. package/skills/roll-design/references/full-contract.md +923 -0
  24. package/skills/roll-doc/SKILL.md +25 -574
  25. package/skills/roll-doc/references/full-contract.md +594 -0
  26. package/skills/roll-doctor/SKILL.md +21 -2
  27. package/skills/roll-fix/SKILL.md +28 -621
  28. package/skills/roll-fix/references/full-contract.md +640 -0
  29. package/skills/roll-idea/SKILL.md +6 -2
  30. package/skills/roll-loop/SKILL.md +27 -543
  31. package/skills/roll-loop/references/full-contract.md +555 -0
  32. package/skills/roll-notes/SKILL.md +6 -2
  33. package/skills/roll-onboard/SKILL.md +6 -2
  34. package/skills/roll-peer/SKILL.md +27 -316
  35. package/skills/roll-peer/references/full-contract.md +329 -0
  36. package/skills/roll-propose/SKILL.md +6 -8
  37. package/skills/roll-review-pr/SKILL.md +6 -2
  38. package/skills/roll-sentinel/SKILL.md +26 -344
  39. package/skills/roll-sentinel/references/full-contract.md +363 -0
  40. package/skills/roll-spar/SKILL.md +27 -269
  41. package/skills/roll-spar/references/full-contract.md +288 -0
  42. package/skills/route-cases/skills.json +235 -0
  43. package/skills/scripts/audit-skills.mjs +272 -0
  44. package/skills/scripts/test-audit-skills.mjs +39 -0
  45. package/skills/tests/fixtures/skill-audit/block-skill/SKILL.md +12 -0
  46. package/skills/tests/fixtures/skill-audit/minimal-skill/SKILL.md +8 -0
  47. package/skills/tests/fixtures/skill-audit/quoted-skill/SKILL.md +10 -0
  48. package/skills/tests/fixtures/skill-audit/route-cases.json +21 -0
  49. package/skills/tests/fixtures/skill-audit/spoke-skill/SKILL.md +12 -0
  50. package/skills/tests/fixtures/skill-audit/spoke-skill/references/runbook.md +3 -0
  51. package/bin/roll +0 -15361
  52. package/lib/backfill-pi-usage.py +0 -243
  53. package/lib/changelog_audit.py +0 -149
  54. package/lib/changelog_generate.py +0 -470
  55. package/lib/consistency_check.py +0 -409
  56. package/lib/context_feed_budget.sh +0 -194
  57. package/lib/github_sync.py +0 -876
  58. package/lib/i18n/slides.sh +0 -3
  59. package/lib/i18n/slides_build.sh +0 -38
  60. package/lib/i18n/slides_delete.sh +0 -19
  61. package/lib/i18n/slides_list.sh +0 -14
  62. package/lib/i18n/slides_logs.sh +0 -12
  63. package/lib/i18n/slides_new.sh +0 -15
  64. package/lib/i18n/slides_preview.sh +0 -14
  65. package/lib/i18n/slides_templates.sh +0 -7
  66. package/lib/i18n.sh +0 -211
  67. package/lib/loop-exit-summary.py +0 -393
  68. package/lib/loop-fmt.py +0 -589
  69. package/lib/loop_pick_agent.py +0 -316
  70. package/lib/loop_result_eval.py +0 -469
  71. package/lib/loop_unstick.py +0 -180
  72. package/lib/model_prices.py +0 -194
  73. package/lib/prices_fetcher.py +0 -534
  74. package/lib/roll-backlog.py +0 -225
  75. package/lib/roll-brief.py +0 -286
  76. package/lib/roll-help.py +0 -158
  77. package/lib/roll-home.py +0 -556
  78. package/lib/roll-init.py +0 -156
  79. package/lib/roll-loop-status.py +0 -1691
  80. package/lib/roll-loop-story.py +0 -191
  81. package/lib/roll-peer.py +0 -252
  82. package/lib/roll-setup.py +0 -102
  83. package/lib/roll-status.py +0 -367
  84. package/lib/roll_git.py +0 -41
  85. package/lib/roll_render.py +0 -414
  86. package/lib/slides/components/README.md +0 -123
  87. package/lib/slides/components/cards-2.html +0 -9
  88. package/lib/slides/components/cards-3.html +0 -9
  89. package/lib/slides/components/cards-4.html +0 -9
  90. package/lib/slides/components/compare.html +0 -22
  91. package/lib/slides/components/highlight.html +0 -9
  92. package/lib/slides/components/pipeline.html +0 -12
  93. package/lib/slides/components/plain.html +0 -7
  94. package/lib/slides/components/quote.html +0 -4
  95. package/lib/slides/components/timeline.html +0 -9
  96. package/lib/slides/templates/introduction-v3.html +0 -571
  97. package/lib/slides/templates/pitch.html +0 -0
  98. package/lib/slides-render.py +0 -778
  99. package/lib/slides-validate.py +0 -357
  100. package/lib/test_quality_gate.py +0 -143
  101. package/skills/roll-deck/SKILL.md +0 -296
  102. /package/skills/roll-debug/{injectable-bb.js → assets/injectable-bb.js} +0 -0
  103. /package/skills/roll-design/{ENGINEERING_CHECKLIST.md → references/engineering-checklist.md} +0 -0
@@ -1,534 +0,0 @@
1
- """
2
- prices_fetcher — fetch + parse + diff + write multi-vendor pricing snapshots.
3
-
4
- US-VIEW-013: replaces the hardcoded PRICES table in ``model_prices.py`` with
5
- versioned JSON snapshots under ``lib/prices/``. The fetcher pulls the live
6
- pricing docs page, extracts the model rate rows, and writes a new snapshot
7
- only when the rates differ from the most recent one on disk.
8
-
9
- US-VIEW-023: vendor-registry architecture — ``fetch``/``parse``/``refresh``
10
- dispatch by vendor. Adding a new vendor is a registry entry, not a change to
11
- the fetch/parse/refresh orchestration.
12
-
13
- Design:
14
- * ``fetch_pricing_html(url, timeout)`` — pure I/O, raises ``FetchError``
15
- * ``parse_pricing_html(html, vendor)`` — dispatches to vendor parser,
16
- raises ``ParseError``
17
- * ``diff_prices(old, new)`` — pure diff, returns list of changes
18
- * ``write_snapshot(prices, ...)`` — pure I/O, returns the path written
19
- * ``refresh(...)`` — orchestrator; the only function with side effects on
20
- both network and disk
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- import datetime as _dt
26
- import json
27
- import os
28
- import re
29
- import sys
30
- from dataclasses import dataclass
31
- from html.parser import HTMLParser
32
- from typing import Any, Callable, Dict, List, Optional, Tuple
33
- from urllib.error import URLError
34
- from urllib.request import Request, urlopen
35
-
36
- DEFAULT_TIMEOUT = 15
37
-
38
-
39
- class FetchError(RuntimeError):
40
- """Raised when fetching the pricing page fails."""
41
-
42
-
43
- class ParseError(ValueError):
44
- """Raised when the pricing HTML cannot be parsed into a prices map."""
45
-
46
-
47
- # ─── Vendor registry ──────────────────────────────────────────────────────────
48
-
49
- @dataclass(frozen=True)
50
- class VendorConfig:
51
- """Configuration for a single pricing vendor."""
52
-
53
- name: str
54
- source_url: str
55
- currency: str
56
- parse: Callable[[str], Dict[str, Dict[str, float]]]
57
-
58
-
59
- def _parse_claude_html(html: str) -> Dict[str, Dict[str, float]]:
60
- """Parse Anthropic/Claude pricing HTML into a {model: rates} map."""
61
- model_re = re.compile(r"claude-(?:opus|sonnet|haiku)-[0-9](?:-[0-9])?")
62
- dollar_re = re.compile(r"\$\s*([0-9]+(?:\.[0-9]+)?)")
63
-
64
- extractor = _TableTextExtractor()
65
- extractor.feed(html)
66
-
67
- prices: Dict[str, Dict[str, float]] = {}
68
- for row in extractor.rows:
69
- text = " ".join(row)
70
- model_match = model_re.search(text)
71
- if not model_match:
72
- continue
73
- model = model_match.group(0)
74
- amounts = [float(m.group(1)) for m in dollar_re.finditer(text)]
75
- if len(amounts) < 4:
76
- continue
77
- in_rate, cache_create, cache_read, out_rate = amounts[:4]
78
- prices[model] = {
79
- "in": in_rate,
80
- "out": out_rate,
81
- "cache_create": cache_create,
82
- "cache_read": cache_read,
83
- }
84
-
85
- if not prices:
86
- raise ParseError("no price rows found in HTML; page layout may have changed")
87
- return prices
88
-
89
-
90
- def _parse_deepseek_html(html: str) -> Dict[str, Dict[str, float]]:
91
- """Parse DeepSeek pricing HTML into a {model: rates} map.
92
-
93
- Handles both the Chinese (元) and English ($) pricing pages.
94
- Extracts deepseek-v4-flash and deepseek-v4-pro rates, then adds
95
- deepseek-chat and deepseek-reasoner as aliases for flash.
96
- """
97
- extractor = _TableTextExtractor()
98
- extractor.feed(html)
99
-
100
- # Find the header row with model names.
101
- model_names: List[str] = []
102
- header_idx = -1
103
- for i, row in enumerate(extractor.rows):
104
- if any(k in ' '.join(row) for k in ('模型', 'MODEL')):
105
- # Cells after the label are model names.
106
- # Strip footnote markers like (1) and HTML tags.
107
- names = [
108
- re.sub(r'<[^>]+>', '', re.sub(r'\s*\(\d+\)', '', cell)).strip()
109
- for cell in row[1:]
110
- if cell.strip()
111
- ]
112
- if len(names) >= 2:
113
- model_names = names
114
- header_idx = i
115
- break
116
-
117
- if len(model_names) < 2:
118
- raise ParseError('no model header row found; page layout may have changed')
119
-
120
- # Walk rows after header to find pricing data.
121
- cache_hit: List[float] = []
122
- cache_miss: List[float] = []
123
- output: List[float] = []
124
-
125
- for row in extractor.rows[header_idx + 1:]:
126
- text = ' '.join(row)
127
- # Skip non-pricing rows.
128
- if not any(k in text for k in ('缓存命中', 'CACHE HIT', '缓存未命中', 'CACHE MISS', '输出', 'OUTPUT')):
129
- continue
130
-
131
- # Extract numeric values followed by 元 or $.
132
- values: List[float] = []
133
- for cell in row:
134
- # Match numbers like 0.02元, $0.14, 1元, etc.
135
- m = re.search(r'(?:\$)?\s*([0-9]+(?:\.[0-9]+)?)\s*(?:元|¥)?', cell)
136
- if m:
137
- values.append(float(m.group(1)))
138
-
139
- if len(values) < len(model_names):
140
- continue
141
-
142
- if any(k in text for k in ('缓存命中', 'CACHE HIT')):
143
- cache_hit = values[:len(model_names)]
144
- elif any(k in text for k in ('缓存未命中', 'CACHE MISS')):
145
- cache_miss = values[:len(model_names)]
146
- elif any(k in text for k in ('输出', 'OUTPUT')):
147
- output = values[:len(model_names)]
148
-
149
- if not cache_miss or not output:
150
- raise ParseError('no price rows found in HTML; page layout may have changed')
151
-
152
- prices: Dict[str, Dict[str, float]] = {}
153
- for idx, model in enumerate(model_names):
154
- if model in ('deepseek-v4-flash', 'deepseek-v4-pro'):
155
- prices[model] = {
156
- 'in': cache_miss[idx],
157
- 'out': output[idx],
158
- 'cache_create': cache_miss[idx],
159
- 'cache_read': cache_hit[idx] if cache_hit else 0.0,
160
- }
161
-
162
- if not prices:
163
- raise ParseError('no price rows found in HTML; page layout may have changed')
164
-
165
- return prices
166
-
167
-
168
- def _try_parse_kimi_pricing(html: str) -> Optional[Dict[str, Dict[str, float]]]:
169
- """Try to parse Kimi pricing from HTML/MDX content.
170
-
171
- Handles the JSX ``DocTable`` format used by Kimi's ``.md`` endpoints:
172
- rows contain [model, unit, cache-hit, cache-miss, output, context].
173
- """
174
- prices: Dict[str, Dict[str, float]] = {}
175
- price_re = re.compile(r"¥\s*([0-9]+(?:\.[0-9]+)?)")
176
- row_re = re.compile(
177
- r'\[\s*"([^"]+)"\s*,\s*"[^"]+"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"[^"]+"\s*\]'
178
- )
179
-
180
- for m in row_re.finditer(html):
181
- model, cache_hit_str, cache_miss_str, output_str = m.groups()
182
- cache_hit_m = price_re.search(cache_hit_str)
183
- cache_miss_m = price_re.search(cache_miss_str)
184
- output_m = price_re.search(output_str)
185
- if not all((cache_hit_m, cache_miss_m, output_m)):
186
- continue
187
- prices[model] = {
188
- "in": float(cache_miss_m.group(1)),
189
- "out": float(output_m.group(1)),
190
- "cache_create": float(cache_miss_m.group(1)),
191
- "cache_read": float(cache_hit_m.group(1)),
192
- }
193
-
194
- return prices if prices else None
195
-
196
-
197
- def _parse_kimi_html(html: str) -> Dict[str, Dict[str, float]]:
198
- """Parse Kimi pricing HTML into a {model: rates} map.
199
-
200
- Kimi pricing is split across sub-pages (``pricing/chat-k25``,
201
- ``pricing/chat-k26``). The parser first tries to extract prices from the
202
- provided HTML; if none found, it fetches the ``.md`` sub-pages and parses
203
- those.
204
- """
205
- prices = _try_parse_kimi_pricing(html)
206
- if prices:
207
- if "kimi-k2.6" in prices:
208
- prices["kimi-for-coding"] = dict(prices["kimi-k2.6"])
209
- return prices
210
-
211
- sub_urls = [
212
- "https://platform.kimi.com/docs/pricing/chat-k25.md",
213
- "https://platform.kimi.com/docs/pricing/chat-k26.md",
214
- ]
215
- combined = html
216
- for url in sub_urls:
217
- try:
218
- combined += "\n" + fetch_pricing_html(url)
219
- except FetchError as exc:
220
- raise ParseError(f"could not fetch kimi sub-page {url}: {exc}")
221
-
222
- prices = _try_parse_kimi_pricing(combined)
223
- if not prices:
224
- raise ParseError("no price rows found in kimi pages")
225
-
226
- if "kimi-k2.6" in prices:
227
- prices["kimi-for-coding"] = dict(prices["kimi-k2.6"])
228
-
229
- return prices
230
-
231
-
232
- VENDOR_REGISTRY: Dict[str, VendorConfig] = {
233
- "anthropic": VendorConfig(
234
- name="anthropic",
235
- source_url="https://platform.claude.com/docs/en/about-claude/pricing",
236
- currency="USD",
237
- parse=_parse_claude_html,
238
- ),
239
- "deepseek": VendorConfig(
240
- name="deepseek",
241
- source_url="https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
242
- currency="CNY",
243
- parse=_parse_deepseek_html,
244
- ),
245
- "kimi": VendorConfig(
246
- name="kimi",
247
- source_url="https://platform.kimi.com/docs/pricing/chat",
248
- currency="CNY",
249
- parse=_parse_kimi_html,
250
- ),
251
- }
252
-
253
-
254
- # ─── Network I/O ──────────────────────────────────────────────────────────────
255
-
256
- def fetch_pricing_html(url: str,
257
- timeout: float = DEFAULT_TIMEOUT) -> str:
258
- """Fetch the pricing docs page and return its raw HTML."""
259
- req = Request(url, headers={"User-Agent": "roll/prices_fetcher"})
260
- try:
261
- with urlopen(req, timeout=timeout) as resp:
262
- data = resp.read()
263
- charset = resp.headers.get_content_charset() or "utf-8"
264
- return data.decode(charset, errors="replace")
265
- except (URLError, OSError, TimeoutError) as exc:
266
- raise FetchError(f"could not fetch {url}: {exc}") from exc
267
-
268
-
269
- # ─── HTML parsing helpers ─────────────────────────────────────────────────────
270
-
271
- class _TableTextExtractor(HTMLParser):
272
- """Walk an HTML document and yield <tr> cell-text lists per row."""
273
-
274
- def __init__(self) -> None:
275
- super().__init__()
276
- self.rows: List[List[str]] = []
277
- self._in_row = False
278
- self._in_cell = False
279
- self._cells: List[str] = []
280
- self._cur: List[str] = []
281
-
282
- def handle_starttag(self, tag: str, attrs): # noqa: ANN001
283
- if tag == "tr":
284
- self._in_row = True
285
- self._cells = []
286
- elif tag in ("td", "th") and self._in_row:
287
- self._in_cell = True
288
- self._cur = []
289
-
290
- def handle_endtag(self, tag: str) -> None:
291
- if tag in ("td", "th") and self._in_cell:
292
- self._cells.append(" ".join(self._cur).strip())
293
- self._in_cell = False
294
- elif tag == "tr" and self._in_row:
295
- if self._cells:
296
- self.rows.append(self._cells)
297
- self._in_row = False
298
-
299
- def handle_data(self, data: str) -> None:
300
- if self._in_cell:
301
- self._cur.append(data)
302
-
303
-
304
- # ─── Parser dispatch ──────────────────────────────────────────────────────────
305
-
306
- def parse_pricing_html(html: str, vendor: str = "anthropic") -> Dict[str, Dict[str, float]]:
307
- """Parse pricing docs HTML into a {model: rates} map.
308
-
309
- Dispatches to the vendor-specific parser registered in ``VENDOR_REGISTRY``.
310
- """
311
- config = VENDOR_REGISTRY.get(vendor)
312
- if not config:
313
- raise ParseError(
314
- f"unknown vendor {vendor!r}; known: {', '.join(sorted(VENDOR_REGISTRY))}"
315
- )
316
- return config.parse(html)
317
-
318
-
319
- # ─── Diff & formatting ────────────────────────────────────────────────────────
320
-
321
- def diff_prices(old: Dict[str, Dict[str, float]],
322
- new: Dict[str, Dict[str, float]]
323
- ) -> List[Tuple[str, str, str, Optional[float], Optional[float]]]:
324
- """Return a list of (kind, model, field, old_val, new_val) tuples.
325
-
326
- kind is one of: ``added``, ``removed``, ``changed``. For added rows the
327
- old_val is None; for removed, the new_val is None.
328
- """
329
- changes: List[Tuple[str, str, str, Optional[float], Optional[float]]] = []
330
- for model in sorted(set(old) | set(new)):
331
- if model not in old:
332
- for field, val in new[model].items():
333
- changes.append(("added", model, field, None, val))
334
- continue
335
- if model not in new:
336
- for field, val in old[model].items():
337
- changes.append(("removed", model, field, val, None))
338
- continue
339
- for field in sorted(set(old[model]) | set(new[model])):
340
- old_val = old[model].get(field)
341
- new_val = new[model].get(field)
342
- if old_val != new_val:
343
- changes.append(("changed", model, field, old_val, new_val))
344
- return changes
345
-
346
-
347
- def format_diff(changes: List[Tuple[str, str, str, Optional[float], Optional[float]]],
348
- colored: bool = True) -> str:
349
- """Render diff_prices output as red-/green-coded lines."""
350
- if not changes:
351
- return ""
352
- red = "\033[31m" if colored else ""
353
- green = "\033[32m" if colored else ""
354
- dim = "\033[2m" if colored else ""
355
- reset = "\033[0m" if colored else ""
356
- lines: List[str] = []
357
- for kind, model, field, old, new in changes:
358
- if kind == "added":
359
- lines.append(f"{green}+ {model} {field} = {new}{reset}")
360
- elif kind == "removed":
361
- lines.append(f"{red}- {model} {field} = {old}{reset}")
362
- else:
363
- lines.append(f"{dim}~ {model} {field}{reset} {red}{old}{reset} → {green}{new}{reset}")
364
- return "\n".join(lines)
365
-
366
-
367
- # ─── Snapshot I/O ─────────────────────────────────────────────────────────────
368
-
369
- _SNAPSHOT_NAME_RE = re.compile(r"snapshot-(\d{4}-\d{2}-\d{2})(?:-([a-z]+))?\.json")
370
-
371
-
372
- def _extract_vendor_from_filename(name: str) -> Optional[str]:
373
- """Extract vendor from snapshot filename.
374
-
375
- snapshot-2026-05-22.json → anthropic
376
- snapshot-2026-05-22-deepseek.json → deepseek
377
- snapshot-2026-06-02-kimi.json → kimi
378
- """
379
- m = _SNAPSHOT_NAME_RE.match(name)
380
- if not m:
381
- return None
382
- return m.group(2) or "anthropic"
383
-
384
-
385
- def _latest_snapshot_path(snapshot_dir: str, vendor: str = "anthropic") -> Optional[str]:
386
- if not os.path.isdir(snapshot_dir):
387
- return None
388
- snaps = sorted(
389
- os.path.join(snapshot_dir, n)
390
- for n in os.listdir(snapshot_dir)
391
- if _SNAPSHOT_NAME_RE.match(n) and _extract_vendor_from_filename(n) == vendor
392
- )
393
- return snaps[-1] if snaps else None
394
-
395
-
396
- def write_snapshot(prices: Dict[str, Dict[str, float]],
397
- *,
398
- snapshot_dir: str,
399
- source_url: str,
400
- vendor: str = "anthropic",
401
- currency: str = "USD",
402
- effective_at: Optional[str] = None,
403
- default_model: Optional[str] = None,
404
- notes: Optional[str] = None) -> str:
405
- """Write a new snapshot JSON and return its path."""
406
- os.makedirs(snapshot_dir, exist_ok=True)
407
- today = effective_at or _dt.date.today().isoformat()
408
- payload: Dict[str, Any] = {
409
- "version": today,
410
- "effective_at": today,
411
- "source_url": source_url,
412
- "vendor": vendor,
413
- "currency": currency,
414
- "default_model": default_model or _pick_default(prices),
415
- "prices": prices,
416
- }
417
- if notes:
418
- payload["notes"] = notes
419
- suffix = f"-{vendor}" if vendor != "anthropic" else ""
420
- dest = os.path.join(snapshot_dir, f"snapshot-{today}{suffix}.json")
421
- with open(dest, "w", encoding="utf-8") as f:
422
- json.dump(payload, f, indent=2, sort_keys=False)
423
- f.write("\n")
424
- return dest
425
-
426
-
427
- def _pick_default(prices: Dict[str, Dict[str, float]]) -> str:
428
- """Pick a sensible fallback model: prefer the cheapest sonnet, else first key."""
429
- for k in prices:
430
- if "sonnet" in k:
431
- return k
432
- return next(iter(prices))
433
-
434
-
435
- # ─── Orchestrator ─────────────────────────────────────────────────────────────
436
-
437
- def refresh(*,
438
- snapshot_dir: str,
439
- vendor: str = "anthropic",
440
- url: Optional[str] = None,
441
- timeout: float = DEFAULT_TIMEOUT,
442
- html: Optional[str] = None,
443
- ) -> Tuple[str, List[Tuple[str, str, str, Optional[float], Optional[float]]]]:
444
- """Fetch (or accept fixture HTML), parse, diff against latest snapshot, write.
445
-
446
- Returns (action, changes) where action is one of:
447
- ``"unchanged"`` — no diff vs latest snapshot, nothing written
448
- ``"written:<path>"`` — new snapshot written at <path>
449
- ``"first:<path>"`` — no prior snapshot existed; baseline written
450
- """
451
- config = VENDOR_REGISTRY.get(vendor)
452
- if not config:
453
- raise ParseError(
454
- f"unknown vendor {vendor!r}; known: {', '.join(sorted(VENDOR_REGISTRY))}"
455
- )
456
-
457
- source_url = url or config.source_url
458
- if html is None:
459
- html = fetch_pricing_html(source_url, timeout=timeout)
460
- new_prices = parse_pricing_html(html, vendor=vendor)
461
-
462
- latest = _latest_snapshot_path(snapshot_dir, vendor=vendor)
463
- if latest is None:
464
- dest = write_snapshot(
465
- new_prices,
466
- snapshot_dir=snapshot_dir,
467
- source_url=source_url,
468
- vendor=vendor,
469
- currency=config.currency,
470
- )
471
- return f"first:{dest}", diff_prices({}, new_prices)
472
-
473
- with open(latest, "r", encoding="utf-8") as f:
474
- old = json.load(f).get("prices", {})
475
- changes = diff_prices(old, new_prices)
476
- if not changes:
477
- return "unchanged", []
478
- dest = write_snapshot(
479
- new_prices,
480
- snapshot_dir=snapshot_dir,
481
- source_url=source_url,
482
- vendor=vendor,
483
- currency=config.currency,
484
- )
485
- return f"written:{dest}", changes
486
-
487
-
488
- # ─── CLI entry — `python3 lib/prices_fetcher.py refresh|show` is the fallback when
489
- # bin/roll is unavailable (e.g. running tests directly).
490
- def _main(argv: List[str]) -> int:
491
- snapshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "prices")
492
- if not argv or argv[0] in ("-h", "--help", "help"):
493
- print("usage: prices_fetcher.py refresh|show [--url URL] [--vendor VENDOR]")
494
- return 0
495
- cmd = argv[0]
496
- url: Optional[str] = None
497
- vendor = "anthropic"
498
- i = 1
499
- while i < len(argv):
500
- if argv[i] == "--url" and i + 1 < len(argv):
501
- url = argv[i + 1]
502
- i += 2
503
- elif argv[i] == "--vendor" and i + 1 < len(argv):
504
- vendor = argv[i + 1]
505
- i += 2
506
- else:
507
- i += 1
508
- if cmd == "show":
509
- latest = _latest_snapshot_path(snapshot_dir, vendor=vendor)
510
- if not latest:
511
- print("no snapshot found", file=sys.stderr)
512
- return 1
513
- with open(latest) as f:
514
- print(f.read())
515
- return 0
516
- if cmd == "refresh":
517
- try:
518
- action, changes = refresh(snapshot_dir=snapshot_dir, vendor=vendor, url=url)
519
- except FetchError as exc:
520
- print(f"fetch failed: {exc}", file=sys.stderr)
521
- return 2
522
- except ParseError as exc:
523
- print(f"parse failed: {exc}", file=sys.stderr)
524
- return 3
525
- print(action)
526
- if changes:
527
- print(format_diff(changes, colored=sys.stdout.isatty()))
528
- return 0
529
- print(f"unknown command: {cmd}", file=sys.stderr)
530
- return 1
531
-
532
-
533
- if __name__ == "__main__": # pragma: no cover
534
- sys.exit(_main(sys.argv[1:]))