Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 232 additions & 5 deletions src/basic_memory/cli/commands/tool.py
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
Expand All @@ -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
Expand All @@ -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", "")
Comment on lines +188 to +190

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve frontmatter requested by the flag

When bm tool read-note ... --include-frontmatter runs in an interactive terminal, this new Rich path renders only title, permalink, and content, so the explicitly requested frontmatter disappears unless the user also knows to add --json. That regresses the documented --include-frontmatter behavior for the default TTY path; render result["frontmatter"] when present or keep JSON output for that flag.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

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_note was reading title, permalink, and content but never accessing result["frontmatter"], so --include-frontmatter data was silently dropped in the Rich path.

Fix: when frontmatter is non-empty, render a dim key/value panel (using a borderless Table inside a Panel) above the note content. Added test_read_note_rich_include_frontmatter asserting the frontmatter panel header and key/value (tags/test) appear in output when --include-frontmatter is passed.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include observations in context output

For build-context responses where an entity has observations, the new TTY default only adds related_results under each primary item and never renders context_result["observations"]. Since the previous JSON default exposed those observation summaries, users running the command interactively now lose core context facts whenever they do not pass --json.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

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_build_context iterated related_results under each primary_result node but never accessed context_result["observations"], so the ObservationSummary facts exposed in JSON output were completely absent in the Rich path.

Fix: after rendering the primary node, iterate observations (real ContextResult field: list of ObservationSummary with category + content) and add each as a dim [category] content leaf, truncating at 120 chars. Updated the BUILD_CONTEXT_RESULT fixture to include a real ObservationSummary shape (type/category/content/permalink/file_path/created_at). Also updated the subtitle to show N observations. Added test_build_context_rich_renders_observations asserting the observation category and content text appear in output.

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")
Expand Down Expand Up @@ -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."),
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."),
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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."),
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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."),
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading