vulnerability-explorer 1.0.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.
vex/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Vulnerability Explorer Package."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1 @@
1
+ """Typer command definitions for vex CLI."""
@@ -0,0 +1,14 @@
1
+ """Command to navigate the catalog interactively."""
2
+
3
+ from vex.commands.options import OptRich
4
+ from vex.core.catalog import collect_entries
5
+ from vex.utils.display import interactive_navigation
6
+ from vex.utils.filesystem import find_repo_root
7
+
8
+
9
+ def cmd_interactive(rich: OptRich = True) -> None:
10
+ """Interactive navigation menu."""
11
+
12
+ root = find_repo_root()
13
+ entries = collect_entries(root)
14
+ interactive_navigation(entries, use_rich=rich)
vex/commands/list.py ADDED
@@ -0,0 +1,29 @@
1
+ """Command to list catalog entries with optional filters."""
2
+
3
+ from vex.commands.options import OptCategory, OptLanguage, OptMode, OptRich, OptType
4
+ from vex.core.catalog import collect_entries
5
+ from vex.utils.display import display_list
6
+ from vex.utils.filesystem import find_repo_root
7
+
8
+
9
+ def cmd_list(category: OptCategory = None, language: OptLanguage = None, mode: OptMode = None, entry_type: OptType = None, rich: OptRich = True) -> None:
10
+ """List entries with optional filters."""
11
+
12
+ root = find_repo_root()
13
+ entries = collect_entries(root)
14
+
15
+ filtered = entries
16
+
17
+ if category:
18
+ filtered = [e for e in filtered if e["fm"].get("category") == category]
19
+
20
+ if language:
21
+ filtered = [e for e in filtered if language in (e["fm"].get("languages") or [])]
22
+
23
+ if mode:
24
+ filtered = [e for e in filtered if mode in (e["fm"].get("analysis_modes") or [])]
25
+
26
+ if entry_type:
27
+ filtered = [e for e in filtered if e["fm"].get("type") == entry_type]
28
+
29
+ display_list(filtered, use_rich=rich)
@@ -0,0 +1,78 @@
1
+ """Reusable Typer options and arguments definitions using Annotated."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ # Global / Shared Options
9
+ OptRich = Annotated[
10
+ bool,
11
+ typer.Option("--rich/--no-rich", help="Enable or disable Rich formatted output", rich_help_panel="Global Options"),
12
+ ]
13
+
14
+ OptVersion = Annotated[
15
+ bool | None,
16
+ typer.Option(
17
+ "--version",
18
+ "-V",
19
+ help="Print version and exit.",
20
+ rich_help_panel="Global Options",
21
+ ),
22
+ ]
23
+
24
+ # Explore Command Options & Arguments
25
+ OptCategory = Annotated[
26
+ str | None,
27
+ typer.Option("--category", "-c", help="Filter by category slug"),
28
+ ]
29
+
30
+ OptLanguage = Annotated[
31
+ str | None,
32
+ typer.Option("--language", "-l", help="Filter by language slug"),
33
+ ]
34
+
35
+ OptMode = Annotated[
36
+ str | None,
37
+ typer.Option("--mode", "-m", help="Filter by analysis mode"),
38
+ ]
39
+
40
+ OptType = Annotated[
41
+ str | None,
42
+ typer.Option("--type", "-t", help="Filter by entry type (concept, manifestation, runtime-signature)"),
43
+ ]
44
+
45
+ OptNoPager = Annotated[
46
+ bool,
47
+ typer.Option("--no-pager", help="Disable pager and print directly to terminal"),
48
+ ]
49
+
50
+ ArgEntryId = Annotated[
51
+ str,
52
+ typer.Argument(help="Entry ID (exact or prefix match)"),
53
+ ]
54
+
55
+ ArgQuery = Annotated[
56
+ str,
57
+ typer.Argument(help="Search query string"),
58
+ ]
59
+
60
+ # Linter Options
61
+ OptRoot = Annotated[
62
+ Path | None,
63
+ typer.Option("--root", "-r", help="Repository root path (default: auto-detected)"),
64
+ ]
65
+
66
+ # Sync Command Options
67
+ DEFAULT_REMOTE_URL = "https://github.com/othonhugo/vulnerability-explorer.git"
68
+ DEFAULT_BRANCH = "unstable"
69
+
70
+ OptRemote = Annotated[
71
+ str,
72
+ typer.Option("--remote", "-r", help="Remote Git repository URL"),
73
+ ]
74
+
75
+ OptBranch = Annotated[
76
+ str,
77
+ typer.Option("--branch", "-b", help="Git branch to pull/clone"),
78
+ ]
vex/commands/read.py ADDED
@@ -0,0 +1,42 @@
1
+ """Command to read a specific catalog entry by ID."""
2
+
3
+ import typer
4
+
5
+ from vex.commands.options import ArgEntryId, OptNoPager, OptRich
6
+ from vex.core.catalog import collect_entries
7
+ from vex.core.models import CatalogEntry
8
+ from vex.utils.display import display_read
9
+ from vex.utils.filesystem import find_repo_root
10
+
11
+
12
+ def cmd_read(entry_id: ArgEntryId, no_pager: OptNoPager = False, rich: OptRich = True) -> None:
13
+ """Read an entry by ID using Pager."""
14
+
15
+ root = find_repo_root()
16
+ entries = collect_entries(root)
17
+
18
+ entry_match: CatalogEntry | None = None
19
+
20
+ for e in entries:
21
+ if e["fm"].get("id") == entry_id:
22
+ entry_match = e
23
+ break
24
+
25
+ if not entry_match:
26
+ candidates = [e for e in entries if str(e["fm"].get("id", "")).startswith(entry_id)]
27
+
28
+ if len(candidates) == 1:
29
+ entry_match = candidates[0]
30
+ elif len(candidates) > 1:
31
+ typer.echo(f"Ambiguous ID '{entry_id}'. Matches:")
32
+
33
+ for c in candidates:
34
+ typer.echo(f" {c['fm'].get('id')}")
35
+
36
+ raise typer.Exit(code=1)
37
+ else:
38
+ typer.echo(f"No entry found with ID '{entry_id}'.")
39
+
40
+ raise typer.Exit(code=1)
41
+
42
+ display_read(entry_match, use_pager=not no_pager, use_rich=rich)
vex/commands/search.py ADDED
@@ -0,0 +1,27 @@
1
+ """Command to search across the vulnerability catalog."""
2
+
3
+ from vex.commands.options import ArgQuery, OptRich
4
+ from vex.core.catalog import collect_entries
5
+ from vex.core.models import CatalogEntry
6
+ from vex.utils.display import display_search
7
+ from vex.utils.filesystem import find_repo_root
8
+
9
+
10
+ def cmd_search(query: ArgQuery, rich: OptRich = True) -> None:
11
+ """Search entries for query term."""
12
+
13
+ root = find_repo_root()
14
+ entries = collect_entries(root)
15
+ q = query.lower()
16
+
17
+ results: list[tuple[int, CatalogEntry]] = []
18
+
19
+ for e in entries:
20
+ full_text = e["path"].read_text(encoding="utf-8").lower()
21
+
22
+ if q in full_text:
23
+ count = full_text.count(q)
24
+ results.append((count, e))
25
+
26
+ results.sort(key=lambda x: x[0], reverse=True)
27
+ display_search(results, query, use_rich=rich)
vex/commands/sync.py ADDED
@@ -0,0 +1,50 @@
1
+ """Command for syncing/updating global vulnerability catalog from remote Git repository."""
2
+
3
+ import subprocess
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from vex.commands.options import OptBranch, OptRemote
9
+ from vex.utils.filesystem import get_global_cache_dir
10
+
11
+ console = Console()
12
+ stderr_console = Console(stderr=True)
13
+
14
+
15
+ def cmd_sync(
16
+ remote: OptRemote,
17
+ branch: OptBranch,
18
+ ) -> None:
19
+ """Download or update the global vulnerability catalog cache."""
20
+ cache_dir = get_global_cache_dir()
21
+
22
+ # Check if git is available in PATH
23
+ try:
24
+ subprocess.run(["git", "--version"], capture_output=True, check=True)
25
+ except (subprocess.SubprocessError, FileNotFoundError):
26
+ stderr_console.print("[bold red]Error:[/] `git` command not found. Please install Git to use `vex sync`.")
27
+ raise typer.Exit(code=1)
28
+
29
+ cache_dir.parent.mkdir(parents=True, exist_ok=True)
30
+
31
+ if (cache_dir / ".git").is_dir():
32
+ console.print(f"[bold cyan]Updating global catalog cache at:[/] {cache_dir}")
33
+ res = subprocess.run(["git", "pull", "origin", branch], cwd=cache_dir, capture_output=True, text=True, check=False)
34
+
35
+ if res.returncode != 0:
36
+ stderr_console.print(f"[bold red]Failed to update catalog:[/] {res.stderr.strip()}")
37
+ raise typer.Exit(code=1)
38
+
39
+ console.print("[bold green]✔ Catalog successfully updated![/]")
40
+ else:
41
+ console.print(f"[bold cyan]Cloning catalog from:[/] {remote}")
42
+ console.print(f"[bold cyan]Target directory:[/] {cache_dir}")
43
+
44
+ res = subprocess.run(["git", "clone", "--depth", "1", "-b", branch, remote, str(cache_dir)], capture_output=True, text=True, check=False)
45
+
46
+ if res.returncode != 0:
47
+ stderr_console.print(f"[bold red]Failed to clone catalog:[/] {res.stderr.strip()}")
48
+ raise typer.Exit(code=1)
49
+
50
+ console.print("[bold green]✔ Catalog successfully downloaded![/]")
vex/commands/tree.py ADDED
@@ -0,0 +1,14 @@
1
+ """Command to show the catalog tree structure."""
2
+
3
+ from vex.commands.options import OptRich
4
+ from vex.core.catalog import collect_entries
5
+ from vex.utils.display import display_tree
6
+ from vex.utils.filesystem import find_repo_root
7
+
8
+
9
+ def cmd_tree(rich: OptRich = True) -> None:
10
+ """Show the catalog tree structure."""
11
+
12
+ root = find_repo_root()
13
+ entries = collect_entries(root)
14
+ display_tree(entries, use_rich=rich)
vex/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core catalog models and parsing logic."""
vex/core/catalog.py ADDED
@@ -0,0 +1,59 @@
1
+ """Catalog loading and parsing utilities."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from vex.core.models import CatalogEntry
10
+
11
+ FRONTMATTER_RE = re.compile(r"\A---\n(.*?\n)---\n?(.*)\Z", re.DOTALL)
12
+
13
+
14
+ def parse_file(path: Path) -> tuple[dict[str, Any] | None, str | None]:
15
+ """Parse a catalog file, returning (frontmatter_dict, body_str) or (None, None)."""
16
+
17
+ text = path.read_text(encoding="utf-8")
18
+ m = FRONTMATTER_RE.match(text)
19
+
20
+ if not m:
21
+ return None, None
22
+
23
+ try:
24
+ fm: Any = yaml.safe_load(m.group(1))
25
+ except yaml.YAMLError:
26
+ return None, None
27
+
28
+ if not isinstance(fm, dict):
29
+ return None, None
30
+
31
+ return fm, m.group(2)
32
+
33
+
34
+ def collect_entries(root: Path) -> list[CatalogEntry]:
35
+ """Collect all catalog entries and runtime-signatures."""
36
+
37
+ entries: list[CatalogEntry] = []
38
+ catalog_dir = root / "data" / "catalog"
39
+ signatures_dir = root / "data" / "signatures"
40
+
41
+ dirs: list[Path] = []
42
+
43
+ if catalog_dir.exists():
44
+ dirs.append(catalog_dir)
45
+
46
+ if signatures_dir.exists():
47
+ dirs.append(signatures_dir)
48
+
49
+ for d in dirs:
50
+ for path in sorted(d.rglob("*.md")):
51
+ if path.name == "README.md" and (path.parent == signatures_dir or path.parent == catalog_dir or path.parent.parent == catalog_dir):
52
+ continue # Skip documentation READMEs
53
+
54
+ fm, body = parse_file(path)
55
+
56
+ if fm is not None and body is not None and "id" in fm:
57
+ entries.append({"path": path, "fm": fm, "body": body})
58
+
59
+ return entries
vex/core/models.py ADDED
@@ -0,0 +1,24 @@
1
+ """Data models and type definitions for Vulnerability Explorer."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any, TypedDict
6
+
7
+
8
+ class CatalogEntry(TypedDict):
9
+ """Data structure representing a parsed catalog entry."""
10
+
11
+ path: Path
12
+ fm: dict[str, Any]
13
+ body: str
14
+
15
+
16
+ @dataclass
17
+ class LintError:
18
+ """Represents a catalog validation violation."""
19
+
20
+ path: Path | str
21
+ message: str
22
+
23
+ def __str__(self) -> str:
24
+ return f"{self.path}: {self.message}"
vex/main.py ADDED
@@ -0,0 +1,38 @@
1
+ """Main entrypoint for vex CLI app."""
2
+
3
+ import typer
4
+
5
+ import vex
6
+ from vex.commands.interactive import cmd_interactive
7
+ from vex.commands.list import cmd_list
8
+ from vex.commands.options import OptRich, OptVersion
9
+ from vex.commands.read import cmd_read
10
+ from vex.commands.search import cmd_search
11
+ from vex.commands.sync import cmd_sync
12
+ from vex.commands.tree import cmd_tree
13
+
14
+ app = typer.Typer(name="vex", help="A terminal reference for software vulnerabilities.", no_args_is_help=False)
15
+
16
+ app.command(name="tree", help="Show the catalog tree structure")(cmd_tree)
17
+ app.command(name="list", help="List entries with filters")(cmd_list)
18
+ app.command(name="read", help="Read an entry by ID")(cmd_read)
19
+ app.command(name="search", help="Full-text search across entries")(cmd_search)
20
+ app.command(name="interactive", help="Interactive navigation menu")(cmd_interactive)
21
+ app.command(name="sync", help="Download or update the global catalog cache")(cmd_sync)
22
+
23
+
24
+ @app.callback(invoke_without_command=True)
25
+ def main(ctx: typer.Context, version: OptVersion = None, rich: OptRich = True) -> None:
26
+ """Vulnerability Explorer CLI."""
27
+
28
+ if version:
29
+ typer.echo(f"vex version {vex.__version__}")
30
+ raise typer.Exit()
31
+
32
+ if ctx.invoked_subcommand is None:
33
+ # Default to interactive if no subcommand passed
34
+ cmd_interactive(rich=rich)
35
+
36
+
37
+ if __name__ == "__main__":
38
+ app()
vex/utils/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Utility modules for filesystem and display operations."""
vex/utils/display.py ADDED
@@ -0,0 +1,264 @@
1
+ """Display and UI utilities using Rich, Questionary, and Pager."""
2
+
3
+ from typing import Any
4
+
5
+ try:
6
+ import questionary
7
+
8
+ QUESTIONARY_AVAILABLE = True
9
+ except ImportError:
10
+ QUESTIONARY_AVAILABLE = False
11
+
12
+ try:
13
+ from rich.console import Console
14
+ from rich.markdown import Markdown
15
+ from rich.table import Table
16
+ from rich.tree import Tree as RichTree
17
+
18
+ RICH_AVAILABLE = True
19
+ except ImportError:
20
+ RICH_AVAILABLE = False
21
+
22
+ from vex.core.models import CatalogEntry
23
+
24
+
25
+ def get_rich_console(*args: Any, **kwargs: Any) -> "Console | None":
26
+ """Safely return a Rich Console instance if available."""
27
+
28
+ if RICH_AVAILABLE:
29
+ return Console(*args, **kwargs) # type: ignore
30
+
31
+ return None
32
+
33
+
34
+ def get_rich_tree(*args: Any, **kwargs: Any) -> "RichTree | None":
35
+ """Safely return a Rich Tree instance if available."""
36
+
37
+ if RICH_AVAILABLE:
38
+ return RichTree(*args, **kwargs) # type: ignore
39
+
40
+ return None
41
+
42
+
43
+ def get_rich_table(*args: Any, **kwargs: Any) -> "Table | None":
44
+ """Safely return a Rich Table instance if available."""
45
+
46
+ if RICH_AVAILABLE:
47
+ return Table(*args, **kwargs) # type: ignore
48
+
49
+ return None
50
+
51
+
52
+ def get_markdown(*args: Any, **kwargs: Any) -> "Markdown | None":
53
+ """Safely return a Rich Markdown instance if available."""
54
+
55
+ if RICH_AVAILABLE:
56
+ return Markdown(*args, **kwargs) # type: ignore
57
+
58
+ return None
59
+
60
+
61
+ def display_tree(entries: list[CatalogEntry], use_rich: bool = True) -> None:
62
+ """Display catalog tree structure."""
63
+
64
+ if not entries:
65
+ print("Catalog is empty. No entries found.")
66
+ return
67
+
68
+ console = get_rich_console() if use_rich else None
69
+ tree = get_rich_tree("[bold]vulnerability-explorer[/bold]") if use_rich else None
70
+
71
+ if console is not None and tree is not None:
72
+ rich_categories: dict[str, Any] = {}
73
+
74
+ for e in entries:
75
+ cat = str(e["fm"].get("category", "unknown"))
76
+ ftype = str(e["fm"].get("type", "unknown"))
77
+ fid = str(e["fm"].get("id", "?"))
78
+
79
+ if cat not in rich_categories:
80
+ rich_categories[cat] = tree.add(f"[bold cyan]{cat}[/bold cyan]")
81
+
82
+ if ftype == "concept":
83
+ rich_categories[cat].add(f"[green]{fid}[/green] (concept)")
84
+ elif ftype == "manifestation":
85
+ rich_categories[cat].add(f" {fid}")
86
+ else:
87
+ rich_categories[cat].add(f" [yellow]{fid}[/yellow] (runtime-sig)")
88
+
89
+ console.print(tree)
90
+ else:
91
+ plain_categories: dict[str, list[tuple[str, str]]] = {}
92
+
93
+ for e in entries:
94
+ cat = str(e["fm"].get("category", "unknown"))
95
+ ftype = str(e["fm"].get("type", "unknown"))
96
+ fid = str(e["fm"].get("id", "?"))
97
+
98
+ plain_categories.setdefault(cat, []).append((ftype, fid))
99
+
100
+ for cat, items in sorted(plain_categories.items()):
101
+ print(f"\n{cat}/")
102
+
103
+ for ftype, fid in items:
104
+ prefix = " " if ftype == "concept" else " "
105
+ suffix = f" ({ftype})" if ftype != "manifestation" else ""
106
+ print(f"{prefix}{fid}{suffix}")
107
+
108
+
109
+ def display_list(entries: list[CatalogEntry], use_rich: bool = True) -> None:
110
+ """Display catalog entries table."""
111
+
112
+ if not entries:
113
+ print("No matching catalog entries found.")
114
+ return
115
+
116
+ console = get_rich_console() if use_rich else None
117
+ table = get_rich_table(title="Catalog Entries") if use_rich else None
118
+
119
+ if console is not None and table is not None:
120
+ table.add_column("ID", style="cyan")
121
+ table.add_column("Type", style="green")
122
+ table.add_column("Title")
123
+ table.add_column("Languages")
124
+ table.add_column("Severity")
125
+
126
+ for e in entries:
127
+ fm = e["fm"]
128
+ raw_langs = fm.get("languages")
129
+ langs = ", ".join(raw_langs) if isinstance(raw_langs, list) else "-"
130
+ sev = str(fm.get("severity_baseline", "-"))
131
+
132
+ table.add_row(
133
+ str(fm.get("id", "?")),
134
+ str(fm.get("type", "?")),
135
+ str(fm.get("title", "?")),
136
+ langs,
137
+ sev,
138
+ )
139
+
140
+ console.print(table)
141
+ else:
142
+ for e in entries:
143
+ fm = e["fm"]
144
+ raw_langs = fm.get("languages")
145
+ langs = ", ".join(raw_langs) if isinstance(raw_langs, list) else "-"
146
+ sev = str(fm.get("severity_baseline", "-"))
147
+
148
+ print(f" {fm.get('id', '?')!s:60s} {fm.get('type', '?')!s:15s} {langs:15s} {sev}")
149
+
150
+
151
+ def display_read(entry: CatalogEntry, use_pager: bool = True, use_rich: bool = True) -> None:
152
+ """Display catalog entry using Pager."""
153
+
154
+ fm = entry["fm"]
155
+ body = entry["body"]
156
+
157
+ console = get_rich_console() if use_rich else None
158
+
159
+ if console is not None:
160
+
161
+ def _render_content(target_console: "Console") -> None:
162
+ target_console.print(f"\n[bold cyan]{fm.get('title', '?')}[/bold cyan]")
163
+ target_console.print(f"[dim]{fm.get('id', '?')} — {fm.get('type', '?')}[/dim]\n")
164
+
165
+ meta_parts: list[str] = []
166
+ raw_langs = fm.get("languages")
167
+
168
+ if isinstance(raw_langs, list):
169
+ meta_parts.append(f"Languages: {', '.join(raw_langs)}")
170
+
171
+ raw_modes = fm.get("analysis_modes")
172
+
173
+ if isinstance(raw_modes, list):
174
+ meta_parts.append(f"Modes: {', '.join(raw_modes)}")
175
+
176
+ if fm.get("severity_baseline"):
177
+ meta_parts.append(f"Severity: {fm['severity_baseline']}")
178
+
179
+ if meta_parts:
180
+ target_console.print("[dim]" + " | ".join(meta_parts) + "[/dim]\n")
181
+
182
+ md = get_markdown(body)
183
+ if md is not None:
184
+ target_console.print(md)
185
+ else:
186
+ target_console.print(body)
187
+
188
+ if use_pager:
189
+ with console.pager(styles=True):
190
+ _render_content(console)
191
+ else:
192
+ _render_content(console)
193
+ else:
194
+ content = f"\n{'=' * 72}\n {fm.get('title', '?')}\n {fm.get('id', '?')} — {fm.get('type', '?')}\n{'=' * 72}\n\n{body}"
195
+ print(content)
196
+
197
+
198
+ def display_search(results: list[tuple[int, CatalogEntry]], query: str, use_rich: bool = True) -> None:
199
+ """Display search results."""
200
+
201
+ if not results:
202
+ print(f"No results for '{query}'.")
203
+ return
204
+
205
+ print(f"\n{len(results)} result(s) for '{query}':\n")
206
+
207
+ console = get_rich_console() if use_rich else None
208
+ table = get_rich_table() if use_rich else None
209
+
210
+ if console is not None and table is not None:
211
+ table.add_column("Hits", justify="right", style="yellow")
212
+ table.add_column("ID", style="cyan")
213
+ table.add_column("Title")
214
+
215
+ for count, e in results:
216
+ fm = e["fm"]
217
+ table.add_row(str(count), str(fm.get("id", "?")), str(fm.get("title", "?")))
218
+
219
+ console.print(table)
220
+ else:
221
+ for count, e in results:
222
+ fm = e["fm"]
223
+ print(f" [{count:3d}] {fm.get('id', '?')!s:55s} {fm.get('title', '?')!s}")
224
+
225
+
226
+ def interactive_navigation(entries: list[CatalogEntry], use_rich: bool = True) -> None:
227
+ """Interactive navigation menu using Arrow Keys with Questionary."""
228
+
229
+ if not QUESTIONARY_AVAILABLE:
230
+ print("Error: 'questionary' package is required for interactive navigation.")
231
+ print("Install it via: pip install questionary")
232
+ return
233
+
234
+ if not entries:
235
+ print("Catalog is empty.")
236
+ return
237
+
238
+ while True:
239
+ categories: dict[str, list[CatalogEntry]] = {}
240
+
241
+ for e in entries:
242
+ cat = str(e["fm"].get("category", "Uncategorized"))
243
+ categories.setdefault(cat, []).append(e)
244
+
245
+ cat_choices = sorted(categories.keys()) + ["[ Exit ]"]
246
+ selected_cat = questionary.select("Select a vulnerability category:", choices=cat_choices).ask()
247
+
248
+ if not selected_cat or selected_cat == "[ Exit ]":
249
+ break
250
+
251
+ cat_entries = categories[selected_cat]
252
+ entry_choices: list[questionary.Choice] = []
253
+
254
+ for e in cat_entries:
255
+ title = str(e["fm"].get("title", "Untitled"))
256
+ entry_id = str(e["fm"].get("id", "no-id"))
257
+ entry_type = str(e["fm"].get("type", "unknown"))
258
+ entry_choices.append(questionary.Choice(title=f"{entry_id} — {title} ({entry_type})", value=e))
259
+
260
+ entry_choices.append(questionary.Choice(title="[ Back to Categories ]", value=None))
261
+ selected_entry: CatalogEntry | None = questionary.select(f"Select an entry in '{selected_cat}':", choices=entry_choices).ask()
262
+
263
+ if selected_entry:
264
+ display_read(selected_entry, use_pager=True, use_rich=use_rich)