Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] 94e2c92805 refactor: extract CLI tool utilities and add cloud mode tests
Refactor CLI tool commands to eliminate code duplication and improve
testability by extracting shared utilities.

Changes:
- Create tool_utils.py with resolve_project() and run_async_tool()
- Refactor all 6 CLI tool commands to use new helpers
- Add project parameter to recent_activity for consistency
- Fix search_notes search_type logic bug (was overwriting itself)
- Add comprehensive unit tests for tool_utils
- Add cloud mode routing and auth tests

Benefits:
- 32% code reduction (342 → 234 lines in tool.py)
- Consistent error handling across all commands
- Better testability with extracted utilities
- Cloud mode functionality validated

Fixes #346

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-10-10 17:08:54 +00:00
4 changed files with 413 additions and 168 deletions
+79 -168
View File
@@ -1,6 +1,6 @@
"""CLI tool commands for Basic Memory."""
import asyncio
import json
import sys
from typing import Annotated, List, Optional
@@ -9,7 +9,7 @@ from loguru import logger
from rich import print as rprint
from basic_memory.cli.app import app
from basic_memory.config import ConfigManager
from basic_memory.cli.commands.tool_utils import resolve_project, run_async_tool
# Import prompts
from basic_memory.mcp.prompts.continue_conversation import (
@@ -78,44 +78,27 @@ def write_note(
# Reading from a file
cat document.md | basic-memory tools write-note --title "Document" --folder "docs"
"""
try:
# If content is not provided, read from stdin
if content is None:
# Check if we're getting data from a pipe or redirect
if not sys.stdin.isatty():
content = sys.stdin.read()
else: # pragma: no cover
# If stdin is a terminal (no pipe/redirect), inform the user
typer.echo(
"No content provided. Please provide content via --content or by piping to stdin.",
err=True,
)
raise typer.Exit(1)
# Also check for empty content
if content is not None and not content.strip():
typer.echo("Empty content provided. Please provide non-empty content.", err=True)
# If content is not provided, read from stdin
if content is None:
# Check if we're getting data from a pipe or redirect
if not sys.stdin.isatty():
content = sys.stdin.read()
else: # pragma: no cover
# If stdin is a terminal (no pipe/redirect), inform the user
typer.echo(
"No content provided. Please provide content via --content or by piping to stdin.",
err=True,
)
raise typer.Exit(1)
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# Also check for empty content
if content is not None and not content.strip():
typer.echo("Empty content provided. Please provide non-empty content.", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
note = asyncio.run(mcp_write_note.fn(title, content, folder, project_name, tags))
rprint(note)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during write_note: {e}", err=True)
raise typer.Exit(1)
raise
project_name = resolve_project(project)
note = run_async_tool(mcp_write_note.fn, title, content, folder, project_name, tags)
rprint(note)
@tool_app.command()
@@ -131,27 +114,9 @@ def read_note(
page_size: int = 10,
):
"""Read a markdown note from the knowledge base."""
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
try:
note = asyncio.run(mcp_read_note.fn(identifier, project_name, page, page_size))
rprint(note)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during read_note: {e}", err=True)
raise typer.Exit(1)
raise
project_name = resolve_project(project)
note = run_async_tool(mcp_read_note.fn, identifier, project_name, page, page_size)
rprint(note)
@tool_app.command()
@@ -168,65 +133,44 @@ def build_context(
max_related: int = 10,
):
"""Get context needed to continue a discussion."""
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
try:
context = asyncio.run(
mcp_build_context.fn(
project=project_name,
url=url,
depth=depth,
timeframe=timeframe,
page=page,
page_size=page_size,
max_related=max_related,
)
)
# Use json module for more controlled serialization
import json
context_dict = context.model_dump(exclude_none=True)
print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during build_context: {e}", err=True)
raise typer.Exit(1)
raise
project_name = resolve_project(project)
context = run_async_tool(
mcp_build_context.fn,
project=project_name,
url=url,
depth=depth,
timeframe=timeframe,
page=page,
page_size=page_size,
max_related=max_related,
)
context_dict = context.model_dump(exclude_none=True)
print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
@tool_app.command()
def recent_activity(
project: Annotated[
Optional[str],
typer.Option(
help="The project to use. If not provided, the default project will be used."
),
] = None,
type: Annotated[Optional[List[SearchItemType]], typer.Option()] = None,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
):
"""Get recent activity across the knowledge base."""
try:
result = asyncio.run(
mcp_recent_activity.fn(
type=type, # pyright: ignore [reportArgumentType]
depth=depth,
timeframe=timeframe,
)
)
# The tool now returns a formatted string directly
print(result)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during recent_activity: {e}", err=True)
raise typer.Exit(1)
raise
project_name = resolve_project(project)
result = run_async_tool(
mcp_recent_activity.fn,
project=project_name,
type=type, # pyright: ignore [reportArgumentType]
depth=depth,
timeframe=timeframe,
)
# The tool now returns a formatted string directly
print(result)
@tool_app.command("search-notes")
@@ -248,58 +192,33 @@ def search_notes(
page_size: int = 10,
):
"""Search across all content in the knowledge base."""
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
if permalink and title: # pragma: no cover
print("Cannot search both permalink and title")
raise typer.Abort()
try:
if permalink and title: # pragma: no cover
typer.echo(
"Use either --permalink or --title, not both. Exiting.",
err=True,
)
raise typer.Exit(1)
# set search type
search_type = ("permalink" if permalink else None,)
search_type = ("permalink_match" if permalink and "*" in query else None,)
search_type = ("title" if title else None,)
search_type = "text" if search_type is None else search_type
results = asyncio.run(
mcp_search.fn(
query,
project_name,
search_type=search_type,
page=page,
after_date=after_date,
page_size=page_size,
)
if permalink and title:
typer.echo(
"Use either --permalink or --title, not both. Exiting.",
err=True,
)
# Use json module for more controlled serialization
import json
raise typer.Exit(1)
results_dict = results.model_dump(exclude_none=True)
print(json.dumps(results_dict, indent=2, ensure_ascii=True, default=str))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception("Error during search", e)
typer.echo(f"Error during search: {e}", err=True)
raise typer.Exit(1)
raise
# Determine search type (fixed logic - was overwriting itself before)
if permalink:
search_type = "permalink_match" if "*" in query else "permalink"
elif title:
search_type = "title"
else:
search_type = "text"
project_name = resolve_project(project)
results = run_async_tool(
mcp_search.fn,
query,
project_name,
search_type=search_type,
page=page,
after_date=after_date,
page_size=page_size,
)
results_dict = results.model_dump(exclude_none=True)
print(json.dumps(results_dict, indent=2, ensure_ascii=True, default=str))
@tool_app.command(name="continue-conversation")
@@ -310,16 +229,8 @@ def continue_conversation(
] = None,
):
"""Prompt to continue a previous conversation or work session."""
try:
# Prompt functions return formatted strings directly
session = asyncio.run(mcp_continue_conversation.fn(topic=topic, timeframe=timeframe)) # type: ignore
rprint(session)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception("Error continuing conversation", e)
typer.echo(f"Error continuing conversation: {e}", err=True)
raise typer.Exit(1)
raise
session = run_async_tool(mcp_continue_conversation.fn, topic=topic, timeframe=timeframe) # type: ignore
rprint(session)
# @tool_app.command(name="show-recent-activity")
@@ -0,0 +1,57 @@
"""Shared utilities for CLI tool commands."""
import asyncio
from typing import Any, Callable, Optional
import typer
from basic_memory.config import ConfigManager
def resolve_project(project: Optional[str] = None) -> str:
"""Resolve project name from parameter or default.
Args:
project: Optional project name to resolve. If None, uses default project.
Returns:
The resolved project name.
Raises:
typer.Exit: If the specified project is not found in the configuration.
"""
config_manager = ConfigManager()
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
return project_name
return config_manager.default_project
def run_async_tool(tool_func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
"""Run an async MCP tool function with proper error handling.
Args:
tool_func: The async function to execute.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
The result from the async function.
Raises:
typer.Exit: If an error occurs during execution.
"""
try:
return asyncio.run(tool_func(*args, **kwargs))
except Exception as e:
if not isinstance(e, typer.Exit):
# Get function name for better error messages
func_name = getattr(tool_func, "__name__", "tool")
typer.echo(f"Error during {func_name}: {e}", err=True)
raise typer.Exit(1)
raise
+66
View File
@@ -12,6 +12,7 @@ from textwrap import dedent
from typing import AsyncGenerator
from unittest.mock import patch
import pytest
import pytest_asyncio
from typer.testing import CliRunner
@@ -490,3 +491,68 @@ def test_ensure_migrations_handles_errors(mock_initialize_database, app_config,
ensure_initialization(app_config)
# We're just making sure it doesn't crash by calling it
# Tests for tool_utils
def test_resolve_project_with_explicit_project(project_config, test_project):
"""Test resolve_project with an explicit project parameter."""
from basic_memory.cli.commands.tool_utils import resolve_project
result = resolve_project(test_project.name)
assert result == test_project.name
def test_resolve_project_with_default(project_config, test_project):
"""Test resolve_project using default project."""
from basic_memory.cli.commands.tool_utils import resolve_project
# Should return the default project when None is passed
result = resolve_project(None)
assert result == test_project.name
def test_resolve_project_invalid_project(project_config):
"""Test resolve_project with invalid project name."""
from basic_memory.cli.commands.tool_utils import resolve_project
# Should raise typer.Exit when project not found
with pytest.raises(SystemExit) as exc_info:
resolve_project("nonexistent-project")
assert exc_info.value.code == 1
def test_run_async_tool_success(project_config):
"""Test run_async_tool with successful execution."""
from basic_memory.cli.commands.tool_utils import run_async_tool
async def sample_tool(x: int, y: int) -> int:
return x + y
result = run_async_tool(sample_tool, 2, 3)
assert result == 5
def test_run_async_tool_with_kwargs(project_config):
"""Test run_async_tool with keyword arguments."""
from basic_memory.cli.commands.tool_utils import run_async_tool
async def sample_tool(x: int, y: int = 10) -> int:
return x + y
result = run_async_tool(sample_tool, 5, y=15)
assert result == 20
def test_run_async_tool_error_handling(project_config):
"""Test run_async_tool handles exceptions properly."""
from basic_memory.cli.commands.tool_utils import run_async_tool
async def failing_tool() -> None:
raise ValueError("Test error")
# Should raise typer.Exit on error
with pytest.raises(SystemExit) as exc_info:
run_async_tool(failing_tool)
assert exc_info.value.code == 1
+211
View File
@@ -0,0 +1,211 @@
"""Tests for CLI tools in cloud mode.
These tests verify that CLI tools properly route to cloud endpoints
and inject authentication headers when in cloud mode.
"""
from unittest.mock import AsyncMock, Mock, patch
import httpx
import pytest
from typer.testing import CliRunner
from basic_memory.cli.commands.tool import tool_app
runner = CliRunner()
@pytest.fixture
def mock_cloud_config(tmp_path):
"""Mock cloud configuration."""
config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True)
# Create mock auth file
auth_file = config_dir / "auth.json"
auth_file.write_text('{"access_token": "test-token", "refresh_token": "test-refresh"}')
# Create mock config file with cloud project
config_file = config_dir / "config.yaml"
config_file.write_text("""
projects:
test-cloud:
path: /tmp/test-cloud
mode: cloud
cloud_project_id: test-project-123
default_project: test-cloud
""")
with patch("basic_memory.config.get_config_dir", return_value=config_dir):
yield config_dir
class TestCloudModeRouting:
"""Tests for cloud mode routing and authentication."""
def test_write_note_routes_to_cloud(self, mock_cloud_config):
"""Test that write_note routes to cloud endpoint in cloud mode."""
# Mock the HTTP client to capture the request
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"title": "Test Note",
"permalink": "test-note",
"status": "Created",
}
with patch("basic_memory.mcp.async_client.httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.post = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value = mock_instance
# Run command
result = runner.invoke(
tool_app,
[
"write-note",
"--title", "Test Note",
"--content", "Test content",
"--folder", "test",
],
)
# Verify cloud endpoint was called
# In cloud mode, requests should go through /proxy endpoint
assert mock_instance.post.called or mock_instance.request.called
# Verify auth headers were injected
if mock_instance.post.called:
call_kwargs = mock_instance.post.call_args.kwargs
assert "headers" in call_kwargs
# Auth is injected at client creation, not per-request
def test_search_notes_cloud_auth_injection(self, mock_cloud_config):
"""Test that search_notes injects auth headers in cloud mode."""
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"results": [],
"metadata": {"total_results": 0},
}
with patch("basic_memory.mcp.async_client.httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.get = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value = mock_instance
# Run command
result = runner.invoke(
tool_app,
["search-notes", "test query"],
)
# Verify cloud endpoint was called with auth
assert mock_instance.get.called or mock_instance.request.called
def test_read_note_cloud_mode(self, mock_cloud_config):
"""Test that read_note works in cloud mode."""
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"title": "Test Note",
"permalink": "test-note",
"content": "Test content",
}
with patch("basic_memory.mcp.async_client.httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.get = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value = mock_instance
# Run command
result = runner.invoke(
tool_app,
["read-note", "test-note"],
)
# Verify cloud endpoint was called
assert mock_instance.get.called or mock_instance.request.called
class TestCloudModeErrors:
"""Tests for cloud mode error handling."""
def test_unauthenticated_error(self, tmp_path):
"""Test error handling when not authenticated in cloud mode."""
config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True)
# Create config WITHOUT auth file
config_file = config_dir / "config.yaml"
config_file.write_text("""
projects:
test-cloud:
path: /tmp/test-cloud
mode: cloud
cloud_project_id: test-project-123
default_project: test-cloud
""")
with patch("basic_memory.config.get_config_dir", return_value=config_dir):
# Mock client to raise authentication error
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 401
mock_response.json.return_value = {"detail": "Unauthorized"}
http_error = httpx.HTTPStatusError(
"401 Unauthorized",
request=Mock(),
response=mock_response,
)
with patch("basic_memory.mcp.async_client.httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.get = AsyncMock(side_effect=http_error)
mock_client.return_value.__aenter__.return_value = mock_instance
# Run command - should handle error gracefully
result = runner.invoke(
tool_app,
["read-note", "test-note"],
)
# Command should exit with error
assert result.exit_code == 1
def test_subscription_required_error(self, mock_cloud_config):
"""Test handling of subscription required error."""
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 403
mock_response.json.return_value = {
"detail": {
"error": "subscription_required",
"message": "Active subscription required",
"subscribe_url": "https://basicmemory.com/subscribe",
}
}
http_error = httpx.HTTPStatusError(
"403 Forbidden",
request=Mock(),
response=mock_response,
)
with patch("basic_memory.mcp.async_client.httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.post = AsyncMock(side_effect=http_error)
mock_client.return_value.__aenter__.return_value = mock_instance
# Run command
result = runner.invoke(
tool_app,
[
"write-note",
"--title", "Test",
"--content", "Test",
"--folder", "test",
],
)
# Command should exit with error
assert result.exit_code == 1