mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94e2c92805 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user