mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix: double-default display in project list + stale test updates
Use config.default_project as single source of truth for the Default column in `bm project list`, removing checks against local DB and cloud API is_default fields that could independently mark multiple projects. Also updates tests that were out of date after get_project_mode changed to default unknown projects to CLOUD: - test_get_client_local_project_uses_asgi_transport: register "main" as LOCAL - test_run_filters_cloud_projects_each_cycle: register local project in config - test_new_project_addition_scenario: register projects as LOCAL in config - test_get_project_mode_defaults_to_cloud: assert new CLOUD default Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -161,13 +161,7 @@ def list_projects(
|
||||
else:
|
||||
cli_route = ProjectMode.LOCAL.value
|
||||
|
||||
is_default = ""
|
||||
if config.default_project == project_name:
|
||||
is_default = "[X]"
|
||||
if local_project is not None and local_project.is_default:
|
||||
is_default = "[X]"
|
||||
if cloud_project is not None and cloud_project.is_default:
|
||||
is_default = "[X]"
|
||||
is_default = "[X]" if config.default_project == project_name else ""
|
||||
|
||||
has_sync = "[X]" if entry and entry.local_sync_path else ""
|
||||
mcp_stdio_target = "local" if local_project is not None else "n/a"
|
||||
@@ -326,7 +320,16 @@ def remove_project(
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _remove_project():
|
||||
async with get_client() as client:
|
||||
# Resolve workspace so cloud-only projects auto-route without --cloud
|
||||
config = ConfigManager().config
|
||||
entry = config.projects.get(name)
|
||||
ws = None
|
||||
if entry and entry.workspace_id:
|
||||
ws = entry.workspace_id
|
||||
elif config.default_workspace:
|
||||
ws = config.default_workspace
|
||||
|
||||
async with get_client(project_name=name, workspace=ws) as client:
|
||||
project_client = ProjectClient(client)
|
||||
# Convert name to permalink for efficient resolution
|
||||
project_permalink = generate_permalink(name)
|
||||
@@ -405,7 +408,16 @@ def set_default_project(
|
||||
"""
|
||||
|
||||
async def _set_default():
|
||||
async with get_client() as client:
|
||||
# Resolve workspace so cloud-only projects auto-route without flags
|
||||
config = ConfigManager().config
|
||||
entry = config.projects.get(name)
|
||||
ws = None
|
||||
if entry and entry.workspace_id:
|
||||
ws = entry.workspace_id
|
||||
elif config.default_workspace:
|
||||
ws = config.default_workspace
|
||||
|
||||
async with get_client(project_name=name, workspace=ws) as client:
|
||||
project_client = ProjectClient(client)
|
||||
# Convert name to permalink for efficient resolution
|
||||
project_permalink = generate_permalink(name)
|
||||
|
||||
@@ -14,7 +14,6 @@ from loguru import logger
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.tools import build_context as mcp_build_context
|
||||
from basic_memory.mcp.tools import edit_note as mcp_edit_note
|
||||
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
|
||||
@@ -36,16 +35,6 @@ VALID_EDIT_OPERATIONS = ["append", "prepend", "find_replace", "replace_section"]
|
||||
# --- Shared helpers ---
|
||||
|
||||
|
||||
def _resolve_project(config_manager: ConfigManager, project: Optional[str]) -> Optional[str]:
|
||||
"""Resolve project name from CLI arg or config default."""
|
||||
if project is not None:
|
||||
project_name, _ = config_manager.get_project(project)
|
||||
if not project_name:
|
||||
raise ValueError(f"No project found named: {project}")
|
||||
return project_name
|
||||
return config_manager.default_project
|
||||
|
||||
|
||||
def _print_json(result: Any) -> None:
|
||||
"""Print a result as formatted JSON."""
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
|
||||
@@ -108,9 +97,6 @@ def write_note(
|
||||
typer.echo("Empty content provided. Please provide non-empty content.", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
project_name = _resolve_project(config_manager, project)
|
||||
|
||||
assert content is not None
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
@@ -119,7 +105,7 @@ def write_note(
|
||||
title=title,
|
||||
content=content,
|
||||
directory=folder,
|
||||
project=project_name,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
tags=tags,
|
||||
output_format="json",
|
||||
@@ -168,14 +154,11 @@ def read_note(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
project_name = _resolve_project(config_manager, project)
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_read_note(
|
||||
identifier=identifier,
|
||||
project=project_name,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
@@ -237,16 +220,13 @@ def edit_note(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
project_name = _resolve_project(config_manager, project)
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_edit_note(
|
||||
identifier=identifier,
|
||||
operation=operation,
|
||||
content=content,
|
||||
project=project_name,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
section=section,
|
||||
find_text=find_text,
|
||||
@@ -304,14 +284,11 @@ def build_context(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
project_name = _resolve_project(config_manager, project)
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_build_context(
|
||||
url=url,
|
||||
project=project_name,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
@@ -365,9 +342,6 @@ def recent_activity(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
project_name = _resolve_project(config_manager, project)
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_recent_activity(
|
||||
@@ -376,7 +350,7 @@ def recent_activity(
|
||||
timeframe=timeframe if timeframe is not None else "7d",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
project=project_name,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
output_format="json",
|
||||
)
|
||||
@@ -460,9 +434,6 @@ def search_notes(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
project_name = _resolve_project(config_manager, project)
|
||||
|
||||
mode_flags = [permalink, title, vector, hybrid]
|
||||
if sum(1 for enabled in mode_flags if enabled) > 1: # pragma: no cover
|
||||
typer.echo(
|
||||
@@ -515,7 +486,7 @@ def search_notes(
|
||||
result = run_with_cleanup(
|
||||
mcp_search(
|
||||
query=query or "",
|
||||
project=project_name,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
search_type=search_type,
|
||||
output_format="json",
|
||||
@@ -649,9 +620,6 @@ def schema_validate(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
project_name = _resolve_project(config_manager, project)
|
||||
|
||||
# Heuristic: if target contains / or ., treat as identifier; otherwise as note type
|
||||
note_type, identifier = None, None
|
||||
if target:
|
||||
@@ -665,7 +633,7 @@ def schema_validate(
|
||||
mcp_schema_validate(
|
||||
note_type=note_type,
|
||||
identifier=identifier,
|
||||
project=project_name,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
output_format="json",
|
||||
)
|
||||
@@ -717,15 +685,12 @@ def schema_infer(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
project_name = _resolve_project(config_manager, project)
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_schema_infer(
|
||||
note_type=entity_type,
|
||||
threshold=threshold,
|
||||
project=project_name,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
output_format="json",
|
||||
)
|
||||
@@ -773,14 +738,11 @@ def schema_diff(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
project_name = _resolve_project(config_manager, project)
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_schema_diff(
|
||||
note_type=entity_type,
|
||||
project=project_name,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
@@ -425,10 +425,12 @@ class BasicMemoryConfig(BaseSettings):
|
||||
def get_project_mode(self, project_name: str) -> ProjectMode:
|
||||
"""Get the routing mode for a project.
|
||||
|
||||
Returns the per-project mode if set, otherwise LOCAL.
|
||||
Returns the per-project mode if set.
|
||||
Unknown projects (not in local config) default to CLOUD —
|
||||
local projects are always registered in config.
|
||||
"""
|
||||
entry = self.projects.get(project_name)
|
||||
return entry.mode if entry else ProjectMode.LOCAL
|
||||
return entry.mode if entry else ProjectMode.CLOUD
|
||||
|
||||
def set_project_mode(self, project_name: str, mode: ProjectMode) -> None:
|
||||
"""Set the routing mode for a project.
|
||||
|
||||
@@ -445,6 +445,7 @@ async def get_project_client(
|
||||
|
||||
# Step 3: Determine if cloud routing is needed
|
||||
config = ConfigManager().config
|
||||
project_entry = config.projects.get(resolved_project)
|
||||
project_mode = config.get_project_mode(resolved_project)
|
||||
|
||||
# Trigger: workspace provided for a local project (without explicit --cloud)
|
||||
@@ -459,7 +460,6 @@ async def get_project_client(
|
||||
if project_mode == ProjectMode.CLOUD or (_explicit_routing() and not _force_local_mode()):
|
||||
# --- Cloud routing: resolve workspace with priority chain ---
|
||||
effective_workspace = workspace
|
||||
project_entry = config.projects.get(resolved_project)
|
||||
|
||||
# Priority 2: per-project workspace_id from config
|
||||
if effective_workspace is None and project_entry and project_entry.workspace_id:
|
||||
|
||||
@@ -5,7 +5,7 @@ Tests mock the MCP tool functions directly.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -79,28 +79,16 @@ SEARCH_RESULT = {
|
||||
}
|
||||
|
||||
|
||||
def _mock_config_manager():
|
||||
"""Create a mock ConfigManager that avoids reading real config."""
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.config = MagicMock()
|
||||
mock_cm.default_project = "test-project"
|
||||
mock_cm.get_project.return_value = ("test-project", "/tmp/test")
|
||||
return mock_cm
|
||||
|
||||
|
||||
# --- write-note ---
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_write_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=WRITE_NOTE_RESULT,
|
||||
)
|
||||
def test_write_note_json_output(mock_mcp_write, mock_config_cls):
|
||||
def test_write_note_json_output(mock_mcp_write):
|
||||
"""write-note outputs valid JSON from MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
@@ -125,16 +113,13 @@ def test_write_note_json_output(mock_mcp_write, mock_config_cls):
|
||||
assert mock_mcp_write.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_write_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=WRITE_NOTE_RESULT,
|
||||
)
|
||||
def test_write_note_with_tags(mock_mcp_write, mock_config_cls):
|
||||
def test_write_note_with_tags(mock_mcp_write):
|
||||
"""write-note passes tags through to MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
@@ -160,16 +145,13 @@ def test_write_note_with_tags(mock_mcp_write, mock_config_cls):
|
||||
# --- read-note ---
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_read_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=READ_NOTE_RESULT,
|
||||
)
|
||||
def test_read_note_json_output(mock_mcp_read, mock_config_cls):
|
||||
def test_read_note_json_output(mock_mcp_read):
|
||||
"""read-note outputs valid JSON from MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "read-note", "test-note"],
|
||||
@@ -185,16 +167,13 @@ def test_read_note_json_output(mock_mcp_read, mock_config_cls):
|
||||
assert mock_mcp_read.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_read_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=READ_NOTE_RESULT,
|
||||
)
|
||||
def test_read_note_workspace_passthrough(mock_mcp_read, mock_config_cls):
|
||||
def test_read_note_workspace_passthrough(mock_mcp_read):
|
||||
"""read-note --workspace passes workspace through to the MCP tool call."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "read-note", "test-note", "--workspace", "tenant-123"],
|
||||
@@ -204,16 +183,13 @@ def test_read_note_workspace_passthrough(mock_mcp_read, mock_config_cls):
|
||||
assert mock_mcp_read.call_args.kwargs["workspace"] == "tenant-123"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_read_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=READ_NOTE_RESULT,
|
||||
)
|
||||
def test_read_note_include_frontmatter(mock_mcp_read, mock_config_cls):
|
||||
def test_read_note_include_frontmatter(mock_mcp_read):
|
||||
"""read-note --include-frontmatter passes flag through to MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "read-note", "test-note", "--include-frontmatter"],
|
||||
@@ -223,16 +199,13 @@ def test_read_note_include_frontmatter(mock_mcp_read, mock_config_cls):
|
||||
assert mock_mcp_read.call_args.kwargs["include_frontmatter"] is True
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_read_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=READ_NOTE_RESULT,
|
||||
)
|
||||
def test_read_note_pagination(mock_mcp_read, mock_config_cls):
|
||||
def test_read_note_pagination(mock_mcp_read):
|
||||
"""read-note --page and --page-size are passed through."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "read-note", "test-note", "--page", "2", "--page-size", "5"],
|
||||
@@ -246,16 +219,13 @@ def test_read_note_pagination(mock_mcp_read, mock_config_cls):
|
||||
# --- edit-note ---
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_edit_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=EDIT_NOTE_RESULT,
|
||||
)
|
||||
def test_edit_note_json_output(mock_mcp_edit, mock_config_cls):
|
||||
def test_edit_note_json_output(mock_mcp_edit):
|
||||
"""edit-note outputs valid JSON from MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
@@ -277,16 +247,13 @@ def test_edit_note_json_output(mock_mcp_edit, mock_config_cls):
|
||||
assert mock_mcp_edit.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_edit_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"title": "Test", "permalink": "test", "error": "Edit failed: not found"},
|
||||
)
|
||||
def test_edit_note_error_response(mock_mcp_edit, mock_config_cls):
|
||||
def test_edit_note_error_response(mock_mcp_edit):
|
||||
"""edit-note exits with code 1 when MCP tool returns error field."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
@@ -306,16 +273,13 @@ def test_edit_note_error_response(mock_mcp_edit, mock_config_cls):
|
||||
# --- build-context ---
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_build_context",
|
||||
new_callable=AsyncMock,
|
||||
return_value=BUILD_CONTEXT_RESULT,
|
||||
)
|
||||
def test_build_context_json_output(mock_build_ctx, mock_config_cls):
|
||||
def test_build_context_json_output(mock_build_ctx):
|
||||
"""build-context outputs valid JSON from MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "build-context", "memory://test/topic"],
|
||||
@@ -328,16 +292,13 @@ def test_build_context_json_output(mock_build_ctx, mock_config_cls):
|
||||
assert mock_build_ctx.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_build_context",
|
||||
new_callable=AsyncMock,
|
||||
return_value=BUILD_CONTEXT_RESULT,
|
||||
)
|
||||
def test_build_context_with_options(mock_build_ctx, mock_config_cls):
|
||||
def test_build_context_with_options(mock_build_ctx):
|
||||
"""build-context passes depth, timeframe, pagination through."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
@@ -366,16 +327,13 @@ def test_build_context_with_options(mock_build_ctx, mock_config_cls):
|
||||
# --- recent-activity ---
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_recent_activity",
|
||||
new_callable=AsyncMock,
|
||||
return_value=RECENT_ACTIVITY_RESULT,
|
||||
)
|
||||
def test_recent_activity_json_output(mock_mcp_recent, mock_config_cls):
|
||||
def test_recent_activity_json_output(mock_mcp_recent):
|
||||
"""recent-activity outputs valid JSON list from MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "recent-activity"],
|
||||
@@ -391,16 +349,13 @@ def test_recent_activity_json_output(mock_mcp_recent, mock_config_cls):
|
||||
assert mock_mcp_recent.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_recent_activity",
|
||||
new_callable=AsyncMock,
|
||||
return_value=RECENT_ACTIVITY_RESULT,
|
||||
)
|
||||
def test_recent_activity_pagination(mock_mcp_recent, mock_config_cls):
|
||||
def test_recent_activity_pagination(mock_mcp_recent):
|
||||
"""recent-activity passes --page and --page-size through."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "recent-activity", "--page", "2", "--page-size", "10"],
|
||||
@@ -412,16 +367,13 @@ def test_recent_activity_pagination(mock_mcp_recent, mock_config_cls):
|
||||
assert kwargs["page_size"] == 10
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_recent_activity",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
)
|
||||
def test_recent_activity_empty(mock_mcp_recent, mock_config_cls):
|
||||
def test_recent_activity_empty(mock_mcp_recent):
|
||||
"""recent-activity handles empty results."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "recent-activity"],
|
||||
@@ -435,16 +387,13 @@ def test_recent_activity_empty(mock_mcp_recent, mock_config_cls):
|
||||
# --- search-notes ---
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_search",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SEARCH_RESULT,
|
||||
)
|
||||
def test_search_notes_json_output(mock_mcp_search, mock_config_cls):
|
||||
def test_search_notes_json_output(mock_mcp_search):
|
||||
"""search-notes outputs valid JSON from MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "search-notes", "test query"],
|
||||
@@ -458,16 +407,13 @@ def test_search_notes_json_output(mock_mcp_search, mock_config_cls):
|
||||
assert mock_mcp_search.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_search",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SEARCH_RESULT,
|
||||
)
|
||||
def test_search_notes_with_meta_filter(mock_mcp_search, mock_config_cls):
|
||||
def test_search_notes_with_meta_filter(mock_mcp_search):
|
||||
"""search-notes --meta key=value builds metadata filters."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "search-notes", "query", "--meta", "status=draft"],
|
||||
@@ -477,16 +423,13 @@ def test_search_notes_with_meta_filter(mock_mcp_search, mock_config_cls):
|
||||
assert mock_mcp_search.call_args.kwargs["metadata_filters"] == {"status": "draft"}
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_search",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SEARCH_RESULT,
|
||||
)
|
||||
def test_search_notes_permalink_mode(mock_mcp_search, mock_config_cls):
|
||||
def test_search_notes_permalink_mode(mock_mcp_search):
|
||||
"""search-notes --permalink sets search_type."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "search-notes", "specs/*", "--permalink"],
|
||||
@@ -496,16 +439,13 @@ def test_search_notes_permalink_mode(mock_mcp_search, mock_config_cls):
|
||||
assert mock_mcp_search.call_args.kwargs["search_type"] == "permalink"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_search",
|
||||
new_callable=AsyncMock,
|
||||
return_value="Error: search failed",
|
||||
)
|
||||
def test_search_notes_string_error(mock_mcp_search, mock_config_cls):
|
||||
def test_search_notes_string_error(mock_mcp_search):
|
||||
"""search-notes exits with code 1 when MCP returns string error."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "search-notes", "query"],
|
||||
@@ -514,41 +454,6 @@ def test_search_notes_string_error(mock_mcp_search, mock_config_cls):
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
# --- Project resolution ---
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_write_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=WRITE_NOTE_RESULT,
|
||||
)
|
||||
def test_project_not_found_error(mock_mcp_write, mock_config_cls):
|
||||
"""Commands exit with error when specified project doesn't exist."""
|
||||
mock_cm = _mock_config_manager()
|
||||
mock_cm.get_project.return_value = (None, None)
|
||||
mock_config_cls.return_value = mock_cm
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
"Test",
|
||||
"--folder",
|
||||
"notes",
|
||||
"--content",
|
||||
"hello",
|
||||
"--project",
|
||||
"nonexistent",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "No project found" in result.output
|
||||
|
||||
|
||||
# --- Routing flags ---
|
||||
|
||||
|
||||
@@ -595,16 +500,13 @@ SCHEMA_VALIDATE_RESULT = {
|
||||
}
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_schema_validate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SCHEMA_VALIDATE_RESULT,
|
||||
)
|
||||
def test_schema_validate_json_output(mock_mcp, mock_config_cls):
|
||||
def test_schema_validate_json_output(mock_mcp):
|
||||
"""schema-validate outputs valid JSON from MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "schema-validate", "person"],
|
||||
@@ -618,16 +520,13 @@ def test_schema_validate_json_output(mock_mcp, mock_config_cls):
|
||||
assert mock_mcp.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_schema_validate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SCHEMA_VALIDATE_RESULT,
|
||||
)
|
||||
def test_schema_validate_identifier_heuristic(mock_mcp, mock_config_cls):
|
||||
def test_schema_validate_identifier_heuristic(mock_mcp):
|
||||
"""schema-validate treats target with / as identifier, not note_type."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "schema-validate", "people/alice.md"],
|
||||
@@ -638,16 +537,13 @@ def test_schema_validate_identifier_heuristic(mock_mcp, mock_config_cls):
|
||||
assert mock_mcp.call_args.kwargs["note_type"] is None
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_schema_validate",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"error": "No notes found of type 'person'"},
|
||||
)
|
||||
def test_schema_validate_error_response(mock_mcp, mock_config_cls):
|
||||
def test_schema_validate_error_response(mock_mcp):
|
||||
"""schema-validate outputs error JSON when MCP returns error dict."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "schema-validate", "person"],
|
||||
@@ -674,16 +570,13 @@ SCHEMA_INFER_RESULT = {
|
||||
}
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_schema_infer",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SCHEMA_INFER_RESULT,
|
||||
)
|
||||
def test_schema_infer_json_output(mock_mcp, mock_config_cls):
|
||||
def test_schema_infer_json_output(mock_mcp):
|
||||
"""schema-infer outputs valid JSON from MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "schema-infer", "person"],
|
||||
@@ -697,16 +590,13 @@ def test_schema_infer_json_output(mock_mcp, mock_config_cls):
|
||||
assert mock_mcp.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_schema_infer",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SCHEMA_INFER_RESULT,
|
||||
)
|
||||
def test_schema_infer_threshold_passthrough(mock_mcp, mock_config_cls):
|
||||
def test_schema_infer_threshold_passthrough(mock_mcp):
|
||||
"""schema-infer passes --threshold through to MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "schema-infer", "person", "--threshold", "0.5"],
|
||||
@@ -729,16 +619,13 @@ SCHEMA_DIFF_RESULT = {
|
||||
}
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_schema_diff",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SCHEMA_DIFF_RESULT,
|
||||
)
|
||||
def test_schema_diff_json_output(mock_mcp, mock_config_cls):
|
||||
def test_schema_diff_json_output(mock_mcp):
|
||||
"""schema-diff outputs valid JSON from MCP tool."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "schema-diff", "person"],
|
||||
|
||||
@@ -128,6 +128,7 @@ async def test_get_client_local_project_uses_asgi_transport(config_manager):
|
||||
"""Local-mode project uses ASGI transport even if API key exists."""
|
||||
cfg = config_manager.load_config()
|
||||
cfg.cloud_api_key = "bmc_test_key_123"
|
||||
cfg.set_project_mode("main", ProjectMode.LOCAL)
|
||||
config_manager.save_config(cfg)
|
||||
|
||||
async with get_client(project_name="main") as client:
|
||||
|
||||
@@ -266,8 +266,15 @@ async def test_workspace_uses_cached_workspace_without_fetch(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_client_rejects_workspace_for_local_project():
|
||||
async def test_get_project_client_rejects_workspace_for_local_project(config_manager):
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.config import ProjectEntry
|
||||
|
||||
# Register "main" as a LOCAL project so get_project_mode returns LOCAL
|
||||
config = config_manager.load_config()
|
||||
(config_manager.config_dir.parent / "main").mkdir(parents=True, exist_ok=True)
|
||||
config.projects["main"] = ProjectEntry(path=str(config_manager.config_dir.parent / "main"))
|
||||
config_manager.save_config(config)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="Workspace 'tenant-123' cannot be used with local project"
|
||||
@@ -429,3 +436,42 @@ class TestGetProjectClientRoutingOrder:
|
||||
|
||||
error_msg = str(exc_info.value).lower()
|
||||
assert "resolve_workspace_parameter should not be called" not in error_msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_only_project_routes_to_cloud(self, config_manager, monkeypatch):
|
||||
"""Project NOT in local config should route to cloud (not default to LOCAL).
|
||||
|
||||
Cloud-only projects aren't registered in local config. The routing logic
|
||||
should detect this and use CLOUD mode, falling back to default_workspace.
|
||||
"""
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
|
||||
config = config_manager.load_config()
|
||||
# Do NOT add "cloud-only-proj" to config.projects — it's cloud-only
|
||||
config.default_workspace = "global-default-tenant-id"
|
||||
config.cloud_api_key = "bmc_test123"
|
||||
config_manager.save_config(config)
|
||||
|
||||
# Patch resolve_workspace_parameter to fail if called — it should be skipped
|
||||
# because default_workspace is set (priority 3)
|
||||
async def fail_if_called(**kwargs): # pragma: no cover
|
||||
raise AssertionError(
|
||||
"resolve_workspace_parameter should not be called when default_workspace is set"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"basic_memory.mcp.project_context.resolve_workspace_parameter",
|
||||
fail_if_called,
|
||||
)
|
||||
|
||||
# Will fail at cloud client creation (no real cloud), but proves cloud routing
|
||||
# was selected instead of local routing
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
async with get_project_client(project="cloud-only-proj"):
|
||||
pass
|
||||
|
||||
# The error should NOT be about workspace resolution or local routing
|
||||
error_msg = str(exc_info.value).lower()
|
||||
assert "resolve_workspace_parameter should not be called" not in error_msg
|
||||
# Should not get a local ASGI routing error
|
||||
assert "no project found" not in error_msg
|
||||
|
||||
@@ -148,6 +148,7 @@ async def test_run_filters_cloud_projects_each_cycle(monkeypatch, tmp_path):
|
||||
config = BasicMemoryConfig(
|
||||
watch_project_reload_interval=1,
|
||||
projects={
|
||||
"local-project": {"path": str(tmp_path / "local"), "mode": "local"},
|
||||
"cloud-project": {"path": str(tmp_path / "cloud"), "mode": "cloud"},
|
||||
},
|
||||
)
|
||||
@@ -258,7 +259,12 @@ async def test_timer_task_cancelled_properly(monkeypatch, tmp_path):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_project_addition_scenario(monkeypatch, tmp_path):
|
||||
config = BasicMemoryConfig()
|
||||
config = BasicMemoryConfig(
|
||||
projects={
|
||||
"existing": {"path": str(tmp_path / "existing"), "mode": "local"},
|
||||
"new": {"path": str(tmp_path / "new"), "mode": "local"},
|
||||
},
|
||||
)
|
||||
|
||||
initial_projects = [
|
||||
Project(id=1, name="existing", path=str(tmp_path / "existing"), permalink="existing")
|
||||
|
||||
@@ -830,10 +830,14 @@ class TestProjectMode:
|
||||
assert ProjectMode.LOCAL.value == "local"
|
||||
assert ProjectMode.CLOUD.value == "cloud"
|
||||
|
||||
def test_get_project_mode_defaults_to_local(self):
|
||||
"""Test that unknown projects default to LOCAL mode."""
|
||||
def test_get_project_mode_defaults_to_cloud(self):
|
||||
"""Test that unknown projects default to CLOUD mode.
|
||||
|
||||
Unknown projects are not registered in local config, so they
|
||||
are assumed to be cloud-only projects discovered from the API.
|
||||
"""
|
||||
config = BasicMemoryConfig()
|
||||
assert config.get_project_mode("nonexistent") == ProjectMode.LOCAL
|
||||
assert config.get_project_mode("nonexistent") == ProjectMode.CLOUD
|
||||
|
||||
def test_set_project_mode_cloud(self):
|
||||
"""Test setting a project to cloud mode."""
|
||||
|
||||
Reference in New Issue
Block a user