-
Notifications
You must be signed in to change notification settings - Fork 227
feat(cli): add Rich human-readable output to bm tool commands #967
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
cc49468
c6cdf14
457cd0e
e079eca
32e7afe
eedafd6
a5114ae
30c6721
cab4bf1
6d2ff99
83f6005
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,10 @@ | ||
| """CLI tool commands for Basic Memory. | ||
|
|
||
| Every command calls its MCP tool with output_format="json" and prints the result. | ||
| No text formatting, no separate code paths, no duplicate data fetching. | ||
| Commands that benefit from human-readable output (search-notes, read-note, | ||
| build-context, recent-activity) default to Rich formatting when stdout is a TTY | ||
| and fall back to raw JSON when piped or when --json is supplied. This follows | ||
| the same bm status / bm project list precedent. | ||
| """ | ||
|
|
||
| import json | ||
|
|
@@ -10,6 +13,12 @@ | |
|
|
||
| import typer | ||
| from loguru import logger | ||
| from rich.console import Console | ||
| from rich.markdown import Markdown | ||
| from rich.panel import Panel | ||
| from rich.table import Table | ||
| from rich.text import Text | ||
| from rich.tree import Tree | ||
|
|
||
| from basic_memory.cli.app import app | ||
| from basic_memory.cli.commands.command_utils import run_with_cleanup | ||
|
|
@@ -32,15 +41,175 @@ | |
|
|
||
| VALID_EDIT_OPERATIONS = ["append", "prepend", "find_replace", "replace_section"] | ||
|
|
||
| # Shared Rich console (stderr=False so output goes to stdout, matching _print_json). | ||
| console = Console() | ||
|
|
||
|
|
||
| # --- Shared helpers --- | ||
|
|
||
|
|
||
| def _use_rich() -> bool: | ||
| """Return True when stdout is an interactive TTY and Rich output is appropriate. | ||
|
|
||
| Trigger: caller did not pass --json and stdout is a TTY. | ||
| Why: piped output (scripts, jq, etc.) must stay machine-parseable; | ||
| human-readable formatting is only useful in an interactive terminal. | ||
| Outcome: Rich output in a terminal; raw JSON when piped or redirected. | ||
| """ | ||
| return sys.stdout.isatty() | ||
|
|
||
|
|
||
| def _print_json(result: Any) -> None: | ||
| """Print a result as formatted JSON.""" | ||
| print(json.dumps(result, indent=2, ensure_ascii=True, default=str)) | ||
|
|
||
|
|
||
| # --- Rich formatters --- | ||
|
|
||
|
|
||
| def _display_search_results(result: dict[str, Any], query: str = "") -> None: | ||
| """Render search-notes results as a Rich table. | ||
|
|
||
| Real SearchResponse.model_dump() shape: | ||
| results: list of SearchResult dicts (title, type, permalink, score, matched_chunk, content) | ||
| current_page: int (NOT "page") | ||
| page_size: int | ||
| total: int | ||
| has_more: bool | ||
| """ | ||
| results = result.get("results", []) | ||
| total = result.get("total", len(results)) | ||
| # Real key is "current_page"; fall back to "page" for forward-compat. | ||
| page = result.get("current_page") or result.get("page", 1) | ||
| page_size = result.get("page_size", len(results)) or 1 | ||
|
|
||
| title = f"Search: [bold cyan]{query}[/bold cyan]" if query else "Search results" | ||
| subtitle = f"{total} result(s) • page {page} of {max(1, -(-total // page_size))}" | ||
|
|
||
| if not results: | ||
| console.print(Panel(Text("No results found.", style="dim"), title=title, expand=False)) | ||
| return | ||
|
|
||
| table = Table(show_header=True, header_style="bold", expand=False) | ||
| table.add_column("Type", style="dim", width=12) | ||
| table.add_column("Title", style="bold cyan") | ||
| table.add_column("Score", style="yellow", width=7) | ||
| table.add_column("Permalink", style="green") | ||
| table.add_column("Snippet", style="dim", max_width=60) | ||
|
|
||
| for item in results: | ||
| item_type = item.get("type", "") | ||
| item_title = item.get("title") or item.get("permalink", "") | ||
| permalink = item.get("permalink", "") | ||
| score = item.get("score") | ||
| score_str = f"{score:.2f}" if score is not None else "" | ||
| # Prefer matched_chunk as the most relevant snippet; fall back to content. | ||
| raw_snippet = item.get("matched_chunk") or item.get("content") or "" | ||
| # Truncate to ~200 chars so the table stays readable. | ||
| snippet = raw_snippet[:200].replace("\n", " ") if raw_snippet else "" | ||
| table.add_row(item_type, item_title, score_str, permalink, snippet) | ||
|
|
||
| console.print(Panel(table, title=title, subtitle=subtitle, expand=False)) | ||
|
|
||
|
|
||
| def _display_read_note(result: dict[str, Any]) -> None: | ||
| """Render read-note result: header panel + rendered Markdown content.""" | ||
| title = result.get("title", "") | ||
| permalink = result.get("permalink", "") | ||
| content = result.get("content", "") | ||
|
|
||
| header = Text() | ||
| header.append(title, style="bold cyan") | ||
| if permalink: | ||
| header.append(f" [{permalink}]", style="dim green") | ||
|
|
||
| console.print(Panel(header, expand=False)) | ||
|
|
||
| if content: | ||
| console.print(Markdown(content)) | ||
| else: | ||
| console.print(Text("(no content)", style="dim")) | ||
|
|
||
|
|
||
| def _display_build_context(result: dict[str, Any]) -> None: | ||
| """Render build-context result as a Rich tree. | ||
|
|
||
| Real GraphContext.model_dump() shape: | ||
| results: list of ContextResult dicts, each with: | ||
| primary_result: EntitySummary | RelationSummary | ObservationSummary | ||
| observations: list of ObservationSummary | ||
| related_results: list of EntitySummary | RelationSummary | ObservationSummary | ||
| metadata: {"uri": ..., ...} | ||
| page/page_size/has_more | ||
|
|
||
| Each summary has: type, title (EntitySummary/RelationSummary), permalink, | ||
| and relation_type (RelationSummary only). | ||
| """ | ||
| metadata = result.get("metadata", {}) | ||
| uri = metadata.get("uri", "") | ||
| context_items: list[dict[str, Any]] = list(result.get("results", [])) | ||
|
|
||
| label = f"[bold cyan]{uri}[/bold cyan]" if uri else "Context" | ||
| tree = Tree(f"[bold]Context:[/bold] {label}") | ||
|
|
||
| if not context_items: | ||
| tree.add("[dim]No related content found.[/dim]") | ||
| else: | ||
| for context_result in context_items: | ||
| # --- Primary result node --- | ||
| primary = context_result.get("primary_result", {}) | ||
| p_title = primary.get("title") or primary.get("permalink", "") | ||
| p_type = primary.get("type", "") | ||
| primary_label = f"[cyan]{p_title}[/cyan]" | ||
| if p_type: | ||
| primary_label = f"[dim]{p_type}[/dim] {primary_label}" | ||
| primary_node = tree.add(primary_label) | ||
|
|
||
| # --- Related items as children --- | ||
| related: list[dict[str, Any]] = list(context_result.get("related_results", [])) | ||
|
Comment on lines
+301
to
+302
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in e079eca. Verified: Fix: after rendering the primary node, iterate |
||
| for rel_item in related: | ||
| rel_title = rel_item.get("title") or rel_item.get("permalink", "") | ||
| rel_type = rel_item.get("type", "") | ||
| relation = rel_item.get("relation_type", "") | ||
|
|
||
| parts = [] | ||
| if relation: | ||
| parts.append(f"[yellow]{relation}[/yellow]") | ||
| if rel_type: | ||
| parts.append(f"[dim]{rel_type}[/dim]") | ||
| parts.append(f"[cyan]{rel_title}[/cyan]") | ||
| primary_node.add(" ".join(parts)) | ||
|
|
||
| # Count total related items across all primary results. | ||
| total_related = sum(len(cr.get("related_results", [])) for cr in context_items) | ||
| subtitle = f"{len(context_items)} primary • {total_related} related" | ||
| console.print(Panel(tree, subtitle=subtitle, expand=False)) | ||
|
|
||
|
|
||
| def _display_recent_activity(result: list[dict[str, Any]]) -> None: | ||
| """Render recent-activity results as a Rich table.""" | ||
| if not result: | ||
| console.print( | ||
| Panel(Text("No recent activity.", style="dim"), title="Recent Activity", expand=False) | ||
| ) | ||
| return | ||
|
|
||
| table = Table(show_header=True, header_style="bold", expand=False) | ||
| table.add_column("Type", style="dim", width=12) | ||
| table.add_column("Title", style="bold cyan") | ||
| table.add_column("Permalink", style="green") | ||
| table.add_column("Updated", style="dim") | ||
|
|
||
| for item in result: | ||
| item_type = item.get("type", "") | ||
| item_title = item.get("title") or item.get("permalink", "") | ||
| permalink = item.get("permalink", "") | ||
| updated = str(item.get("updated_at") or item.get("created_at") or "") | ||
| table.add_row(item_type, item_title, permalink, updated) | ||
|
|
||
| console.print(Panel(table, title="Recent Activity", expand=False)) | ||
|
|
||
|
|
||
| def _delete_note_failure_message(result: dict[str, Any]) -> str | None: | ||
| """Return the CLI failure message for delete-note JSON results, if any.""" | ||
| error = result.get("error") | ||
|
|
@@ -183,6 +352,9 @@ def read_note( | |
| include_frontmatter: bool = typer.Option( | ||
| False, "--include-frontmatter", help="Include YAML frontmatter in output" | ||
| ), | ||
| json_output: bool = typer.Option( | ||
| False, "--json", help="Output raw JSON instead of formatted display" | ||
| ), | ||
| project: Annotated[ | ||
| Optional[str], | ||
| typer.Option(help="The project to use. If not provided, the default project will be used."), | ||
|
|
@@ -201,10 +373,14 @@ def read_note( | |
| ): | ||
| """Read a markdown note from the knowledge base. | ||
|
|
||
| Displays formatted Markdown output by default when run in a terminal. | ||
| Use --json for raw machine-readable output. | ||
|
|
||
| Examples: | ||
|
|
||
| bm tool read-note my-note | ||
| bm tool read-note my-note --include-frontmatter | ||
| bm tool read-note my-note --json | ||
| """ | ||
| try: | ||
| validate_routing_flags(local, cloud) | ||
|
|
@@ -232,7 +408,14 @@ def read_note( | |
| _print_json(result) | ||
| raise typer.Exit(1) | ||
|
|
||
| _print_json(result) | ||
| # Trigger: --json flag or non-TTY stdout (piped output). | ||
| # Why: scripts and downstream tools need parseable JSON; Rich markup | ||
| # would corrupt those pipelines. | ||
| # Outcome: raw JSON for machine consumers; formatted display for humans. | ||
| if json_output or not _use_rich() or isinstance(result, str): | ||
| _print_json(result) | ||
| else: | ||
| _display_read_note(result) | ||
| except ValueError as e: | ||
| typer.echo(f"Error: {e}", err=True) | ||
| raise typer.Exit(1) | ||
|
|
@@ -390,6 +573,9 @@ def build_context( | |
| page: int = typer.Option(1, "--page", help="Page number for pagination"), | ||
| page_size: int = typer.Option(10, "--page-size", help="Number of results per page"), | ||
| max_related: int = typer.Option(10, "--max-related", help="Maximum related items to return"), | ||
| json_output: bool = typer.Option( | ||
| False, "--json", help="Output raw JSON instead of formatted display" | ||
| ), | ||
| project: Annotated[ | ||
| Optional[str], | ||
| typer.Option(help="The project to use. If not provided, the default project will be used."), | ||
|
|
@@ -408,10 +594,14 @@ def build_context( | |
| ): | ||
| """Get context needed to continue a discussion. | ||
|
|
||
| Displays a Rich tree view by default when run in a terminal. | ||
| Use --json for raw machine-readable output. | ||
|
|
||
| Examples: | ||
|
|
||
| bm tool build-context memory://specs/search | ||
| bm tool build-context specs/search --depth 2 --timeframe 30d | ||
| bm tool build-context memory://specs/search --json | ||
| """ | ||
| try: | ||
| validate_routing_flags(local, cloud) | ||
|
|
@@ -430,7 +620,15 @@ def build_context( | |
| output_format="json", | ||
| ) | ||
| ) | ||
| _print_json(result) | ||
|
|
||
| # Trigger: --json flag or non-TTY stdout (piped output). | ||
| # Why: scripts and downstream tools need parseable JSON; Rich markup | ||
| # would corrupt those pipelines. | ||
| # Outcome: raw JSON for machine consumers; formatted display for humans. | ||
| if json_output or not _use_rich() or isinstance(result, str): | ||
| _print_json(result) | ||
| else: | ||
| _display_build_context(result) | ||
| except ValueError as e: | ||
| typer.echo(f"Error: {e}", err=True) | ||
| raise typer.Exit(1) | ||
|
|
@@ -452,6 +650,9 @@ def recent_activity( | |
| # Match the MCP recent_activity default (page_size=10) so identical default | ||
| # invocations return the same number of rows from CLI and MCP. | ||
| page_size: int = typer.Option(10, "--page-size", help="Number of results per page"), | ||
| json_output: bool = typer.Option( | ||
| False, "--json", help="Output raw JSON instead of formatted display" | ||
| ), | ||
| project: Annotated[ | ||
| Optional[str], | ||
| typer.Option(help="The project to use. If not provided, the default project will be used."), | ||
|
|
@@ -470,11 +671,15 @@ def recent_activity( | |
| ): | ||
| """Get recent activity across the knowledge base. | ||
|
|
||
| Displays a formatted table by default when run in a terminal. | ||
| Use --json for raw machine-readable output. | ||
|
|
||
| Examples: | ||
|
|
||
| bm tool recent-activity | ||
| bm tool recent-activity --timeframe 30d --page-size 20 | ||
| bm tool recent-activity --type entity --type observation | ||
| bm tool recent-activity --json | ||
| """ | ||
| try: | ||
| validate_routing_flags(local, cloud) | ||
|
|
@@ -492,7 +697,15 @@ def recent_activity( | |
| output_format="json", | ||
| ) | ||
| ) | ||
| _print_json(result) | ||
|
|
||
| # Trigger: --json flag or non-TTY stdout (piped output). | ||
| # Why: scripts and downstream tools need parseable JSON; Rich markup | ||
| # would corrupt those pipelines. | ||
| # Outcome: raw JSON for machine consumers; formatted display for humans. | ||
| if json_output or not _use_rich() or isinstance(result, str): | ||
| _print_json(result) | ||
| else: | ||
| _display_recent_activity(result) | ||
| except ValueError as e: | ||
| typer.echo(f"Error: {e}", err=True) | ||
| raise typer.Exit(1) | ||
|
|
@@ -556,6 +769,9 @@ def search_notes( | |
| ] = None, | ||
| page: int = typer.Option(1, "--page", help="Page number for pagination"), | ||
| page_size: int = typer.Option(10, "--page-size", help="Number of results per page"), | ||
| json_output: bool = typer.Option( | ||
| False, "--json", help="Output raw JSON instead of formatted display" | ||
| ), | ||
| project: Annotated[ | ||
| Optional[str], | ||
| typer.Option(help="The project to use. If not provided, the default project will be used."), | ||
|
|
@@ -574,13 +790,17 @@ def search_notes( | |
| ): | ||
| """Search across all content in the knowledge base. | ||
|
|
||
| Displays a formatted table by default when run in a terminal. | ||
| Use --json for raw machine-readable output. | ||
|
|
||
| Examples: | ||
|
|
||
| bm tool search-notes "my query" | ||
| bm tool search-notes --permalink "specs/*" | ||
| bm tool search-notes --tag python --tag async | ||
| bm tool search-notes --meta status=draft | ||
| bm tool search-notes "auth" --entity-type observation --category requirement | ||
| bm tool search-notes "my query" --json | ||
| """ | ||
| try: | ||
| validate_routing_flags(local, cloud) | ||
|
|
@@ -658,7 +878,14 @@ def search_notes( | |
| typer.echo(result, err=True) | ||
| raise typer.Exit(1) | ||
|
|
||
| _print_json(result) | ||
| # Trigger: --json flag or non-TTY stdout (piped output). | ||
| # Why: scripts and downstream tools need parseable JSON; Rich markup | ||
| # would corrupt those pipelines. | ||
| # Outcome: raw JSON for machine consumers; formatted display for humans. | ||
| if json_output or not _use_rich(): | ||
| _print_json(result) | ||
| else: | ||
| _display_search_results(result, query=query or "") | ||
| except ValueError as e: | ||
| typer.echo(f"Error: {e}", err=True) | ||
| raise typer.Exit(1) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
bm tool read-note ... --include-frontmatterruns in an interactive terminal, this new Rich path renders onlytitle,permalink, andcontent, so the explicitly requested frontmatter disappears unless the user also knows to add--json. That regresses the documented--include-frontmatterbehavior for the default TTY path; renderresult["frontmatter"]when present or keep JSON output for that flag.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in e079eca.
Verified:
_display_read_notewas readingtitle,permalink, andcontentbut never accessingresult["frontmatter"], so--include-frontmatterdata was silently dropped in the Rich path.Fix: when
frontmatteris non-empty, render a dim key/value panel (using a borderlessTableinside aPanel) above the note content. Addedtest_read_note_rich_include_frontmatterasserting the frontmatter panel header and key/value (tags/test) appear in output when--include-frontmatteris passed.