diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 3df836d9..3526f89f 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -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 @@ from typing import Annotated, Any, Dict, List, Optional 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,135 @@ app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI") 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]) -> None: + """Render search-notes results as a Rich table.""" + results = result.get("results", []) + total = result.get("total", len(results)) + query = result.get("query") or "" + page = result.get("page", 1) + page_size = result.get("page_size", len(results)) + + title = f"Search results for [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("Permalink", style="green") + + for item in results: + item_type = item.get("type", "") + item_title = item.get("title") or item.get("permalink", "") + permalink = item.get("permalink", "") + table.add_row(item_type, item_title, permalink) + + 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.""" + metadata = result.get("metadata", {}) + uri = metadata.get("uri", "") + results = result.get("results", []) + total = len(results) + + label = f"[bold cyan]{uri}[/bold cyan]" if uri else "Context" + tree = Tree(f"[bold]Context:[/bold] {label}") + + if not results: + tree.add("[dim]No related content found.[/dim]") + else: + for item in results: + item_title = item.get("title") or item.get("permalink", "") + relation = item.get("relation_type", "") + item_type = item.get("type", "") + + parts = [] + if relation: + parts.append(f"[yellow]{relation}[/yellow]") + if item_type: + parts.append(f"[dim]{item_type}[/dim]") + parts.append(f"[cyan]{item_title}[/cyan]") + + tree.add(" ".join(parts)) + + subtitle = f"{total} related item(s)" + 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 +312,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 +333,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 +368,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(): + _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 +533,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 +554,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 +580,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(): + _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 +610,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 +631,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 +657,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(): + _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 +729,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,6 +750,9 @@ 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" @@ -581,6 +760,7 @@ def search_notes( 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 +838,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) except ValueError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py new file mode 100644 index 00000000..610c96ea --- /dev/null +++ b/tests/cli/test_cli_tool_rich_output.py @@ -0,0 +1,370 @@ +"""Tests for Rich (human-readable) output mode for bm tool commands. + +Commands default to Rich output when stdout is a TTY and fall back to raw JSON +when stdout is piped or --json is supplied. These tests verify both modes. +""" + +import json +from unittest.mock import AsyncMock, patch + +import pytest +from typer.testing import CliRunner + +from basic_memory.cli.main import app as cli_app + +runner = CliRunner() + +# --------------------------------------------------------------------------- +# Shared mock payloads (mirrors test_cli_tool_json_output.py for symmetry) +# --------------------------------------------------------------------------- + +READ_NOTE_RESULT = { + "title": "Test Note", + "permalink": "notes/test-note", + "file_path": "notes/Test Note.md", + "content": "# Test Note\n\nhello world", + "frontmatter": {"title": "Test Note", "tags": ["test"]}, +} + +SEARCH_RESULT = { + "query": "test", + "total": 2, + "page": 1, + "page_size": 10, + "results": [ + { + "type": "entity", + "title": "Test Note", + "permalink": "notes/test-note", + "file_path": "notes/Test Note.md", + }, + { + "type": "observation", + "title": "Another Note", + "permalink": "notes/another-note", + "file_path": "notes/Another Note.md", + }, + ], +} + +SEARCH_RESULT_EMPTY = { + "query": "nothing", + "total": 0, + "page": 1, + "page_size": 10, + "results": [], +} + +BUILD_CONTEXT_RESULT = { + "results": [ + { + "type": "entity", + "title": "Related Note", + "permalink": "notes/related", + "relation_type": "references", + } + ], + "metadata": {"uri": "notes/test-note", "depth": 1}, + "page": 1, + "page_size": 10, +} + +BUILD_CONTEXT_EMPTY = { + "results": [], + "metadata": {"uri": "notes/test-note", "depth": 1}, + "page": 1, + "page_size": 10, +} + +RECENT_ACTIVITY_RESULT = [ + { + "type": "entity", + "title": "Note A", + "permalink": "notes/note-a", + "file_path": "notes/Note A.md", + "created_at": "2025-01-01 00:00:00", + "updated_at": "2025-01-01 12:00:00", + }, + { + "type": "entity", + "title": "Note B", + "permalink": "notes/note-b", + "file_path": "notes/Note B.md", + "created_at": "2025-01-02 00:00:00", + "updated_at": None, + }, +] + + +# --------------------------------------------------------------------------- +# Helper: simulate a TTY by patching _use_rich to return True +# --------------------------------------------------------------------------- + + +def _tty_runner(args, **kwargs): + """Invoke CLI as if stdout is a TTY (Rich output enabled).""" + with patch("basic_memory.cli.commands.tool._use_rich", return_value=True): + return runner.invoke(cli_app, args, **kwargs) + + +# --------------------------------------------------------------------------- +# search-notes – Rich output +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_search_notes_rich_output_default(mock_mcp): + """search-notes produces Rich table output when stdout is a TTY.""" + result = _tty_runner(["tool", "search-notes", "test"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # Rich output should NOT be valid JSON + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + # But it should contain the result titles + assert "Test Note" in result.output + assert "Another Note" in result.output + assert "notes/test-note" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT_EMPTY, +) +def test_search_notes_rich_empty(mock_mcp): + """search-notes Rich output handles empty results gracefully.""" + result = _tty_runner(["tool", "search-notes", "nothing"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "No results found" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_search_notes_json_flag_overrides_tty(mock_mcp): + """search-notes --json outputs raw JSON even when stdout is a TTY.""" + result = _tty_runner(["tool", "search-notes", "test", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["total"] == 2 + assert data["results"][0]["title"] == "Test Note" + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_search_notes_non_tty_gives_json(mock_mcp): + """search-notes outputs JSON when stdout is not a TTY (default runner behaviour).""" + # CliRunner does not set isatty(); _use_rich() returns False → JSON path. + result = runner.invoke(cli_app, ["tool", "search-notes", "test"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["total"] == 2 + + +# --------------------------------------------------------------------------- +# read-note – Rich output +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_rich_output_default(mock_mcp): + """read-note produces Rich formatted output when stdout is a TTY.""" + result = _tty_runner(["tool", "read-note", "test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # Rich output contains the note title + assert "Test Note" in result.output + # And the rendered markdown content + assert "hello world" in result.output + # Not raw JSON + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_json_flag_overrides_tty(mock_mcp): + """read-note --json outputs raw JSON even when stdout is a TTY.""" + result = _tty_runner(["tool", "read-note", "test-note", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["title"] == "Test Note" + assert data["content"] == "# Test Note\n\nhello world" + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value={"title": "", "permalink": "", "content": "", "frontmatter": {}}, +) +def test_read_note_rich_empty_content(mock_mcp): + """read-note Rich output handles empty content without crashing.""" + result = _tty_runner(["tool", "read-note", "empty-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "no content" in result.output.lower() + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_non_tty_gives_json(mock_mcp): + """read-note outputs JSON when stdout is not a TTY.""" + result = runner.invoke(cli_app, ["tool", "read-note", "test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["title"] == "Test Note" + + +# --------------------------------------------------------------------------- +# build-context – Rich output +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_RESULT, +) +def test_build_context_rich_output_default(mock_mcp): + """build-context produces Rich tree output when stdout is a TTY.""" + result = _tty_runner(["tool", "build-context", "memory://notes/test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "notes/test-note" in result.output + assert "Related Note" in result.output + # Not raw JSON + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_EMPTY, +) +def test_build_context_rich_empty(mock_mcp): + """build-context Rich output handles empty results gracefully.""" + result = _tty_runner(["tool", "build-context", "memory://notes/test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "No related content found" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_RESULT, +) +def test_build_context_json_flag_overrides_tty(mock_mcp): + """build-context --json outputs raw JSON even when stdout is a TTY.""" + result = _tty_runner(["tool", "build-context", "memory://notes/test-note", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert "results" in data + assert data["results"][0]["title"] == "Related Note" + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_RESULT, +) +def test_build_context_non_tty_gives_json(mock_mcp): + """build-context outputs JSON when stdout is not a TTY.""" + result = runner.invoke(cli_app, ["tool", "build-context", "memory://notes/test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert "results" in data + + +# --------------------------------------------------------------------------- +# recent-activity – Rich output +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_recent_activity", + new_callable=AsyncMock, + return_value=RECENT_ACTIVITY_RESULT, +) +def test_recent_activity_rich_output_default(mock_mcp): + """recent-activity produces Rich table output when stdout is a TTY.""" + result = _tty_runner(["tool", "recent-activity"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "Note A" in result.output + assert "Note B" in result.output + assert "notes/note-a" in result.output + # Not raw JSON + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + + +@patch( + "basic_memory.cli.commands.tool.mcp_recent_activity", + new_callable=AsyncMock, + return_value=[], +) +def test_recent_activity_rich_empty(mock_mcp): + """recent-activity Rich output handles empty results gracefully.""" + result = _tty_runner(["tool", "recent-activity"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "No recent activity" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_recent_activity", + new_callable=AsyncMock, + return_value=RECENT_ACTIVITY_RESULT, +) +def test_recent_activity_json_flag_overrides_tty(mock_mcp): + """recent-activity --json outputs raw JSON even when stdout is a TTY.""" + result = _tty_runner(["tool", "recent-activity", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert isinstance(data, list) + assert data[0]["title"] == "Note A" + + +@patch( + "basic_memory.cli.commands.tool.mcp_recent_activity", + new_callable=AsyncMock, + return_value=RECENT_ACTIVITY_RESULT, +) +def test_recent_activity_non_tty_gives_json(mock_mcp): + """recent-activity outputs JSON when stdout is not a TTY.""" + result = runner.invoke(cli_app, ["tool", "recent-activity"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert isinstance(data, list) + assert len(data) == 2