bca-mcp 0.2.3__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.
bca_mcp/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Blockchain Academics MCP server (Python).
2
+
3
+ Sibling of `@blockchainacademics/mcp` — same tool surface, same REST API,
4
+ same attribution contract.
5
+ """
6
+
7
+ from bca_mcp.client import BcaClient, get_client, set_client
8
+ from bca_mcp.errors import (
9
+ BcaAuthError,
10
+ BcaBadRequestError,
11
+ BcaError,
12
+ BcaNetworkError,
13
+ BcaRateLimitError,
14
+ BcaUpstreamError,
15
+ )
16
+
17
+ __version__ = "0.2.3"
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ "BcaClient",
22
+ "get_client",
23
+ "set_client",
24
+ "BcaError",
25
+ "BcaAuthError",
26
+ "BcaBadRequestError",
27
+ "BcaNetworkError",
28
+ "BcaRateLimitError",
29
+ "BcaUpstreamError",
30
+ ]
bca_mcp/__main__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Entry point — `python -m bca_mcp` or `bca-mcp` runs the stdio server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+
8
+ from bca_mcp.server import run_stdio
9
+
10
+
11
+ def main() -> None:
12
+ try:
13
+ asyncio.run(run_stdio())
14
+ except KeyboardInterrupt:
15
+ sys.exit(0)
16
+ except Exception as err: # noqa: BLE001
17
+ # Fatal startup → stderr so the host sees it without corrupting stdio.
18
+ sys.stderr.write(f"[bca-mcp] fatal: {err!r}\n")
19
+ sys.exit(1)
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
@@ -0,0 +1,11 @@
1
+ """`bca` command-line interface — bundled with the bca-mcp package.
2
+
3
+ Human-facing counterpart to the MCP server. Talks to the same REST API
4
+ (`api.blockchainacademics.com`) via the same `BcaClient`. Distribution:
5
+ `pipx install bca-mcp` OR `pip install bca-mcp` exposes the `bca` binary
6
+ alongside the `bca-mcp` MCP server entry point.
7
+ """
8
+
9
+ from bca_mcp.cli.main import app
10
+
11
+ __all__ = ["app"]
bca_mcp/cli/config.py ADDED
@@ -0,0 +1,61 @@
1
+ """CLI config file at `~/.bca/config.toml` — stores API key + base URL.
2
+
3
+ Env vars (`BCA_API_KEY`, `BCA_API_BASE`) take precedence over stored values.
4
+ Config is chmod 600 — plaintext key on disk, user-scoped.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ if sys.version_info >= (3, 11):
15
+ import tomllib
16
+ else: # pragma: no cover
17
+ import tomli as tomllib # type: ignore[no-redef]
18
+
19
+ import tomli_w
20
+
21
+ CONFIG_DIR = Path.home() / ".bca"
22
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
23
+
24
+
25
+ def read_config() -> dict[str, Any]:
26
+ if not CONFIG_FILE.exists():
27
+ return {}
28
+ try:
29
+ return tomllib.loads(CONFIG_FILE.read_text(encoding="utf-8"))
30
+ except (tomllib.TOMLDecodeError, OSError):
31
+ return {}
32
+
33
+
34
+ def write_config(data: dict[str, Any]) -> None:
35
+ CONFIG_DIR.mkdir(mode=0o700, exist_ok=True)
36
+ CONFIG_FILE.write_bytes(tomli_w.dumps(data).encode("utf-8"))
37
+ try:
38
+ CONFIG_FILE.chmod(0o600)
39
+ except OSError: # pragma: no cover — non-POSIX fallback
40
+ pass
41
+
42
+
43
+ def apply_env_defaults() -> None:
44
+ """Load config into env vars unless env already set.
45
+
46
+ Called at the top of every CLI command so `BcaClient()` (which reads
47
+ env) picks up stored values transparently. Env wins — no clobbering.
48
+ """
49
+ cfg = read_config()
50
+ if not os.environ.get("BCA_API_KEY") and cfg.get("api_key"):
51
+ os.environ["BCA_API_KEY"] = str(cfg["api_key"])
52
+ if not os.environ.get("BCA_API_BASE") and cfg.get("api_base"):
53
+ os.environ["BCA_API_BASE"] = str(cfg["api_base"])
54
+
55
+
56
+ def mask_key(key: str | None) -> str:
57
+ if not key:
58
+ return "(not set)"
59
+ if len(key) < 12:
60
+ return "***"
61
+ return f"{key[:8]}…{key[-4:]}"
bca_mcp/cli/main.py ADDED
@@ -0,0 +1,411 @@
1
+ """`bca` CLI — human-facing counterpart to the MCP server.
2
+
3
+ Each subcommand reuses `bca_mcp.client.BcaClient` + existing tool wrappers,
4
+ so the CLI stays in lockstep with MCP tool behavior and envelope parsing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from typing import Any, Optional
12
+
13
+ import typer
14
+
15
+ from bca_mcp.cli.config import (
16
+ CONFIG_FILE,
17
+ apply_env_defaults,
18
+ mask_key,
19
+ read_config,
20
+ write_config,
21
+ )
22
+ from bca_mcp.cli.render import (
23
+ as_json,
24
+ cite_footer,
25
+ console,
26
+ entity_panel,
27
+ explainer_render,
28
+ indicator_panel,
29
+ markdown_render,
30
+ news_table,
31
+ price_table,
32
+ unwrap,
33
+ )
34
+ from bca_mcp.cli.runner import err_console, handle_errors, run_async
35
+
36
+ app = typer.Typer(
37
+ name="bca",
38
+ help="Blockchain Academics CLI — crypto intelligence for terminals + scripts.",
39
+ no_args_is_help=True,
40
+ add_completion=False,
41
+ )
42
+ config_app = typer.Typer(help="Config management.")
43
+ news_app = typer.Typer(help="News search + retrieval.")
44
+ market_app = typer.Typer(help="Market data (prices, overview).")
45
+ app.add_typer(config_app, name="config")
46
+ app.add_typer(news_app, name="news")
47
+ app.add_typer(market_app, name="market")
48
+
49
+
50
+ # ---------- core commands ----------
51
+
52
+
53
+ @app.command()
54
+ def login(
55
+ api_key: Optional[str] = typer.Option(
56
+ None,
57
+ "--api-key",
58
+ "-k",
59
+ help="API key; if omitted, prompted interactively.",
60
+ ),
61
+ api_base: Optional[str] = typer.Option(
62
+ None,
63
+ "--api-base",
64
+ help="Override API base URL (default https://api.blockchainacademics.com).",
65
+ ),
66
+ ) -> None:
67
+ """Store your BCA API key in ~/.bca/config.toml (chmod 600)."""
68
+ import os
69
+
70
+ cfg = read_config()
71
+ if api_key is None:
72
+ api_key = typer.prompt("BCA API key", hide_input=True)
73
+ if not api_key.strip():
74
+ err_console.print("[red]Empty key; aborting.[/red]")
75
+ raise typer.Exit(code=1)
76
+ cfg["api_key"] = api_key.strip()
77
+ if api_base:
78
+ cleaned = api_base.strip().rstrip("/")
79
+ # HIGH: refuse to persist a non-HTTPS base URL unless the operator
80
+ # has explicitly opted in via BCA_ALLOW_INSECURE_BASE=1. Mirrors the
81
+ # same gate inside BcaClient so CLI config and runtime agree.
82
+ if (
83
+ not cleaned.startswith("https://")
84
+ and os.environ.get("BCA_ALLOW_INSECURE_BASE") != "1"
85
+ ):
86
+ err_console.print(
87
+ f"[red]Refusing to save non-HTTPS api_base='{cleaned}'.[/red] "
88
+ "Set BCA_ALLOW_INSECURE_BASE=1 to override for local dev."
89
+ )
90
+ raise typer.Exit(code=1)
91
+ cfg["api_base"] = cleaned
92
+ write_config(cfg)
93
+ console.print(f"[green]Saved[/green] to [bold]{CONFIG_FILE}[/bold]")
94
+
95
+
96
+ @config_app.command("show")
97
+ def config_show() -> None:
98
+ """Show config location and masked key."""
99
+ cfg = read_config()
100
+ console.print(f"[bold]Config file:[/bold] {CONFIG_FILE}")
101
+ console.print(f"[bold]API key:[/bold] {mask_key(cfg.get('api_key'))}")
102
+ console.print(
103
+ f"[bold]API base:[/bold] "
104
+ f"{cfg.get('api_base') or 'https://api.blockchainacademics.com (default)'}"
105
+ )
106
+
107
+
108
+ @app.command()
109
+ @handle_errors
110
+ @run_async
111
+ async def version() -> None:
112
+ """Show CLI version + live API version."""
113
+ from bca_mcp import __version__
114
+
115
+ console.print(f"[bold]bca-mcp[/bold] {__version__}")
116
+ apply_env_defaults()
117
+ from bca_mcp.client import BcaClient
118
+
119
+ client = BcaClient()
120
+ try:
121
+ res = await client.request("/v1/status")
122
+ data = unwrap(res)
123
+ if isinstance(data, dict):
124
+ api_ver = data.get("version") or data.get("commit") or "live"
125
+ console.print(f"[bold]api:[/bold] {api_ver}")
126
+ except Exception as exc: # noqa: BLE001
127
+ console.print(f"[yellow]API unreachable:[/yellow] {exc}")
128
+
129
+
130
+ # ---------- news ----------
131
+
132
+
133
+ @news_app.command("search")
134
+ @handle_errors
135
+ @run_async
136
+ async def news_search(
137
+ query: str = typer.Argument(..., help="Search query (1-512 chars)."),
138
+ limit: int = typer.Option(10, "--limit", "-n", min=1, max=50),
139
+ entity: Optional[str] = typer.Option(None, "--entity"),
140
+ topic: Optional[str] = typer.Option(None, "--topic"),
141
+ since: Optional[str] = typer.Option(None, "--since", help="ISO 8601."),
142
+ json_out: bool = typer.Option(False, "--json"),
143
+ ) -> None:
144
+ """Full-text search across the BCA corpus."""
145
+ apply_env_defaults()
146
+ from bca_mcp.tools import search_news
147
+
148
+ args = {"query": query, "limit": limit}
149
+ if entity:
150
+ args["entity"] = entity
151
+ if topic:
152
+ args["topic"] = topic
153
+ if since:
154
+ args["since"] = since
155
+ res = await search_news.run(args)
156
+ if json_out:
157
+ as_json(res)
158
+ return
159
+ data = unwrap(res)
160
+ articles = (
161
+ data.get("articles") if isinstance(data, dict) else data
162
+ ) or (data if isinstance(data, list) else [])
163
+ if not articles:
164
+ console.print("[yellow]No results.[/yellow]")
165
+ return
166
+ news_table(articles)
167
+ cite_footer(res)
168
+
169
+
170
+ # ---------- entity ----------
171
+
172
+
173
+ @app.command()
174
+ @handle_errors
175
+ @run_async
176
+ async def entity(
177
+ slug: str = typer.Argument(..., help="Entity slug (e.g. 'ethereum')."),
178
+ json_out: bool = typer.Option(False, "--json"),
179
+ ) -> None:
180
+ """Entity dossier: chain, project, person, or ticker."""
181
+ apply_env_defaults()
182
+ from bca_mcp.tools import get_entity
183
+
184
+ res = await get_entity.run({"slug": slug})
185
+ if json_out:
186
+ as_json(res)
187
+ return
188
+ data = unwrap(res)
189
+ if isinstance(data, dict):
190
+ entity_panel(data)
191
+ else:
192
+ console.print(str(data))
193
+ cite_footer(res)
194
+
195
+
196
+ # ---------- market ----------
197
+
198
+
199
+ @app.command("price")
200
+ @handle_errors
201
+ @run_async
202
+ async def price(
203
+ tickers: str = typer.Argument(..., help="Comma-separated tickers, e.g. BTC,ETH,SOL"),
204
+ json_out: bool = typer.Option(False, "--json"),
205
+ ) -> None:
206
+ """Spot price + 24h change for one or more tickers."""
207
+ apply_env_defaults()
208
+ from bca_mcp.tools import market
209
+
210
+ ticker_list = [t.strip().upper() for t in tickers.split(",") if t.strip()]
211
+ if not ticker_list:
212
+ err_console.print("[red]No tickers provided.[/red]")
213
+ raise typer.Exit(code=1)
214
+ rows: list[dict[str, Any]] = []
215
+ last_envelope: dict[str, Any] = {}
216
+ for t in ticker_list:
217
+ try:
218
+ res = await market.run_get_price({"ticker": t})
219
+ except AttributeError:
220
+ # Fallback if tool module shape differs.
221
+ from bca_mcp.client import get_client
222
+
223
+ res = await get_client().request("/v1/prices", {"ticker": t})
224
+ last_envelope = res if isinstance(res, dict) else {}
225
+ data = unwrap(res)
226
+ if isinstance(data, dict):
227
+ data.setdefault("ticker", t)
228
+ rows.append(data)
229
+ elif isinstance(data, list):
230
+ rows.extend(d for d in data if isinstance(d, dict))
231
+ if json_out:
232
+ as_json(rows)
233
+ return
234
+ if not rows:
235
+ console.print("[yellow]No price data.[/yellow]")
236
+ return
237
+ price_table(rows)
238
+ cite_footer(last_envelope)
239
+
240
+
241
+ @market_app.command("overview")
242
+ @handle_errors
243
+ @run_async
244
+ async def market_overview(
245
+ limit: int = typer.Option(10, "--limit", "-n", min=1, max=50),
246
+ json_out: bool = typer.Option(False, "--json"),
247
+ ) -> None:
248
+ """Top-N market overview by market cap."""
249
+ apply_env_defaults()
250
+ from bca_mcp.client import get_client
251
+
252
+ res = await get_client().request("/v1/market/overview", {"limit": limit})
253
+ if json_out:
254
+ as_json(res)
255
+ return
256
+ data = unwrap(res)
257
+ rows = (
258
+ data.get("tokens") if isinstance(data, dict) else data
259
+ ) or (data if isinstance(data, list) else [])
260
+ if not rows:
261
+ console.print("[yellow]No data.[/yellow]")
262
+ return
263
+ price_table(rows)
264
+ cite_footer(res)
265
+
266
+
267
+ # ---------- explainer ----------
268
+
269
+
270
+ @app.command()
271
+ @handle_errors
272
+ @run_async
273
+ async def explainer(
274
+ slug: str = typer.Argument(..., help="Academy lesson slug."),
275
+ json_out: bool = typer.Option(False, "--json"),
276
+ ) -> None:
277
+ """Render an academy lesson as markdown."""
278
+ apply_env_defaults()
279
+ from bca_mcp.tools import get_explainer
280
+
281
+ res = await get_explainer.run({"slug": slug})
282
+ if json_out:
283
+ as_json(res)
284
+ return
285
+ data = unwrap(res)
286
+ if isinstance(data, dict):
287
+ explainer_render(data)
288
+ else:
289
+ console.print(str(data))
290
+ cite_footer(res)
291
+
292
+
293
+ # ---------- indicators ----------
294
+
295
+
296
+ @app.command()
297
+ @handle_errors
298
+ @run_async
299
+ async def indicator(
300
+ name: str = typer.Argument(..., help="Indicator slug, e.g. coverage-index."),
301
+ entity: str = typer.Argument(..., help="Entity slug."),
302
+ window: str = typer.Option("30d", "--window", "-w", help="7d | 30d | 90d"),
303
+ json_out: bool = typer.Option(False, "--json"),
304
+ ) -> None:
305
+ """Read a proprietary indicator value."""
306
+ apply_env_defaults()
307
+ from bca_mcp.client import get_client
308
+
309
+ res = await get_client().request(
310
+ f"/v1/indicators/{name}",
311
+ {"entity": entity, "window": window},
312
+ )
313
+ if json_out:
314
+ as_json(res)
315
+ return
316
+ data = unwrap(res)
317
+ if isinstance(data, dict):
318
+ indicator_panel(name, entity, data)
319
+ else:
320
+ console.print(str(data))
321
+ cite_footer(res)
322
+
323
+
324
+ # ---------- agent ----------
325
+
326
+
327
+ @app.command()
328
+ @handle_errors
329
+ @run_async
330
+ async def agent(
331
+ skill: str = typer.Argument(
332
+ ...,
333
+ help="Skill slug: due-diligence, tokenomics-model, summarize-whitepaper, "
334
+ "translate-contract, monitor-keyword.",
335
+ ),
336
+ entity: Optional[str] = typer.Option(None, "--entity"),
337
+ url: Optional[str] = typer.Option(None, "--url"),
338
+ source: Optional[str] = typer.Option(None, "--source"),
339
+ target: Optional[str] = typer.Option(None, "--target"),
340
+ keyword: Optional[str] = typer.Option(None, "--keyword"),
341
+ timeout_s: int = typer.Option(120, "--timeout", help="Polling timeout seconds."),
342
+ json_out: bool = typer.Option(False, "--json"),
343
+ ) -> None:
344
+ """Kick off an async agent-backed skill job and poll until done."""
345
+ apply_env_defaults()
346
+ from bca_mcp.client import get_client
347
+
348
+ client = get_client()
349
+ payload: dict[str, Any] = {}
350
+ for k, v in (
351
+ ("entity", entity),
352
+ ("url", url),
353
+ ("source_language", source),
354
+ ("target_language", target),
355
+ ("keyword", keyword),
356
+ ):
357
+ if v is not None:
358
+ payload[k] = v
359
+
360
+ res = await client.post(f"/v1/agent-jobs/{skill}/run", payload)
361
+ data = unwrap(res)
362
+ job_id = (data or {}).get("job_id") if isinstance(data, dict) else None
363
+ if not job_id:
364
+ err_console.print(
365
+ "[red]No job_id returned.[/red] Raw response:"
366
+ )
367
+ as_json(res)
368
+ raise typer.Exit(code=1)
369
+
370
+ console.print(f"[cyan]Job[/cyan] {job_id} [dim]submitted — polling…[/dim]")
371
+ start = time.monotonic()
372
+ with console.status("[dim]Running skill…[/dim]", spinner="dots"):
373
+ while time.monotonic() - start < timeout_s:
374
+ poll = await client.request(f"/v1/agent-jobs/{job_id}")
375
+ poll_data = unwrap(poll) or {}
376
+ status = poll_data.get("status") if isinstance(poll_data, dict) else None
377
+ if status in ("completed", "success", "done"):
378
+ if json_out:
379
+ as_json(poll)
380
+ return
381
+ output = poll_data.get("output") if isinstance(poll_data, dict) else None
382
+ if isinstance(output, dict):
383
+ # Try common shapes: {markdown, summary, body}
384
+ body = (
385
+ output.get("markdown")
386
+ or output.get("summary")
387
+ or output.get("body")
388
+ )
389
+ if body:
390
+ markdown_render(str(body))
391
+ else:
392
+ as_json(output)
393
+ elif isinstance(output, str):
394
+ markdown_render(output)
395
+ else:
396
+ as_json(poll)
397
+ cite_footer(poll)
398
+ return
399
+ if status in ("failed", "error"):
400
+ err_console.print(
401
+ f"[red]Job {job_id} failed:[/red] "
402
+ f"{poll_data.get('error') if isinstance(poll_data, dict) else status}"
403
+ )
404
+ raise typer.Exit(code=1)
405
+ await asyncio.sleep(2)
406
+ err_console.print(f"[yellow]Timeout after {timeout_s}s.[/yellow] Job {job_id} still running.")
407
+ raise typer.Exit(code=124)
408
+
409
+
410
+ if __name__ == "__main__": # pragma: no cover
411
+ app()
bca_mcp/cli/render.py ADDED
@@ -0,0 +1,117 @@
1
+ """Rich rendering helpers — tables, panels, markdown for CLI output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Iterable
7
+
8
+ from rich.console import Console
9
+ from rich.markdown import Markdown
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ console = Console()
14
+
15
+
16
+ def as_json(payload: Any) -> None:
17
+ """Dump response verbatim (including envelope)."""
18
+ console.print_json(json.dumps(payload, default=str))
19
+
20
+
21
+ def unwrap(envelope: dict[str, Any]) -> Any:
22
+ """Return envelope['data'] for rendering, falling back to envelope itself."""
23
+ if isinstance(envelope, dict) and "data" in envelope:
24
+ return envelope["data"]
25
+ return envelope
26
+
27
+
28
+ def cite_footer(envelope: dict[str, Any]) -> None:
29
+ cite = envelope.get("cite_url") if isinstance(envelope, dict) else None
30
+ as_of = envelope.get("as_of") if isinstance(envelope, dict) else None
31
+ if cite or as_of:
32
+ bits: list[str] = []
33
+ if cite:
34
+ bits.append(f"[link={cite}]{cite}[/link]")
35
+ if as_of:
36
+ bits.append(f"as_of={as_of}")
37
+ console.print(f"[dim]{' · '.join(bits)}[/dim]")
38
+
39
+
40
+ def news_table(rows: Iterable[dict[str, Any]]) -> None:
41
+ table = Table(show_header=True, header_style="bold cyan")
42
+ table.add_column("Published")
43
+ table.add_column("Title")
44
+ table.add_column("Source")
45
+ table.add_column("Cite")
46
+ for row in rows:
47
+ table.add_row(
48
+ str(row.get("published_at") or "—")[:10],
49
+ str(row.get("title") or "—"),
50
+ str(row.get("source") or row.get("source_slug") or "—"),
51
+ str(row.get("cite_url") or row.get("url") or "—"),
52
+ )
53
+ console.print(table)
54
+
55
+
56
+ def price_table(rows: Iterable[dict[str, Any]]) -> None:
57
+ table = Table(show_header=True, header_style="bold cyan")
58
+ table.add_column("Ticker")
59
+ table.add_column("Price (USD)", justify="right")
60
+ table.add_column("24h %", justify="right")
61
+ table.add_column("Market cap", justify="right")
62
+ for row in rows:
63
+ ch = row.get("change_24h_percent") or row.get("change_24h")
64
+ ch_str = f"{ch:+.2f}%" if isinstance(ch, (int, float)) else "—"
65
+ color = "green" if isinstance(ch, (int, float)) and ch >= 0 else "red"
66
+ mc = row.get("market_cap_usd") or row.get("market_cap")
67
+ table.add_row(
68
+ str(row.get("ticker") or row.get("symbol") or "—").upper(),
69
+ f"${row.get('price_usd') or row.get('price') or '—'}",
70
+ f"[{color}]{ch_str}[/{color}]",
71
+ f"${mc:,.0f}" if isinstance(mc, (int, float)) else "—",
72
+ )
73
+ console.print(table)
74
+
75
+
76
+ def entity_panel(data: dict[str, Any]) -> None:
77
+ title = data.get("name") or data.get("slug") or "entity"
78
+ kind = data.get("kind") or "—"
79
+ body_lines: list[str] = [f"[bold]kind:[/bold] {kind}"]
80
+ for key in ("ticker", "chain", "website", "twitter", "github"):
81
+ val = data.get(key)
82
+ if val:
83
+ body_lines.append(f"[bold]{key}:[/bold] {val}")
84
+ if data.get("summary"):
85
+ body_lines.append("")
86
+ body_lines.append(str(data["summary"]))
87
+ console.print(
88
+ Panel.fit(
89
+ "\n".join(body_lines),
90
+ title=f"[bold cyan]{title}[/bold cyan]",
91
+ border_style="cyan",
92
+ )
93
+ )
94
+
95
+
96
+ def indicator_panel(name: str, entity: str, data: dict[str, Any]) -> None:
97
+ value = data.get("value") if isinstance(data, dict) else data
98
+ window = data.get("window") if isinstance(data, dict) else "—"
99
+ console.print(
100
+ Panel.fit(
101
+ f"[bold]value:[/bold] {value}\n"
102
+ f"[bold]window:[/bold] {window}",
103
+ title=f"[bold magenta]{name}[/bold magenta] · {entity}",
104
+ border_style="magenta",
105
+ )
106
+ )
107
+
108
+
109
+ def explainer_render(data: dict[str, Any]) -> None:
110
+ title = data.get("title") or data.get("slug") or "Explainer"
111
+ console.print(f"[bold cyan]{title}[/bold cyan]\n")
112
+ body = data.get("body") or data.get("content") or data.get("summary") or ""
113
+ console.print(Markdown(str(body)))
114
+
115
+
116
+ def markdown_render(text: str) -> None:
117
+ console.print(Markdown(text))