From 2e5813d31e98e565b4acc251824633196490289d Mon Sep 17 00:00:00 2001 From: phernandez Date: Sat, 21 Feb 2026 20:04:03 -0600 Subject: [PATCH] feat: CLI refactoring + workspace-aware cloud project listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor CLI commands to use typed ProjectClient instead of raw HTTP calls, and add workspace metadata to cloud project listings so users can distinguish personal vs organization projects. Key changes: - 🔧 CLI commands now use ProjectClient typed API clients instead of call_get/call_post with manual URL construction - 🏢 Cloud project listings include workspace_name, workspace_type, and workspace_tenant_id for each cloud-sourced project - Pass config.default_workspace when fetching cloud projects via _fetch_cloud_projects() and CLI list_projects - Add --workspace flag to `bm project list` for explicit workspace override - Add "Workspace" column to CLI project list table - Add `bm tool list-projects` and `bm tool list-workspaces` JSON commands - Comprehensive tests for workspace passthrough, merge behavior, and CLI routing Co-Authored-By: Claude Opus 4.6 Signed-off-by: phernandez --- .../cli/commands/cloud/project_sync.py | 33 +- src/basic_memory/cli/commands/cloud/upload.py | 20 +- .../cli/commands/cloud/workspace.py | 67 ++- .../cli/commands/command_utils.py | 22 +- src/basic_memory/cli/commands/doctor.py | 11 +- src/basic_memory/cli/commands/project.py | 185 ++++--- src/basic_memory/cli/commands/status.py | 5 +- src/basic_memory/cli/commands/tool.py | 68 +++ src/basic_memory/config.py | 23 + src/basic_memory/mcp/async_client.py | 21 + src/basic_memory/mcp/clients/project.py | 146 +++++- src/basic_memory/mcp/project_context.py | 113 ++++- src/basic_memory/mcp/tools/build_context.py | 13 +- .../mcp/tools/project_management.py | 282 +++++++++-- src/basic_memory/mcp/tools/read_content.py | 13 +- src/basic_memory/mcp/tools/read_note.py | 13 +- src/basic_memory/mcp/tools/search.py | 12 +- src/basic_memory/mcp/tools/workspaces.py | 31 +- src/basic_memory/sync/watch_service.py | 2 +- tests/cli/test_cli_tool_json_output.py | 98 ++++ tests/cli/test_project_add_with_local_path.py | 44 +- tests/cli/test_project_info_errors.py | 5 +- tests/cli/test_project_list_and_ls.py | 53 +- tests/cli/test_project_set_cloud_local.py | 109 +++++ tests/cli/test_workspace_commands.py | 153 +++++- tests/mcp/test_project_context.py | 155 ++++++ tests/mcp/test_tool_contracts.py | 2 +- tests/mcp/test_tool_project_management.py | 462 ++++++++++++++++-- tests/test_config.py | 53 ++ 29 files changed, 1895 insertions(+), 319 deletions(-) diff --git a/src/basic_memory/cli/commands/cloud/project_sync.py b/src/basic_memory/cli/commands/cloud/project_sync.py index 4990f2e2..a9b49c70 100644 --- a/src/basic_memory/cli/commands/cloud/project_sync.py +++ b/src/basic_memory/cli/commands/cloud/project_sync.py @@ -11,7 +11,6 @@ import typer from rich.console import Console from basic_memory.cli.app import cloud_app -from basic_memory.cli.auth import CLIAuth from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info from basic_memory.cli.commands.cloud.rclone_commands import ( RcloneError, @@ -25,8 +24,8 @@ from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.cli.commands.routing import force_routing from basic_memory.config import ConfigManager, ProjectEntry from basic_memory.mcp.async_client import get_client -from basic_memory.mcp.tools.utils import call_get, call_post -from basic_memory.schemas.project_info import ProjectItem, ProjectList +from basic_memory.mcp.clients import ProjectClient +from basic_memory.schemas.project_info import ProjectItem from basic_memory.utils import generate_permalink, normalize_project_path console = Console() @@ -37,11 +36,9 @@ console = Console() def _has_cloud_credentials(config) -> bool: """Return whether cloud credentials are available (API key or OAuth token).""" - if config.cloud_api_key: - return True + from basic_memory.config import has_cloud_credentials - auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain) - return auth.load_tokens() is not None + return has_cloud_credentials(config) def _require_cloud_credentials(config) -> None: @@ -57,8 +54,7 @@ def _require_cloud_credentials(config) -> None: async def _get_cloud_project(name: str) -> ProjectItem | None: """Fetch a project by name from the cloud API.""" async with get_client() as client: - response = await call_get(client, "/v2/projects/") - projects_list = ProjectList.model_validate(response.json()) + projects_list = await ProjectClient(client).list_projects() for proj in projects_list.projects: if generate_permalink(proj.name) == generate_permalink(name): return proj @@ -132,12 +128,9 @@ def sync_project_command( async def _trigger_db_sync(): async with get_client() as client: - response = await call_post( - client, - f"/v2/projects/{project_data.external_id}/sync?force_full=true", - json={}, + return await ProjectClient(client).sync( + project_data.external_id, force_full=True ) - return response.json() try: with force_routing(cloud=True): @@ -210,12 +203,9 @@ def bisync_project_command( async def _trigger_db_sync(): async with get_client() as client: - response = await call_post( - client, - f"/v2/projects/{project_data.external_id}/sync?force_full=true", - json={}, + return await ProjectClient(client).sync( + project_data.external_id, force_full=True ) - return response.json() try: with force_routing(cloud=True): @@ -329,9 +319,8 @@ def setup_project_sync( async def _verify_project_exists(): """Verify the project exists on cloud by listing all projects.""" async with get_client() as client: - response = await call_get(client, "/v2/projects/") - project_list = response.json() - project_names = [p["name"] for p in project_list["projects"]] + projects_list = await ProjectClient(client).list_projects() + project_names = [p.name for p in projects_list.projects] if name not in project_names: raise ValueError(f"Project '{name}' not found on cloud") return True diff --git a/src/basic_memory/cli/commands/cloud/upload.py b/src/basic_memory/cli/commands/cloud/upload.py index 1a7142da..fd49a63d 100644 --- a/src/basic_memory/cli/commands/cloud/upload.py +++ b/src/basic_memory/cli/commands/cloud/upload.py @@ -10,7 +10,6 @@ import httpx from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path from basic_memory.mcp.async_client import get_client -from basic_memory.mcp.tools.utils import call_put # Archive file extensions that should be skipped during upload ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".tbz2"} @@ -24,7 +23,7 @@ async def upload_path( dry_run: bool = False, *, client_cm_factory: Callable[[], AbstractAsyncContextManager[httpx.AsyncClient]] | None = None, - put_func=call_put, + put_func: Callable | None = None, ) -> bool: """ Upload a file or directory to cloud project via WebDAV. @@ -117,9 +116,20 @@ async def upload_path( # Upload via HTTP PUT to WebDAV endpoint with mtime header # Using X-OC-Mtime (ownCloud/Nextcloud standard) - response = await put_func( - client, remote_path, content=content, headers={"X-OC-Mtime": str(mtime)} - ) + if put_func is not None: + # Test injection path + response = await put_func( + client, + remote_path, + content=content, + headers={"X-OC-Mtime": str(mtime)}, + ) + else: + response = await client.put( + remote_path, + content=content, + headers={"X-OC-Mtime": str(mtime)}, + ) response.raise_for_status() # Format total size based on magnitude diff --git a/src/basic_memory/cli/commands/cloud/workspace.py b/src/basic_memory/cli/commands/cloud/workspace.py index 989ae949..f06b3c9c 100644 --- a/src/basic_memory/cli/commands/cloud/workspace.py +++ b/src/basic_memory/cli/commands/cloud/workspace.py @@ -4,9 +4,13 @@ import typer from rich.console import Console from rich.table import Table -from basic_memory.cli.app import cloud_app from basic_memory.cli.commands.command_utils import run_with_cleanup -from basic_memory.mcp.project_context import get_available_workspaces +from basic_memory.config import ConfigManager +from basic_memory.mcp.project_context import ( + _workspace_choices, + _workspace_matches_identifier, + get_available_workspaces, +) console = Console() @@ -33,18 +37,77 @@ def list_workspaces() -> None: console.print("[yellow]No accessible workspaces found.[/yellow]") return + config = ConfigManager().config + default_ws = config.default_workspace + table = Table(title="Available Workspaces") table.add_column("Name", style="cyan") table.add_column("Type", style="blue") table.add_column("Role", style="green") table.add_column("Tenant ID", style="yellow") + table.add_column("Default", style="magenta") for workspace in workspaces: + is_default = "[X]" if workspace.tenant_id == default_ws else "" table.add_row( workspace.name, workspace.workspace_type, workspace.role, workspace.tenant_id, + is_default, ) console.print(table) + + +@workspace_app.command("set-default") +def set_default_workspace( + identifier: str = typer.Argument(..., help="Workspace name or tenant_id to set as default"), +) -> None: + """Set the default cloud workspace. + + The default workspace is used as fallback when no per-project workspace + is configured. Resolves the identifier against available workspaces. + + Examples: + bm cloud workspace set-default Personal + bm cloud workspace set-default 11111111-1111-1111-1111-111111111111 + """ + + async def _list(): + return await get_available_workspaces() + + try: + workspaces = run_with_cleanup(_list()) + except RuntimeError as exc: + console.print(f"[red]Error: {exc}[/red]") + raise typer.Exit(1) + + if not workspaces: + console.print("[yellow]No accessible workspaces found.[/yellow]") + raise typer.Exit(1) + + matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, identifier)] + + if not matches: + console.print(f"[red]Error: Workspace '{identifier}' not found[/red]") + console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]") + raise typer.Exit(1) + + if len(matches) > 1: + console.print( + f"[red]Error: Workspace name '{identifier}' matches multiple workspaces. " + f"Use tenant_id instead.[/red]" + ) + console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]") + raise typer.Exit(1) + + selected = matches[0] + config_manager = ConfigManager() + config = config_manager.config + config.default_workspace = selected.tenant_id + config_manager.save_config(config) + + console.print( + f"[green]Default workspace set to '{selected.name}' ({selected.tenant_id})[/green]" + ) diff --git a/src/basic_memory/cli/commands/command_utils.py b/src/basic_memory/cli/commands/command_utils.py index a6002178..33ec2f8c 100644 --- a/src/basic_memory/cli/commands/command_utils.py +++ b/src/basic_memory/cli/commands/command_utils.py @@ -11,9 +11,8 @@ from rich.console import Console from basic_memory import db from basic_memory.config import ConfigManager from basic_memory.mcp.async_client import get_client -from basic_memory.mcp.tools.utils import call_post, call_get +from basic_memory.mcp.clients import ProjectClient from basic_memory.mcp.project_context import get_active_project -from basic_memory.schemas import ProjectInfoResponse console = Console() @@ -61,16 +60,12 @@ async def run_sync( try: async with get_client(project_name=project) as client: project_item = await get_active_project(client, project, None) - url = f"/v2/projects/{project_item.external_id}/sync" - params = [] - if force_full: - params.append("force_full=true") - if not run_in_background: - params.append("run_in_background=false") - if params: - url += "?" + "&".join(params) - response = await call_post(client, url) - data = response.json() + project_client = ProjectClient(client) + data = await project_client.sync( + project_item.external_id, + force_full=force_full, + run_in_background=run_in_background, + ) # Background mode returns {"message": "..."}, foreground returns SyncReportResponse if "message" in data: console.print(f"[green]{data['message']}[/green]") @@ -94,8 +89,7 @@ async def get_project_info(project: str): try: async with get_client(project_name=project) as client: project_item = await get_active_project(client, project, None) - response = await call_get(client, f"/v2/projects/{project_item.external_id}/info") - return ProjectInfoResponse.model_validate(response.json()) + return await ProjectClient(client).get_info(project_item.external_id) except (ToolError, ValueError) as e: error_text = str(e) if "internal proxy error" in error_text.lower() and "not found in configuration" in ( diff --git a/src/basic_memory/cli/commands/doctor.py b/src/basic_memory/cli/commands/doctor.py index 5b900cc0..57961204 100644 --- a/src/basic_memory/cli/commands/doctor.py +++ b/src/basic_memory/cli/commands/doctor.py @@ -19,7 +19,6 @@ from basic_memory.markdown.markdown_processor import MarkdownProcessor from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown from basic_memory.mcp.async_client import get_client from basic_memory.mcp.clients import KnowledgeClient, ProjectClient, SearchClient -from basic_memory.mcp.tools.utils import call_post from basic_memory.schemas.base import Entity from basic_memory.schemas.project_info import ProjectInfoRequest from basic_memory.schemas.search import SearchQuery @@ -98,11 +97,10 @@ async def run_doctor() -> None: await processor.write_file(manual_path, manual_markdown) console.print("[green]OK[/green] Manual file written") - sync_response = await call_post( - client, - f"/v2/projects/{project_id}/sync?force_full=true&run_in_background=false", + sync_data = await project_client.sync( + project_id, force_full=True, run_in_background=False ) - sync_report = SyncReportResponse.model_validate(sync_response.json()) + sync_report = SyncReportResponse.model_validate(sync_data) if sync_report.total == 0: raise ValueError("Sync did not detect any changes") @@ -118,8 +116,7 @@ async def run_doctor() -> None: console.print("[green]OK[/green] Search confirmed manual file") - status_response = await call_post(client, f"/v2/projects/{project_id}/status") - status_report = SyncReportResponse.model_validate(status_response.json()) + status_report = await project_client.get_status(project_id) if status_report.total != 0: raise ValueError("Project status not clean after sync") diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index a2cebf1f..2f4ea215 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -25,9 +25,8 @@ from basic_memory.cli.commands.command_utils import get_project_info, run_with_c from basic_memory.cli.commands.routing import force_routing, validate_routing_flags from basic_memory.config import ConfigManager, ProjectEntry, ProjectMode from basic_memory.mcp.async_client import get_client -from basic_memory.mcp.tools.utils import call_delete, call_get, call_patch, call_post, call_put -from basic_memory.schemas.project_info import ProjectItem, ProjectList, ProjectStatusResponse -from basic_memory.schemas.v2 import ProjectResolveResponse +from basic_memory.mcp.clients import ProjectClient +from basic_memory.schemas.project_info import ProjectItem, ProjectList from basic_memory.utils import generate_permalink, normalize_project_path console = Console() @@ -49,6 +48,7 @@ def format_path(path: str) -> str: def list_projects( local: bool = typer.Option(False, "--local", help="Force local routing for this command"), cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"), + workspace: str = typer.Option(None, "--workspace", help="Cloud workspace name or tenant_id"), ) -> None: """List Basic Memory projects from local and (when available) cloud.""" try: @@ -57,20 +57,22 @@ def list_projects( console.print(f"[red]Error: {e}[/red]") raise typer.Exit(1) - async def _list_projects(): - async with get_client() as client: - response = await call_get(client, "/v2/projects/") - return ProjectList.model_validate(response.json()) + async def _list_projects(ws: str | None = None): + async with get_client(workspace=ws) as client: + return await ProjectClient(client).list_projects() try: config = ConfigManager().config + # Use explicit workspace, fall back to config default + effective_workspace = workspace or config.default_workspace + local_result: ProjectList | None = None cloud_result: ProjectList | None = None cloud_error: Exception | None = None if cloud: with force_routing(cloud=True): - cloud_result = run_with_cleanup(_list_projects()) + cloud_result = run_with_cleanup(_list_projects(effective_workspace)) elif local: with force_routing(local=True): local_result = run_with_cleanup(_list_projects()) @@ -82,14 +84,33 @@ def list_projects( if _has_cloud_credentials(config): try: with force_routing(cloud=True): - cloud_result = run_with_cleanup(_list_projects()) + cloud_result = run_with_cleanup(_list_projects(effective_workspace)) except Exception as exc: # pragma: no cover cloud_error = exc + # Resolve workspace name for cloud projects (best-effort) + cloud_ws_name: str | None = None + cloud_ws_type: str | None = None + if cloud_result and effective_workspace: + try: + from basic_memory.mcp.project_context import get_available_workspaces + + workspaces = run_with_cleanup(get_available_workspaces()) + matched = next( + (ws for ws in workspaces if ws.tenant_id == effective_workspace), + None, + ) + if matched: + cloud_ws_name = matched.name + cloud_ws_type = matched.workspace_type + except Exception: + pass + table = Table(title="Basic Memory Projects") table.add_column("Name", style="cyan") table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold") table.add_column("Cloud Path", style="green") + table.add_column("Workspace", style="green") table.add_column("CLI Route", style="blue") table.add_column("MCP (stdio)", style="blue") table.add_column("Sync", style="green") @@ -151,10 +172,16 @@ def list_projects( has_sync = "[X]" if entry and entry.local_sync_path else "" mcp_stdio_target = "local" if local_project is not None else "n/a" + # Show workspace name (type) for cloud-sourced projects + ws_label = "" + if cloud_project is not None and cloud_ws_name: + ws_label = f"{cloud_ws_name} ({cloud_ws_type})" if cloud_ws_type else cloud_ws_name + row = [ project_name, local_path, cloud_path, + ws_label, cli_route, mcp_stdio_target, has_sync, @@ -229,8 +256,7 @@ def add_project( "local_sync_path": local_sync_path, "set_default": set_default, } - response = await call_post(client, "/v2/projects/", json=data) - return ProjectStatusResponse.model_validate(response.json()) + return await ProjectClient(client).create_project(data) else: # Local mode: path is required if path is None: @@ -243,8 +269,7 @@ def add_project( async def _add_project(): async with get_client() as client: data = {"name": name, "path": resolved_path, "set_default": set_default} - response = await call_post(client, "/v2/projects/", json=data) - return ProjectStatusResponse.model_validate(response.json()) + return await ProjectClient(client).create_project(data) try: with force_routing(local=local, cloud=cloud): @@ -302,19 +327,13 @@ def remove_project( async def _remove_project(): async with get_client() as client: + project_client = ProjectClient(client) # Convert name to permalink for efficient resolution project_permalink = generate_permalink(name) - - # Use v2 project resolver to find project ID by permalink - resolve_data = {"identifier": project_permalink} - response = await call_post(client, "/v2/projects/resolve", json=resolve_data) - target_project = response.json() - - # Use v2 API with project ID - response = await call_delete( - client, f"/v2/projects/{target_project['external_id']}?delete_notes={delete_notes}" + target_project = await project_client.resolve_project(project_permalink) + return await project_client.delete_project( + target_project.external_id, delete_notes=delete_notes ) - return ProjectStatusResponse.model_validate(response.json()) try: # Get config to check for local sync path and bisync state @@ -387,19 +406,11 @@ def set_default_project( async def _set_default(): async with get_client() as client: + project_client = ProjectClient(client) # Convert name to permalink for efficient resolution project_permalink = generate_permalink(name) - - # Use v2 project resolver to find project ID by permalink - resolve_data = {"identifier": project_permalink} - response = await call_post(client, "/v2/projects/resolve", json=resolve_data) - target_project = response.json() - - # Use v2 API with project ID - response = await call_put( - client, f"/v2/projects/{target_project['external_id']}/default" - ) - return ProjectStatusResponse.model_validate(response.json()) + target_project = await project_client.resolve_project(project_permalink) + return await project_client.set_default(target_project.external_id) try: with force_routing(local=local): @@ -410,31 +421,6 @@ def set_default_project( raise typer.Exit(1) -@project_app.command("sync-config") -def synchronize_projects( - local: bool = typer.Option( - False, "--local", help="Force local API routing (required in cloud mode)" - ), -) -> None: - """Synchronize project config between configuration file and database. - - In cloud mode, use --local to sync local configuration. - """ - - async def _sync_config(): - async with get_client() as client: - response = await call_post(client, "/v2/projects/config/sync") - return ProjectStatusResponse.model_validate(response.json()) - - try: - with force_routing(local=local): - result = run_with_cleanup(_sync_config()) - console.print(f"[green]{result.message}[/green]") - except Exception as e: # pragma: no cover - console.print(f"[red]Error synchronizing projects: {str(e)}[/red]") - raise typer.Exit(1) - - @project_app.command("move") def move_project( name: str = typer.Argument(..., help="Name of the project to move"), @@ -450,17 +436,11 @@ def move_project( async def _move_project(): async with get_client() as client: - data = {"path": resolved_path} - resolve_response = await call_post( - client, - "/v2/projects/resolve", - json={"identifier": name}, + project_client = ProjectClient(client) + project_info = await project_client.resolve_project(name) + return await project_client.update_project( + project_info.external_id, {"path": resolved_path} ) - project_info = ProjectResolveResponse.model_validate(resolve_response.json()) - response = await call_patch( - client, f"/v2/projects/{project_info.external_id}", json=data - ) - return ProjectStatusResponse.model_validate(response.json()) try: with force_routing(local=True): @@ -489,17 +469,24 @@ def move_project( @project_app.command("set-cloud") def set_cloud( name: str = typer.Argument(..., help="Name of the project to route through cloud"), + workspace: str = typer.Option( + None, + "--workspace", + help="Cloud workspace name or tenant_id to associate with this project", + ), ) -> None: """Set a project to cloud mode (route through cloud API). Requires either an API key or an active OAuth session. - Examples: - bm cloud api-key save bmc_abc123... # save API key, then: - bm project set-cloud research # route "research" through cloud + Use --workspace to associate a specific workspace with this project. + If omitted, uses the default workspace (if set) or auto-selects when + only one workspace is available. - bm cloud login # OAuth login, then: - bm project set-cloud research # route "research" through cloud + Examples: + bm project set-cloud research --workspace Personal + bm project set-cloud research --workspace 11111111-... + bm project set-cloud research # uses default workspace """ config_manager = ConfigManager() @@ -522,10 +509,54 @@ def set_cloud( console.print("[dim]Run 'bm cloud api-key save ' or 'bm cloud login' first[/dim]") raise typer.Exit(1) + # --- Resolve workspace to tenant_id --- + resolved_workspace_id: str | None = None + + if workspace is not None: + # Explicit --workspace: resolve to tenant_id via cloud lookup + from basic_memory.mcp.project_context import ( + get_available_workspaces, + _workspace_matches_identifier, + _workspace_choices, + ) + + workspaces = run_with_cleanup(get_available_workspaces()) + matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, workspace)] + if not matches: + console.print(f"[red]Error: Workspace '{workspace}' not found[/red]") + if workspaces: + console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]") + raise typer.Exit(1) + if len(matches) > 1: + console.print( + f"[red]Error: Workspace name '{workspace}' matches multiple workspaces. " + f"Use tenant_id instead.[/red]" + ) + console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]") + raise typer.Exit(1) + resolved_workspace_id = matches[0].tenant_id + elif config.default_workspace: + # Fall back to global default + resolved_workspace_id = config.default_workspace + else: + # Try auto-select if single workspace + try: + from basic_memory.mcp.project_context import get_available_workspaces + + workspaces = run_with_cleanup(get_available_workspaces()) + if len(workspaces) == 1: + resolved_workspace_id = workspaces[0].tenant_id + except Exception: + pass # Workspace resolution is optional at set-cloud time + config.set_project_mode(name, ProjectMode.CLOUD) + if resolved_workspace_id: + config.projects[name].workspace_id = resolved_workspace_id config_manager.save_config(config) console.print(f"[green]Project '{name}' set to cloud mode[/green]") + if resolved_workspace_id: + console.print(f"[dim]Workspace: {resolved_workspace_id}[/dim]") console.print("[dim]MCP tools and CLI commands for this project will route through cloud[/dim]") @@ -535,6 +566,8 @@ def set_local( ) -> None: """Revert a project to local mode (use in-process ASGI transport). + Clears any associated cloud workspace. + Example: bm project set-local research """ @@ -547,6 +580,7 @@ def set_local( raise typer.Exit(1) config.set_project_mode(name, ProjectMode.LOCAL) + config.projects[name].workspace_id = None config_manager.save_config(config) console.print(f"[green]Project '{name}' set to local mode[/green]") @@ -612,8 +646,7 @@ def ls_project_command( # Get project info async def _get_project(): async with get_client() as client: - response = await call_get(client, "/v2/projects/") - projects_list = ProjectList.model_validate(response.json()) + projects_list = await ProjectClient(client).list_projects() for proj in projects_list.projects: if generate_permalink(proj.name) == generate_permalink(name): return proj diff --git a/src/basic_memory/cli/commands/status.py b/src/basic_memory/cli/commands/status.py index c059a9af..afec5fc4 100644 --- a/src/basic_memory/cli/commands/status.py +++ b/src/basic_memory/cli/commands/status.py @@ -14,7 +14,7 @@ from basic_memory.cli.app import app from basic_memory.cli.commands.routing import force_routing, validate_routing_flags from basic_memory.config import ConfigManager from basic_memory.mcp.async_client import get_client -from basic_memory.mcp.tools.utils import call_post +from basic_memory.mcp.clients import ProjectClient from basic_memory.schemas import SyncReportResponse from basic_memory.mcp.project_context import get_active_project @@ -149,8 +149,7 @@ async def run_status(project: Optional[str] = None, verbose: bool = False): # p try: async with get_client(project_name=project) as client: project_item = await get_active_project(client, project, None) - response = await call_post(client, f"/v2/projects/{project_item.external_id}/status") - sync_report = SyncReportResponse.model_validate(response.json()) + sync_report = await ProjectClient(client).get_status(project_item.external_id) display_changes(project_item.name, "Status", sync_report, verbose) diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 75ae13ee..bd6eeb9c 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -17,6 +17,8 @@ from basic_memory.cli.commands.routing import force_routing, validate_routing_fl 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 +from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces from basic_memory.mcp.tools import read_note as mcp_read_note from basic_memory.mcp.tools import recent_activity as mcp_recent_activity from basic_memory.mcp.tools import schema_diff as mcp_schema_diff @@ -545,6 +547,72 @@ def search_notes( raise +# --- list-projects --- + + +@tool_app.command("list-projects") +def list_projects( + local: bool = typer.Option( + False, "--local", help="Force local API routing (ignore cloud mode)" + ), + cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"), +): + """List all available projects with their status (JSON output). + + Examples: + + bm tool list-projects + bm tool list-projects --local + """ + try: + validate_routing_flags(local, cloud) + + with force_routing(local=local, cloud=cloud): + result = run_with_cleanup(mcp_list_projects(output_format="json")) + _print_json(result) + except ValueError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + except Exception as e: # pragma: no cover + if not isinstance(e, typer.Exit): + typer.echo(f"Error during list_projects: {e}", err=True) + raise typer.Exit(1) + raise + + +# --- list-workspaces --- + + +@tool_app.command("list-workspaces") +def list_workspaces( + local: bool = typer.Option( + False, "--local", help="Force local API routing (ignore cloud mode)" + ), + cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"), +): + """List available cloud workspaces (JSON output). + + Examples: + + bm tool list-workspaces + bm tool list-workspaces --cloud + """ + try: + validate_routing_flags(local, cloud) + + with force_routing(local=local, cloud=cloud): + result = run_with_cleanup(mcp_list_workspaces(output_format="json")) + _print_json(result) + except ValueError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + except Exception as e: # pragma: no cover + if not isinstance(e, typer.Exit): + typer.echo(f"Error during list_workspaces: {e}", err=True) + raise typer.Exit(1) + raise + + # --- schema-validate --- diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 2ee3f4b1..fdd976b5 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -96,6 +96,10 @@ class ProjectEntry(BaseModel): default=ProjectMode.LOCAL, description="Routing mode: local (in-process ASGI) or cloud (remote API)", ) + workspace_id: Optional[str] = Field( + default=None, + description="Cloud workspace tenant_id. Set by 'bm project set-cloud --workspace'.", + ) # Cloud sync state (replaces CloudProjectConfig) local_sync_path: Optional[str] = Field( default=None, @@ -322,6 +326,11 @@ class BasicMemoryConfig(BaseSettings): description="API key for cloud access (bmc_ prefixed). Account-level, not per-project.", ) + default_workspace: Optional[str] = Field( + default=None, + description="Default cloud workspace tenant_id. Set by 'bm cloud workspace set-default'.", + ) + @model_validator(mode="before") @classmethod def migrate_legacy_projects(cls, data: Any) -> Any: @@ -776,6 +785,20 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig: raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover +def has_cloud_credentials(config: BasicMemoryConfig) -> bool: + """Check if cloud credentials are available (API key or OAuth token). + + Shared utility used by both MCP tools and CLI commands to determine + whether cloud project discovery is possible. + """ + if config.cloud_api_key: + return True + from basic_memory.cli.auth import CLIAuth + + auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain) + return auth.load_tokens() is not None + + def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None: """Save configuration to file.""" try: diff --git a/src/basic_memory/mcp/async_client.py b/src/basic_memory/mcp/async_client.py index 6930e09d..3d96b89f 100644 --- a/src/basic_memory/mcp/async_client.py +++ b/src/basic_memory/mcp/async_client.py @@ -106,6 +106,27 @@ def set_client_factory(factory: Callable[[], AbstractAsyncContextManager[AsyncCl _client_factory = factory +def is_factory_mode() -> bool: + """Return True when a client factory override is active (e.g., cloud app).""" + return _client_factory is not None + + +@asynccontextmanager +async def get_cloud_proxy_client( + workspace: Optional[str] = None, +) -> AsyncIterator[AsyncClient]: + """Create a cloud proxy client for project-level operations. + + Used by MCP tools to fetch cloud project lists independently of the + default get_client() routing, which always goes through the local ASGI + transport in stdio mode. + """ + config = ConfigManager().config + timeout = _build_timeout() + async with _cloud_client(config, timeout, workspace=workspace) as client: + yield client + + @asynccontextmanager async def get_client( project_name: Optional[str] = None, diff --git a/src/basic_memory/mcp/clients/project.py b/src/basic_memory/mcp/clients/project.py index 50e06401..a5c2ad57 100644 --- a/src/basic_memory/mcp/clients/project.py +++ b/src/basic_memory/mcp/clients/project.py @@ -7,8 +7,16 @@ from typing import Any from httpx import AsyncClient -from basic_memory.mcp.tools.utils import call_get, call_post, call_delete +from basic_memory.mcp.tools.utils import ( + call_delete, + call_get, + call_patch, + call_post, + call_put, +) +from basic_memory.schemas import ProjectInfoResponse, SyncReportResponse from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse +from basic_memory.schemas.v2 import ProjectResolveResponse class ProjectClient: @@ -70,11 +78,14 @@ class ProjectClient: ) return ProjectStatusResponse.model_validate(response.json()) - async def delete_project(self, project_external_id: str) -> ProjectStatusResponse: + async def delete_project( + self, project_external_id: str, delete_notes: bool = False + ) -> ProjectStatusResponse: """Delete a project by its external ID. Args: project_external_id: Project external ID (UUID) + delete_notes: If True, also delete project files from disk Returns: ProjectStatusResponse with deletion result @@ -82,8 +93,137 @@ class ProjectClient: Raises: ToolError: If the request fails """ + url = f"/v2/projects/{project_external_id}" + if delete_notes: + url += "?delete_notes=true" response = await call_delete( self.http_client, - f"/v2/projects/{project_external_id}", + url, ) return ProjectStatusResponse.model_validate(response.json()) + + async def resolve_project(self, identifier: str) -> ProjectResolveResponse: + """Resolve a project name/permalink to its full project record. + + Args: + identifier: Project name or permalink + + Returns: + ProjectResolveResponse with project metadata + + Raises: + ToolError: If the request fails + """ + response = await call_post( + self.http_client, + "/v2/projects/resolve", + json={"identifier": identifier}, + ) + return ProjectResolveResponse.model_validate(response.json()) + + async def set_default(self, project_external_id: str) -> ProjectStatusResponse: + """Set a project as the default. + + Args: + project_external_id: Project external ID (UUID) + + Returns: + ProjectStatusResponse with result + + Raises: + ToolError: If the request fails + """ + response = await call_put( + self.http_client, + f"/v2/projects/{project_external_id}/default", + ) + return ProjectStatusResponse.model_validate(response.json()) + + async def update_project( + self, project_external_id: str, data: dict[str, Any] + ) -> ProjectStatusResponse: + """Update a project's configuration (e.g. path). + + Args: + project_external_id: Project external ID (UUID) + data: Fields to update + + Returns: + ProjectStatusResponse with update result + + Raises: + ToolError: If the request fails + """ + response = await call_patch( + self.http_client, + f"/v2/projects/{project_external_id}", + json=data, + ) + return ProjectStatusResponse.model_validate(response.json()) + + async def sync( + self, + project_external_id: str, + force_full: bool = False, + run_in_background: bool = True, + ) -> dict[str, Any]: + """Trigger a sync operation for a project. + + Args: + project_external_id: Project external ID (UUID) + force_full: If True, force a full scan bypassing watermark optimization + run_in_background: If True, return immediately; if False, wait for completion + + Returns: + Raw response dict — background mode returns {"message": ...}, + foreground mode returns a SyncReportResponse-shaped dict. + + Raises: + ToolError: If the request fails + """ + url = f"/v2/projects/{project_external_id}/sync" + params = [] + if force_full: + params.append("force_full=true") + if not run_in_background: + params.append("run_in_background=false") + if params: + url += "?" + "&".join(params) + response = await call_post(self.http_client, url) + return response.json() + + async def get_status(self, project_external_id: str) -> SyncReportResponse: + """Get the sync status for a project. + + Args: + project_external_id: Project external ID (UUID) + + Returns: + SyncReportResponse describing pending changes + + Raises: + ToolError: If the request fails + """ + response = await call_post( + self.http_client, + f"/v2/projects/{project_external_id}/status", + ) + return SyncReportResponse.model_validate(response.json()) + + async def get_info(self, project_external_id: str) -> ProjectInfoResponse: + """Get detailed project information and statistics. + + Args: + project_external_id: Project external ID (UUID) + + Returns: + ProjectInfoResponse with project details + + Raises: + ToolError: If the request fails + """ + response = await call_get( + self.http_client, + f"/v2/projects/{project_external_id}/info", + ) + return ProjectInfoResponse.model_validate(response.json()) diff --git a/src/basic_memory/mcp/project_context.py b/src/basic_memory/mcp/project_context.py index 8f00dc6e..9107c077 100644 --- a/src/basic_memory/mcp/project_context.py +++ b/src/basic_memory/mcp/project_context.py @@ -19,7 +19,7 @@ from loguru import logger from fastmcp import Context from mcp.server.fastmcp.exceptions import ToolError -from basic_memory.config import ConfigManager, ProjectMode +from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode from basic_memory.project_resolver import ProjectResolver from basic_memory.schemas.cloud import WorkspaceInfo, WorkspaceListResponse from basic_memory.schemas.project_info import ProjectItem, ProjectList @@ -346,6 +346,35 @@ def add_project_metadata(result: str, project_name: str) -> str: return f"{result}\n\n[Session: Using project '{project_name}']" +def detect_project_from_url_prefix(identifier: str, config: BasicMemoryConfig) -> Optional[str]: + """Check if a memory URL's first path segment matches a known project in config. + + This enables automatic project routing from memory URLs like + ``memory://specs/in-progress`` without requiring the caller to pass + an explicit ``project`` parameter. + + Uses local config only — no network calls. + + Args: + identifier: Raw identifier string (may or may not start with ``memory://``). + config: Current BasicMemoryConfig with project entries. + + Returns: + Matching project name from config, or None if no match. + """ + path = memory_url_path(identifier) if identifier.strip().startswith("memory://") else identifier + normalized = normalize_project_reference(path) + prefix, _ = _split_project_prefix(normalized) + if prefix is None: + return None + + prefix_permalink = generate_permalink(prefix) + for project_name in config.projects: + if generate_permalink(project_name) == prefix_permalink: + return project_name + return None + + @asynccontextmanager async def get_project_client( project: Optional[str] = None, @@ -359,6 +388,20 @@ async def get_project_client( the project. This helper resolves the project from config first (no network), creates the correctly-routed client, then validates via API. + Routing decision order: + 1. Explicit --local/--cloud flags → skip workspace, use flag routing + 2. Cloud routing (explicit --cloud OR project mode CLOUD) → + resolve workspace via priority chain, create cloud client + 3. Otherwise → local ASGI client + + Workspace resolution priority (when cloud routing): + 1. Explicit ``workspace`` parameter + 2. Per-project ``workspace_id`` from config + 3. Global ``default_workspace`` from config + 4. MCP session cache (context) + 5. Auto-select if single workspace + 6. Error listing choices + Args: project: Optional explicit project parameter workspace: Optional cloud workspace selector (tenant_id or unique name) @@ -371,8 +414,12 @@ async def get_project_client( ValueError: If no project can be resolved RuntimeError: If cloud project but no API key configured """ - # Deferred import to avoid circular dependency - from basic_memory.mcp.async_client import get_client + # Deferred imports to avoid circular dependency + from basic_memory.mcp.async_client import ( + _explicit_routing, + _force_local_mode, + get_client, + ) # Step 1: Resolve project name from config (no network call) resolved_project = await resolve_project_parameter(project) @@ -386,28 +433,64 @@ async def get_project_client( f"Available projects: {project_names}" ) - # Step 2: Resolve project mode and optional workspace selection + # Step 2: Check explicit routing BEFORE workspace resolution + # Trigger: CLI passed --local or --cloud + # Why: explicit flags must be deterministic — skip workspace entirely for --local + # Outcome: route strictly based on explicit flag, no workspace network calls + if _explicit_routing() and _force_local_mode(): + async with get_client(project_name=resolved_project) as client: + active_project = await get_active_project(client, resolved_project, context) + yield client, active_project + return + + # Step 3: Determine if cloud routing is needed config = ConfigManager().config project_mode = config.get_project_mode(resolved_project) - active_workspace: WorkspaceInfo | None = None - # Trigger: workspace provided for a local project + # Trigger: workspace provided for a local project (without explicit --cloud) # Why: workspace selection is a cloud routing concern only # Outcome: fail fast with a deterministic guidance message - if project_mode != ProjectMode.CLOUD and workspace is not None: + if project_mode != ProjectMode.CLOUD and workspace is not None and not _explicit_routing(): raise ValueError( f"Workspace '{workspace}' cannot be used with local project '{resolved_project}'. " "Workspace selection is only supported for cloud-mode projects." ) - if project_mode == ProjectMode.CLOUD: - active_workspace = await resolve_workspace_parameter(workspace=workspace, context=context) + 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) - # Step 2: Create client routed based on project's mode - async with get_client( - project_name=resolved_project, - workspace=active_workspace.tenant_id if active_workspace else None, - ) as client: - # Step 3: Validate project exists via API + # Priority 2: per-project workspace_id from config + if effective_workspace is None and project_entry and project_entry.workspace_id: + effective_workspace = project_entry.workspace_id + + # Priority 3: global default_workspace from config + if effective_workspace is None and config.default_workspace: + effective_workspace = config.default_workspace + + # Priorities 4-6: if still unresolved, fall back to resolve_workspace_parameter + # which checks context cache, auto-selects single workspace, or errors + if effective_workspace is not None: + # Config-resolved workspace — pass directly to get_client, skip network lookup + async with get_client( + project_name=resolved_project, + workspace=effective_workspace, + ) as client: + active_project = await get_active_project(client, resolved_project, context) + yield client, active_project + else: + # No config-based workspace — use resolve_workspace_parameter for discovery + active_ws = await resolve_workspace_parameter(workspace=None, context=context) + async with get_client( + project_name=resolved_project, + workspace=active_ws.tenant_id, + ) as client: + active_project = await get_active_project(client, resolved_project, context) + yield client, active_project + return + + # Step 4: Local routing (default) + async with get_client(project_name=resolved_project) as client: active_project = await get_active_project(client, resolved_project, context) yield client, active_project diff --git a/src/basic_memory/mcp/tools/build_context.py b/src/basic_memory/mcp/tools/build_context.py index b5b158e9..4075ee60 100644 --- a/src/basic_memory/mcp/tools/build_context.py +++ b/src/basic_memory/mcp/tools/build_context.py @@ -5,7 +5,12 @@ from typing import Optional, Literal from loguru import logger from fastmcp import Context -from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path +from basic_memory.config import ConfigManager +from basic_memory.mcp.project_context import ( + detect_project_from_url_prefix, + get_project_client, + resolve_project_and_path, +) from basic_memory.mcp.server import mcp from basic_memory.schemas.base import TimeFrame from basic_memory.schemas.memory import ( @@ -247,6 +252,12 @@ async def build_context( Raises: ToolError: If project doesn't exist or depth parameter is invalid """ + # Detect project from memory URL prefix before routing + if project is None: + detected = detect_project_from_url_prefix(url, ConfigManager().config) + if detected: + project = detected + logger.info(f"Building context from {url} in project {project}") # Convert string depth to integer if needed diff --git a/src/basic_memory/mcp/tools/project_management.py b/src/basic_memory/mcp/tools/project_management.py index 51658316..d2e201ba 100644 --- a/src/basic_memory/mcp/tools/project_management.py +++ b/src/basic_memory/mcp/tools/project_management.py @@ -6,78 +6,270 @@ and manage project context during conversations. import os from typing import Literal -from fastmcp import Context -from basic_memory.mcp.async_client import get_client +from fastmcp import Context +from loguru import logger + +from basic_memory.config import ConfigManager, has_cloud_credentials +from basic_memory.mcp.async_client import get_client, get_cloud_proxy_client, is_factory_mode from basic_memory.mcp.server import mcp -from basic_memory.schemas.project_info import ProjectInfoRequest +from basic_memory.schemas.project_info import ProjectInfoRequest, ProjectItem, ProjectList from basic_memory.utils import generate_permalink +# --- Helpers for dual-fetch + merge --- + + +async def _fetch_cloud_projects( + workspace: str | None = None, + context: Context | None = None, +) -> ProjectList | None: + """Fetch projects from the cloud API, returning None on failure. + + Logs warnings on failure so the caller can fall back to local-only results. + """ + try: + from basic_memory.mcp.clients import ProjectClient + + async with get_cloud_proxy_client(workspace=workspace) as cloud_client: + cloud_project_client = ProjectClient(cloud_client) + cloud_list = await cloud_project_client.list_projects() + if context: # pragma: no cover + await context.info(f"Discovered {len(cloud_list.projects)} cloud projects") + return cloud_list + except Exception as exc: + logger.warning(f"Cloud project discovery failed: {exc}") + if context: # pragma: no cover + await context.info("Cloud project discovery failed, showing local projects only") + return None + + +def _merge_projects( + local_list: ProjectList | None, + cloud_list: ProjectList | None, + *, + cloud_workspace_name: str | None = None, + cloud_workspace_type: str | None = None, + cloud_workspace_tenant_id: str | None = None, +) -> list[dict]: + """Merge local and cloud project lists by permalink. + + Returns a sorted list of dicts with unified project metadata. + Same merge-by-permalink algorithm used by the CLI `bm project list`. + """ + names_by_permalink: dict[str, str] = {} + local_by_permalink: dict[str, ProjectItem] = {} + cloud_by_permalink: dict[str, ProjectItem] = {} + + if local_list: + for project in local_list.projects: + permalink = generate_permalink(project.name) + names_by_permalink[permalink] = project.name + local_by_permalink[permalink] = project + + if cloud_list: + for project in cloud_list.projects: + permalink = generate_permalink(project.name) + names_by_permalink[permalink] = project.name + cloud_by_permalink[permalink] = project + + merged: list[dict] = [] + for permalink in sorted(names_by_permalink): + name = names_by_permalink[permalink] + local_proj = local_by_permalink.get(permalink) + cloud_proj = cloud_by_permalink.get(permalink) + + # Determine source label + if local_proj and cloud_proj: + source = "local+cloud" + elif cloud_proj: + source = "cloud" + else: + source = "local" + + # Prefer local path for backward compat; fall back to cloud path + local_path = local_proj.path if local_proj else None + cloud_path = cloud_proj.path if cloud_proj else None + path = local_path or cloud_path or "" + + is_default = False + if local_proj and local_proj.is_default: + is_default = True + if cloud_proj and cloud_proj.is_default: + is_default = True + + # Prefer cloud display_name / is_private (cloud injects these) + display_name = None + is_private = False + if cloud_proj: + display_name = cloud_proj.display_name + is_private = cloud_proj.is_private + elif local_proj: + display_name = local_proj.display_name + is_private = local_proj.is_private + + # Attach workspace info for cloud-sourced projects + ws_name = cloud_workspace_name if cloud_proj else None + ws_type = cloud_workspace_type if cloud_proj else None + ws_tenant_id = cloud_workspace_tenant_id if cloud_proj else None + + merged.append( + { + "name": name, + "path": path, + "local_path": local_path, + "cloud_path": cloud_path, + "source": source, + "is_default": is_default, + "is_private": is_private, + "display_name": display_name, + "workspace_name": ws_name, + "workspace_type": ws_type, + "workspace_tenant_id": ws_tenant_id, + } + ) + + return merged + + +def _format_project_list_text(merged: list[dict]) -> str: + """Format merged project list as human-readable text.""" + result = "Available projects:\n" + for project in merged: + display_name = project["display_name"] + name = project["name"] + label = f"{display_name} ({name})" if display_name else name + source = project["source"] + result += f"• {label} ({source})\n" + + result += "\n" + "─" * 40 + "\n" + result += "Next: Ask which project to use for this session.\n" + result += "Example: 'Which project should I use for this task?'\n\n" + result += ( + "Session reminder: Track the selected project for all subsequent " + "operations in this conversation.\n" + ) + result += "The user can say 'switch to [project]' to change projects." + return result + + +def _format_project_list_json( + merged: list[dict], + default_project: str | None, + constrained_project: str | None, +) -> dict: + """Format merged project list as structured JSON.""" + return { + "projects": merged, + "default_project": default_project, + "constrained_project": constrained_project, + } + + @mcp.tool( "list_memory_projects", annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def list_memory_projects( output_format: Literal["text", "json"] = "text", + workspace: str | None = None, context: Context | None = None, ) -> str | dict: """List all available projects with their status. + Shows projects from both local and cloud sources when cloud credentials + are available, merging by permalink to give a unified view. + Args: output_format: "text" returns the existing human-readable project list. "json" returns structured project metadata. + workspace: Cloud workspace name or tenant_id. Falls back to + config.default_workspace when not specified. context: Optional FastMCP context for progress/status logging. """ - async with get_client() as client: - if context: # pragma: no cover - await context.info("Listing all available projects") + if context: # pragma: no cover + await context.info("Listing all available projects") - constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT") + constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT") - from basic_memory.mcp.clients import ProjectClient + from basic_memory.mcp.clients import ProjectClient - project_client = ProjectClient(client) - project_list = await project_client.list_projects() + # --- Factory mode (cloud app) --- + # Trigger: set_client_factory() was called (e.g., basic-memory-cloud) + # Why: there is no local ASGI server; the factory IS the only source + # Outcome: single fetch, no merge needed + if is_factory_mode(): + async with get_client() as client: + project_client = ProjectClient(client) + project_list = await project_client.list_projects() + merged = _merge_projects(project_list, None) if output_format == "json": - projects = [ - { - "name": project.name, - "path": project.path, - "is_default": project.is_default, - # Reserved for forward-compatible cloud/private project metadata. - # Local project list responses do not currently provide these values. - "is_private": False, - "display_name": None, - } - for project in project_list.projects - ] - return { - "projects": projects, - "default_project": project_list.default_project, - "constrained_project": constrained_project, - } - - if constrained_project: - result = f"Project: {constrained_project}\n\n" - result += "Note: This MCP server is constrained to a single project.\n" - result += "All operations will automatically use this project." - return result - - result = "Available projects:\n" - for project in project_list.projects: - label = ( - f"{project.display_name} ({project.name})" if project.display_name else project.name + return _format_project_list_json( + merged, project_list.default_project, constrained_project ) - result += f"• {label}\n" + if constrained_project: + return _format_constrained_text(constrained_project) + return _format_project_list_text(merged) - result += "\n" + "─" * 40 + "\n" - result += "Next: Ask which project to use for this session.\n" - result += "Example: 'Which project should I use for this task?'\n\n" - result += "Session reminder: Track the selected project for all subsequent operations in this conversation.\n" - result += "The user can say 'switch to [project]' to change projects." - return result + # --- Normal MCP stdio mode --- + # Always fetch local projects via the ASGI transport + async with get_client() as client: + project_client = ProjectClient(client) + local_list = await project_client.list_projects() + + # Fetch cloud projects when credentials are available + cloud_list: ProjectList | None = None + cloud_ws_name: str | None = None + cloud_ws_type: str | None = None + cloud_ws_tenant_id: str | None = None + config = ConfigManager().config + if has_cloud_credentials(config): + # Use explicit workspace, fall back to config default + effective_workspace = workspace or config.default_workspace + cloud_list = await _fetch_cloud_projects(effective_workspace, context) + + # Resolve workspace metadata so each cloud project carries its workspace info + if cloud_list: + cloud_ws_tenant_id = effective_workspace + try: + from basic_memory.mcp.project_context import get_available_workspaces + + workspaces = await get_available_workspaces(context) + matched = next( + (ws for ws in workspaces if ws.tenant_id == effective_workspace), + None, + ) + if matched: + cloud_ws_name = matched.name + cloud_ws_type = matched.workspace_type + except Exception: + pass # workspace lookup is best-effort + + merged = _merge_projects( + local_list, + cloud_list, + cloud_workspace_name=cloud_ws_name, + cloud_workspace_type=cloud_ws_type, + cloud_workspace_tenant_id=cloud_ws_tenant_id, + ) + default_project = local_list.default_project + + if output_format == "json": + return _format_project_list_json(merged, default_project, constrained_project) + + if constrained_project: + return _format_constrained_text(constrained_project) + + return _format_project_list_text(merged) + + +def _format_constrained_text(constrained_project: str) -> str: + """Format text output when the MCP server is constrained to a single project.""" + result = f"Project: {constrained_project}\n\n" + result += "Note: This MCP server is constrained to a single project.\n" + result += "All operations will automatically use this project." + return result @mcp.tool( diff --git a/src/basic_memory/mcp/tools/read_content.py b/src/basic_memory/mcp/tools/read_content.py index 5ddd80d5..7eec4eca 100644 --- a/src/basic_memory/mcp/tools/read_content.py +++ b/src/basic_memory/mcp/tools/read_content.py @@ -15,7 +15,12 @@ from PIL import Image as PILImage from fastmcp import Context from mcp.server.fastmcp.exceptions import ToolError -from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path +from basic_memory.config import ConfigManager +from basic_memory.mcp.project_context import ( + detect_project_from_url_prefix, + get_project_client, + resolve_project_and_path, +) from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.utils import call_get, resolve_entity_id from basic_memory.schemas.memory import memory_url_path @@ -205,6 +210,12 @@ async def read_content( HTTPError: If project doesn't exist or is inaccessible SecurityError: If path attempts path traversal """ + # Detect project from memory URL prefix before routing + if project is None: + detected = detect_project_from_url_prefix(path, ConfigManager().config) + if detected: + project = detected + logger.info("Reading file", path=path, project=project) async with get_project_client(project, workspace, context) as (client, active_project): diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index 4b79cbce..ec324d9f 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -8,7 +8,12 @@ import yaml from loguru import logger from fastmcp import Context -from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path +from basic_memory.config import ConfigManager +from basic_memory.mcp.project_context import ( + detect_project_from_url_prefix, + get_project_client, + resolve_project_and_path, +) from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.search import search_notes from basic_memory.schemas.memory import memory_url_path @@ -128,6 +133,12 @@ async def read_note( If the exact note isn't found, this tool provides helpful suggestions including related notes, search commands, and note creation templates. """ + # Detect project from memory URL prefix before routing + if project is None: + detected = detect_project_from_url_prefix(identifier, ConfigManager().config) + if detected: + project = detected + async with get_project_client(project, workspace, context) as (client, active_project): # Resolve identifier with project-prefix awareness for memory:// URLs _, entity_path, _ = await resolve_project_and_path(client, identifier, project, context) diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 4d4bf90f..addb37a4 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -8,7 +8,11 @@ from fastmcp import Context from basic_memory.config import ConfigManager from basic_memory.mcp.container import get_container -from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path +from basic_memory.mcp.project_context import ( + detect_project_from_url_prefix, + get_project_client, + resolve_project_and_path, +) from basic_memory.mcp.server import mcp from basic_memory.schemas.search import ( SearchItemType, @@ -427,6 +431,12 @@ async def search_notes( types = types or [] entity_types = entity_types or [] + # Detect project from memory URL prefix before routing + if project is None: + detected = detect_project_from_url_prefix(query, ConfigManager().config) + if detected: + project = detected + async with get_project_client(project, workspace, context) as (client, active_project): # Handle memory:// URLs by resolving to permalink search _, resolved_query, is_memory_url = await resolve_project_and_path( diff --git a/src/basic_memory/mcp/tools/workspaces.py b/src/basic_memory/mcp/tools/workspaces.py index 8e1be026..30b8757b 100644 --- a/src/basic_memory/mcp/tools/workspaces.py +++ b/src/basic_memory/mcp/tools/workspaces.py @@ -1,5 +1,7 @@ """Workspace discovery MCP tool.""" +from typing import Literal + from fastmcp import Context from basic_memory.mcp.project_context import get_available_workspaces @@ -10,10 +12,35 @@ from basic_memory.mcp.server import mcp description="List available cloud workspaces (tenant_id, type, role, and name).", annotations={"readOnlyHint": True, "openWorldHint": False}, ) -async def list_workspaces(context: Context | None = None) -> str: - """List workspaces available to the current cloud user.""" +async def list_workspaces( + output_format: Literal["text", "json"] = "text", + context: Context | None = None, +) -> str | dict: + """List workspaces available to the current cloud user. + + Args: + output_format: "text" returns human-readable workspace list. + "json" returns structured workspace metadata. + context: Optional FastMCP context for progress/status logging. + """ workspaces = await get_available_workspaces(context=context) + if output_format == "json": + return { + "workspaces": [ + { + "tenant_id": ws.tenant_id, + "name": ws.name, + "workspace_type": ws.workspace_type, + "role": ws.role, + "organization_id": ws.organization_id, + "has_active_subscription": ws.has_active_subscription, + } + for ws in workspaces + ], + "count": len(workspaces), + } + if not workspaces: return ( "# No Workspaces Available\n\n" diff --git a/src/basic_memory/sync/watch_service.py b/src/basic_memory/sync/watch_service.py index 664069d2..2aeefa4b 100644 --- a/src/basic_memory/sync/watch_service.py +++ b/src/basic_memory/sync/watch_service.py @@ -191,7 +191,7 @@ class WatchService: for p in projects if self.app_config.get_project_mode(p.name) != ProjectMode.CLOUD ] - logger.info(f"Skipping cloud-mode projects in watch cycle: {cloud_projects}") + logger.debug(f"Skipping cloud-mode projects in watch cycle: {cloud_projects}") project_paths = [project.path for project in projects] logger.debug(f"Starting watch cycle for directories: {project_paths}") diff --git a/tests/cli/test_cli_tool_json_output.py b/tests/cli/test_cli_tool_json_output.py index 06bd23b9..7c09e09f 100644 --- a/tests/cli/test_cli_tool_json_output.py +++ b/tests/cli/test_cli_tool_json_output.py @@ -751,3 +751,101 @@ def test_schema_diff_json_output(mock_mcp, mock_config_cls): assert len(data["new_fields"]) == 1 mock_mcp.assert_called_once() assert mock_mcp.call_args.kwargs["output_format"] == "json" + + +# --- list-projects --- + +LIST_PROJECTS_RESULT = { + "projects": [ + { + "name": "main", + "path": "/home/user/notes", + "is_default": True, + "status": "active", + }, + { + "name": "research", + "path": "/home/user/research", + "is_default": False, + "status": "active", + }, + ], + "count": 2, +} + + +@patch( + "basic_memory.cli.commands.tool.mcp_list_projects", + new_callable=AsyncMock, + return_value=LIST_PROJECTS_RESULT, +) +def test_list_projects_json_output(mock_mcp): + """list-projects outputs valid JSON from MCP tool.""" + result = runner.invoke( + cli_app, + ["tool", "list-projects"], + ) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["count"] == 2 + assert len(data["projects"]) == 2 + assert data["projects"][0]["name"] == "main" + mock_mcp.assert_called_once() + assert mock_mcp.call_args.kwargs["output_format"] == "json" + + +# --- list-workspaces --- + +LIST_WORKSPACES_RESULT = { + "workspaces": [ + { + "tenant_id": "tenant-abc", + "name": "My Workspace", + "workspace_type": "personal", + "role": "owner", + "organization_id": None, + "has_active_subscription": True, + }, + ], + "count": 1, +} + + +@patch( + "basic_memory.cli.commands.tool.mcp_list_workspaces", + new_callable=AsyncMock, + return_value=LIST_WORKSPACES_RESULT, +) +def test_list_workspaces_json_output(mock_mcp): + """list-workspaces outputs valid JSON from MCP tool.""" + result = runner.invoke( + cli_app, + ["tool", "list-workspaces"], + ) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["count"] == 1 + assert data["workspaces"][0]["tenant_id"] == "tenant-abc" + assert data["workspaces"][0]["name"] == "My Workspace" + mock_mcp.assert_called_once() + assert mock_mcp.call_args.kwargs["output_format"] == "json" + + +@patch( + "basic_memory.cli.commands.tool.mcp_list_workspaces", + new_callable=AsyncMock, + return_value={"workspaces": [], "count": 0}, +) +def test_list_workspaces_empty(mock_mcp): + """list-workspaces handles empty workspace list.""" + result = runner.invoke( + cli_app, + ["tool", "list-workspaces"], + ) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["workspaces"] == [] + assert data["count"] == 0 diff --git a/tests/cli/test_project_add_with_local_path.py b/tests/cli/test_project_add_with_local_path.py index 1b165a77..1f93cf61 100644 --- a/tests/cli/test_project_add_with_local_path.py +++ b/tests/cli/test_project_add_with_local_path.py @@ -8,6 +8,11 @@ import pytest from typer.testing import CliRunner from basic_memory.cli.app import app +from basic_memory.mcp.clients.project import ProjectClient +from basic_memory.schemas.project_info import ProjectStatusResponse + +# Importing registers project subcommands on the shared app instance. +import basic_memory.cli.commands.project as project_cmd # noqa: F401 @pytest.fixture @@ -45,36 +50,33 @@ def mock_config(tmp_path, monkeypatch): @pytest.fixture def mock_api_client(monkeypatch): """Stub the API client for project add without stdlib mocks.""" - import basic_memory.cli.commands.project as project_cmd @asynccontextmanager async def fake_get_client(): yield object() - class _Resp: - def json(self): - return { - "message": "Project 'test-project' added successfully", - "status": "success", - "default": False, - "old_project": None, - "new_project": { - "id": 1, - "external_id": "12345678-1234-1234-1234-123456789012", - "name": "test-project", - "path": "/test-project", - "is_default": False, - }, - } + _response_data = { + "message": "Project 'test-project' added successfully", + "status": "success", + "default": False, + "old_project": None, + "new_project": { + "id": 1, + "external_id": "12345678-1234-1234-1234-123456789012", + "name": "test-project", + "path": "/test-project", + "is_default": False, + }, + } - calls: list[tuple[str, dict]] = [] + calls: list[dict] = [] - async def fake_call_post(client, path: str, json: dict, **kwargs): - calls.append((path, json)) - return _Resp() + async def fake_create_project(self, project_data): + calls.append(project_data) + return ProjectStatusResponse.model_validate(_response_data) monkeypatch.setattr(project_cmd, "get_client", fake_get_client) - monkeypatch.setattr(project_cmd, "call_post", fake_call_post) + monkeypatch.setattr(ProjectClient, "create_project", fake_create_project) return calls diff --git a/tests/cli/test_project_info_errors.py b/tests/cli/test_project_info_errors.py index 19b00e48..74306df1 100644 --- a/tests/cli/test_project_info_errors.py +++ b/tests/cli/test_project_info_errors.py @@ -9,6 +9,7 @@ from mcp.server.fastmcp.exceptions import ToolError from typer.testing import CliRunner from basic_memory.cli.app import app +from basic_memory.mcp.clients.project import ProjectClient import basic_memory.cli.commands.command_utils as command_utils import basic_memory.cli.commands.project as project_cmd # noqa: F401 @@ -26,12 +27,12 @@ async def test_get_project_info_cloud_config_error_has_clear_message(monkeypatch async def fake_get_active_project(client, project, context): return SimpleNamespace(external_id="proj-123") - async def fake_call_get(client, url): + async def fake_get_info(self, project_external_id): raise ToolError("Internal proxy error: Project 'demo' not found in configuration") monkeypatch.setattr(command_utils, "get_client", fake_get_client) monkeypatch.setattr(command_utils, "get_active_project", fake_get_active_project) - monkeypatch.setattr(command_utils, "call_get", fake_call_get) + monkeypatch.setattr(ProjectClient, "get_info", fake_get_info) with pytest.raises(typer.Exit) as exc: await command_utils.get_project_info("demo") diff --git a/tests/cli/test_project_list_and_ls.py b/tests/cli/test_project_list_and_ls.py index bfbad060..be9f583d 100644 --- a/tests/cli/test_project_list_and_ls.py +++ b/tests/cli/test_project_list_and_ls.py @@ -9,6 +9,8 @@ import pytest from typer.testing import CliRunner from basic_memory.cli.app import app +from basic_memory.mcp.clients.project import ProjectClient +from basic_memory.schemas.project_info import ProjectList # Importing registers project subcommands on the shared app instance. import basic_memory.cli.commands.project as project_cmd # noqa: F401 @@ -43,7 +45,7 @@ def mock_client(monkeypatch): """Mock get_client with a no-op async context manager.""" @asynccontextmanager - async def fake_get_client(): + async def fake_get_client(workspace=None): yield object() monkeypatch.setattr(project_cmd, "get_client", fake_get_client) @@ -105,20 +107,14 @@ def test_project_list_shows_local_cloud_presence_and_routes( "default_project": "alpha", } - class _Resp: - def __init__(self, payload: dict): - self._payload = payload + _original_list_projects = ProjectClient.list_projects - def json(self): - return self._payload - - async def fake_call_get(client, path: str, **kwargs): - assert path == "/v2/projects/" + async def fake_list_projects(self): if os.getenv("BASIC_MEMORY_FORCE_CLOUD", "").lower() in ("true", "1", "yes"): - return _Resp(cloud_payload) - return _Resp(local_payload) + return ProjectList.model_validate(cloud_payload) + return ProjectList.model_validate(local_payload) - monkeypatch.setattr(project_cmd, "call_get", fake_call_get) + monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects) result = runner.invoke(app, ["project", "list"], env={"COLUMNS": "240"}) @@ -170,19 +166,14 @@ def test_project_ls_local_mode_defaults_to_local_route( "default_project": "alpha", } - class _Resp: - def json(self): - return payload - - async def fake_call_get(client, path: str, **kwargs): - assert path == "/v2/projects/" + async def fake_list_projects(self): assert os.getenv("BASIC_MEMORY_FORCE_CLOUD", "").lower() not in ("true", "1", "yes") - return _Resp() + return ProjectList.model_validate(payload) def fail_if_called(*args, **kwargs): raise AssertionError("project_ls should not be used for default local route") - monkeypatch.setattr(project_cmd, "call_get", fake_call_get) + monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects) monkeypatch.setattr(project_cmd, "project_ls", fail_if_called) result = runner.invoke(app, ["project", "ls", "--name", "alpha"], env={"COLUMNS": "200"}) @@ -219,23 +210,18 @@ def test_project_ls_cloud_mode_defaults_to_cloud_route( "default_project": "alpha", } - class _Resp: - def json(self): - return cloud_payload - class _TenantInfo: bucket_name = "tenant-bucket" - async def fake_call_get(client, path: str, **kwargs): - assert path == "/v2/projects/" + async def fake_list_projects(self): # Cloud routing should be active when project mode is cloud assert os.getenv("BASIC_MEMORY_FORCE_CLOUD", "").lower() in ("true", "1", "yes") - return _Resp() + return ProjectList.model_validate(cloud_payload) async def fake_get_mount_info(): return _TenantInfo() - monkeypatch.setattr(project_cmd, "call_get", fake_call_get) + monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects) monkeypatch.setattr(project_cmd, "get_mount_info", fake_get_mount_info) monkeypatch.setattr(project_cmd, "project_ls", lambda *args, **kwargs: [" 42 cloud.md"]) @@ -273,22 +259,17 @@ def test_project_ls_cloud_route_uses_cloud_listing( "default_project": "alpha", } - class _Resp: - def json(self): - return cloud_payload - class _TenantInfo: bucket_name = "tenant-bucket" - async def fake_call_get(client, path: str, **kwargs): - assert path == "/v2/projects/" + async def fake_list_projects(self): assert os.getenv("BASIC_MEMORY_FORCE_CLOUD", "").lower() in ("true", "1", "yes") - return _Resp() + return ProjectList.model_validate(cloud_payload) async def fake_get_mount_info(): return _TenantInfo() - monkeypatch.setattr(project_cmd, "call_get", fake_call_get) + monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects) monkeypatch.setattr(project_cmd, "get_mount_info", fake_get_mount_info) monkeypatch.setattr(project_cmd, "project_ls", lambda *args, **kwargs: [" 42 cloud.md"]) diff --git a/tests/cli/test_project_set_cloud_local.py b/tests/cli/test_project_set_cloud_local.py index 28a92974..b38c7dea 100644 --- a/tests/cli/test_project_set_cloud_local.py +++ b/tests/cli/test_project_set_cloud_local.py @@ -154,3 +154,112 @@ class TestSetLocal: result = runner.invoke(app, ["project", "set-local", "main"]) assert result.exit_code == 0 assert "local mode" in result.stdout.lower() + + def test_set_local_clears_workspace_id(self, runner, mock_config): + """Test that set-local clears workspace_id from the project entry.""" + from basic_memory import config as config_module + + # Manually set workspace_id on the project + config_module._CONFIG_CACHE = None + config_data = json.loads(mock_config.read_text()) + config_data["projects"]["research"]["mode"] = "cloud" + config_data["projects"]["research"]["workspace_id"] = "11111111-1111-1111-1111-111111111111" + mock_config.write_text(json.dumps(config_data, indent=2)) + config_module._CONFIG_CACHE = None + + # Set back to local + result = runner.invoke(app, ["project", "set-local", "research"]) + assert result.exit_code == 0 + + # Verify workspace_id was cleared + config_module._CONFIG_CACHE = None + updated_data = json.loads(mock_config.read_text()) + assert updated_data["projects"]["research"]["workspace_id"] is None + assert updated_data["projects"]["research"]["mode"] == "local" + + +class TestSetCloudWithWorkspace: + """Tests for 'bm project set-cloud --workspace' option.""" + + def test_set_cloud_with_workspace_stores_workspace_id(self, runner, mock_config, monkeypatch): + """Test that --workspace resolves to tenant_id and stores it.""" + from basic_memory import config as config_module + from basic_memory.schemas.cloud import WorkspaceInfo + + config_module._CONFIG_CACHE = None + + async def fake_get_available_workspaces(): + return [ + WorkspaceInfo( + tenant_id="11111111-1111-1111-1111-111111111111", + workspace_type="personal", + name="Personal", + role="owner", + ), + ] + + monkeypatch.setattr( + "basic_memory.mcp.project_context.get_available_workspaces", + fake_get_available_workspaces, + ) + + result = runner.invoke(app, ["project", "set-cloud", "research", "--workspace", "Personal"]) + assert result.exit_code == 0 + assert "cloud mode" in result.stdout.lower() + assert "11111111-1111-1111-1111-111111111111" in result.stdout + + # Verify workspace_id was persisted + config_module._CONFIG_CACHE = None + updated_data = json.loads(mock_config.read_text()) + assert ( + updated_data["projects"]["research"]["workspace_id"] + == "11111111-1111-1111-1111-111111111111" + ) + + def test_set_cloud_with_workspace_not_found(self, runner, mock_config, monkeypatch): + """Test --workspace with unknown workspace name.""" + from basic_memory import config as config_module + from basic_memory.schemas.cloud import WorkspaceInfo + + config_module._CONFIG_CACHE = None + + async def fake_get_available_workspaces(): + return [ + WorkspaceInfo( + tenant_id="11111111-1111-1111-1111-111111111111", + workspace_type="personal", + name="Personal", + role="owner", + ), + ] + + monkeypatch.setattr( + "basic_memory.mcp.project_context.get_available_workspaces", + fake_get_available_workspaces, + ) + + result = runner.invoke( + app, ["project", "set-cloud", "research", "--workspace", "Nonexistent"] + ) + assert result.exit_code == 1 + assert "not found" in result.stdout.lower() + + def test_set_cloud_uses_default_workspace_when_no_flag(self, runner, mock_config, monkeypatch): + """Test that set-cloud uses default_workspace when --workspace is not passed.""" + from basic_memory import config as config_module + + config_module._CONFIG_CACHE = None + + # Set default_workspace in config + config_data = json.loads(mock_config.read_text()) + config_data["default_workspace"] = "global-default-tenant-id" + mock_config.write_text(json.dumps(config_data, indent=2)) + config_module._CONFIG_CACHE = None + + result = runner.invoke(app, ["project", "set-cloud", "research"]) + assert result.exit_code == 0 + + # Verify workspace_id was set from default + config_module._CONFIG_CACHE = None + updated_data = json.loads(mock_config.read_text()) + assert updated_data["projects"]["research"]["workspace_id"] == "global-default-tenant-id" diff --git a/tests/cli/test_workspace_commands.py b/tests/cli/test_workspace_commands.py index ae2b08de..a1285a71 100644 --- a/tests/cli/test_workspace_commands.py +++ b/tests/cli/test_workspace_commands.py @@ -1,12 +1,18 @@ """Tests for workspace CLI commands.""" +import tempfile +from pathlib import Path + import pytest from typer.testing import CliRunner +import basic_memory.config from basic_memory.cli.app import app +from basic_memory.config import BasicMemoryConfig, ConfigManager from basic_memory.schemas.cloud import WorkspaceInfo -# Importing registers workspace commands on the shared app instance. +# Importing the cloud package registers workspace_app on cloud_app. +import basic_memory.cli.commands.cloud as cloud_cmd # noqa: F401 import basic_memory.cli.commands.cloud.workspace as workspace_cmd # noqa: F401 @@ -34,34 +40,14 @@ def test_workspace_list_prints_available_workspaces(runner, monkeypatch): monkeypatch.setattr(workspace_cmd, "get_available_workspaces", fake_get_available_workspaces) - result = runner.invoke(app, ["workspace", "list"]) + result = runner.invoke(app, ["cloud", "workspace", "list"]) assert result.exit_code == 0 assert "Available Workspaces" in result.stdout assert "Personal" in result.stdout assert "Team" in result.stdout - assert "11111111-1111-1111-1111-111111111111" in result.stdout - - -def test_workspaces_alias_matches_workspace_list_output(runner, monkeypatch): - async def fake_get_available_workspaces(context=None): - return [ - WorkspaceInfo( - tenant_id="11111111-1111-1111-1111-111111111111", - workspace_type="personal", - name="Personal", - role="owner", - ) - ] - - monkeypatch.setattr(workspace_cmd, "get_available_workspaces", fake_get_available_workspaces) - - list_result = runner.invoke(app, ["workspace", "list"]) - alias_result = runner.invoke(app, ["workspaces"]) - - assert list_result.exit_code == 0 - assert alias_result.exit_code == 0 - assert list_result.stdout == alias_result.stdout + # Tenant ID may be truncated by Rich table rendering + assert "11111111" in result.stdout def test_workspace_list_requires_oauth_login_message(runner, monkeypatch): @@ -70,8 +56,125 @@ def test_workspace_list_requires_oauth_login_message(runner, monkeypatch): monkeypatch.setattr(workspace_cmd, "get_available_workspaces", fail_get_available_workspaces) - result = runner.invoke(app, ["workspace", "list"]) + result = runner.invoke(app, ["cloud", "workspace", "list"]) assert result.exit_code == 1 assert "Workspace discovery requires OAuth login" in result.stdout assert "bm cloud login" in result.stdout + + +class TestWorkspaceSetDefault: + """Tests for 'bm cloud workspace set-default' command.""" + + @pytest.fixture(autouse=True) + def _setup_config(self, monkeypatch): + """Set up a temp config for each test.""" + self.temp_dir = tempfile.mkdtemp() + temp_path = Path(self.temp_dir) + config_dir = temp_path / ".basic-memory" + config_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(temp_path)) + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir)) + basic_memory.config._CONFIG_CACHE = None + + config_manager = ConfigManager() + test_config = BasicMemoryConfig( + projects={"main": {"path": str(temp_path / "main")}}, + ) + config_manager.save_config(test_config) + + def test_set_default_workspace_by_name(self, runner, monkeypatch): + async def fake_get_available_workspaces(context=None): + return [ + WorkspaceInfo( + tenant_id="11111111-1111-1111-1111-111111111111", + workspace_type="personal", + name="Personal", + role="owner", + ), + ] + + monkeypatch.setattr( + workspace_cmd, "get_available_workspaces", fake_get_available_workspaces + ) + + result = runner.invoke(app, ["cloud", "workspace", "set-default", "Personal"]) + + assert result.exit_code == 0 + assert "Default workspace set" in result.stdout + assert "Personal" in result.stdout + + # Verify config was updated + basic_memory.config._CONFIG_CACHE = None + config = ConfigManager().config + assert config.default_workspace == "11111111-1111-1111-1111-111111111111" + + def test_set_default_workspace_by_tenant_id(self, runner, monkeypatch): + async def fake_get_available_workspaces(context=None): + return [ + WorkspaceInfo( + tenant_id="22222222-2222-2222-2222-222222222222", + workspace_type="organization", + name="Team", + role="editor", + ), + ] + + monkeypatch.setattr( + workspace_cmd, "get_available_workspaces", fake_get_available_workspaces + ) + + result = runner.invoke( + app, ["cloud", "workspace", "set-default", "22222222-2222-2222-2222-222222222222"] + ) + + assert result.exit_code == 0 + assert "Default workspace set" in result.stdout + + def test_set_default_workspace_not_found(self, runner, monkeypatch): + async def fake_get_available_workspaces(context=None): + return [ + WorkspaceInfo( + tenant_id="11111111-1111-1111-1111-111111111111", + workspace_type="personal", + name="Personal", + role="owner", + ), + ] + + monkeypatch.setattr( + workspace_cmd, "get_available_workspaces", fake_get_available_workspaces + ) + + result = runner.invoke(app, ["cloud", "workspace", "set-default", "Nonexistent"]) + + assert result.exit_code == 1 + assert "not found" in result.stdout + + def test_set_default_workspace_no_workspaces(self, runner, monkeypatch): + async def fake_get_available_workspaces(context=None): + return [] + + monkeypatch.setattr( + workspace_cmd, "get_available_workspaces", fake_get_available_workspaces + ) + + result = runner.invoke(app, ["cloud", "workspace", "set-default", "Personal"]) + + assert result.exit_code == 1 + assert "No accessible workspaces" in result.stdout + + def test_set_default_workspace_oauth_error(self, runner, monkeypatch): + async def fail_get_available_workspaces(context=None): # pragma: no cover + raise RuntimeError( + "Workspace discovery requires OAuth login. Run 'bm cloud login' first." + ) + + monkeypatch.setattr( + workspace_cmd, "get_available_workspaces", fail_get_available_workspaces + ) + + result = runner.invoke(app, ["cloud", "workspace", "set-default", "Personal"]) + + assert result.exit_code == 1 + assert "OAuth login" in result.stdout diff --git a/tests/mcp/test_project_context.py b/tests/mcp/test_project_context.py index 771b7299..89bb7fa7 100644 --- a/tests/mcp/test_project_context.py +++ b/tests/mcp/test_project_context.py @@ -274,3 +274,158 @@ async def test_get_project_client_rejects_workspace_for_local_project(): ): async with get_project_client(project="main", workspace="tenant-123"): pass + + +class TestDetectProjectFromUrlPrefix: + """Test detect_project_from_url_prefix for URL-based project detection.""" + + def test_detects_project_from_memory_url(self, config_manager): + from basic_memory.mcp.project_context import detect_project_from_url_prefix + + config = config_manager.load_config() + # The config has "test-project" from the conftest fixture + result = detect_project_from_url_prefix("memory://test-project/some-note", config) + assert result == "test-project" + + def test_detects_project_from_plain_path(self, config_manager): + from basic_memory.mcp.project_context import detect_project_from_url_prefix + + config = config_manager.load_config() + result = detect_project_from_url_prefix("test-project/some-note", config) + assert result == "test-project" + + def test_returns_none_for_unknown_prefix(self, config_manager): + from basic_memory.mcp.project_context import detect_project_from_url_prefix + + config = config_manager.load_config() + result = detect_project_from_url_prefix("memory://unknown-project/note", config) + assert result is None + + def test_returns_none_for_no_slash(self, config_manager): + from basic_memory.mcp.project_context import detect_project_from_url_prefix + + config = config_manager.load_config() + result = detect_project_from_url_prefix("memory://single-segment", config) + assert result is None + + def test_returns_none_for_wildcard_prefix(self, config_manager): + from basic_memory.mcp.project_context import detect_project_from_url_prefix + + config = config_manager.load_config() + result = detect_project_from_url_prefix("memory://*/notes", config) + assert result is None + + def test_matches_case_insensitive_via_permalink(self, config_manager): + from basic_memory.mcp.project_context import detect_project_from_url_prefix + from basic_memory.config import ProjectEntry + + config = config_manager.load_config() + (config_manager.config_dir.parent / "My Research").mkdir(parents=True, exist_ok=True) + config.projects["My Research"] = ProjectEntry( + path=str(config_manager.config_dir.parent / "My Research") + ) + config_manager.save_config(config) + + result = detect_project_from_url_prefix("memory://my-research/notes", config) + assert result == "My Research" + + +class TestGetProjectClientRoutingOrder: + """Test that get_project_client respects explicit routing before workspace resolution.""" + + @pytest.mark.asyncio + async def test_local_flag_skips_workspace_resolution(self, config_manager, monkeypatch): + """--local flag should never trigger workspace resolution, even for cloud projects.""" + from basic_memory.mcp.project_context import get_project_client + from basic_memory.config import ProjectEntry, ProjectMode + + config = config_manager.load_config() + config.projects["cloud-proj"] = ProjectEntry( + path=str(config_manager.config_dir.parent / "cloud-proj"), + mode=ProjectMode.CLOUD, + ) + config_manager.save_config(config) + + # Set explicit local routing + monkeypatch.setenv("BASIC_MEMORY_EXPLICIT_ROUTING", "true") + monkeypatch.setenv("BASIC_MEMORY_FORCE_LOCAL", "true") + monkeypatch.delenv("BASIC_MEMORY_FORCE_CLOUD", raising=False) + + # Should not raise "Multiple workspaces" — it should skip workspace entirely + # It will fail at project validation (no API running), which proves routing worked + with pytest.raises(Exception) as exc_info: + async with get_project_client(project="cloud-proj"): + pass + + # The error should NOT be about workspaces + assert "workspace" not in str(exc_info.value).lower() + + @pytest.mark.asyncio + async def test_cloud_project_uses_per_project_workspace_id(self, config_manager, monkeypatch): + """Cloud project with workspace_id in config should use it without network lookup.""" + from basic_memory.mcp.project_context import get_project_client + from basic_memory.config import ProjectEntry, ProjectMode + + config = config_manager.load_config() + config.projects["cloud-proj"] = ProjectEntry( + path=str(config_manager.config_dir.parent / "cloud-proj"), + mode=ProjectMode.CLOUD, + workspace_id="per-project-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 + async def fail_if_called(**kwargs): # pragma: no cover + raise AssertionError( + "resolve_workspace_parameter should not be called when workspace_id 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 workspace + # resolution was skipped + with pytest.raises(Exception) as exc_info: + async with get_project_client(project="cloud-proj"): + pass + + # Should not be a workspace resolution error + 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_project_uses_default_workspace(self, config_manager, monkeypatch): + """Cloud project without workspace_id should fall back to default_workspace.""" + from basic_memory.mcp.project_context import get_project_client + from basic_memory.config import ProjectEntry, ProjectMode + + config = config_manager.load_config() + config.projects["cloud-proj"] = ProjectEntry( + path=str(config_manager.config_dir.parent / "cloud-proj"), + mode=ProjectMode.CLOUD, + ) + 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 + 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, but proves workspace resolution was skipped + with pytest.raises(Exception) as exc_info: + async with get_project_client(project="cloud-proj"): + pass + + error_msg = str(exc_info.value).lower() + assert "resolve_workspace_parameter should not be called" not in error_msg diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 3dc35feb..dfb74373 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -38,7 +38,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = { "fetch": ["id"], "list_directory": ["dir_name", "depth", "file_name_glob", "project", "workspace"], "list_memory_projects": ["output_format"], - "list_workspaces": [], + "list_workspaces": ["output_format"], "move_note": [ "identifier", "destination_path", diff --git a/tests/mcp/test_tool_project_management.py b/tests/mcp/test_tool_project_management.py index b866966a..dd98e22a 100644 --- a/tests/mcp/test_tool_project_management.py +++ b/tests/mcp/test_tool_project_management.py @@ -1,11 +1,46 @@ """Tests for MCP project management tools.""" +from unittest.mock import AsyncMock, patch + import pytest from sqlalchemy import select from basic_memory import db from basic_memory.mcp.tools import list_memory_projects, create_memory_project, delete_project +from basic_memory.mcp.tools.project_management import _merge_projects from basic_memory.models.project import Project +from basic_memory.schemas.project_info import ProjectItem, ProjectList + + +# --- Helpers --- + + +def _make_project( + name: str, + path: str, + *, + id: int = 1, + external_id: str = "00000000-0000-0000-0000-000000000001", + is_default: bool = False, + display_name: str | None = None, + is_private: bool = False, +) -> ProjectItem: + return ProjectItem( + id=id, + external_id=external_id, + name=name, + path=path, + is_default=is_default, + display_name=display_name, + is_private=is_private, + ) + + +def _make_list(projects: list[ProjectItem], default: str | None = None) -> ProjectList: + return ProjectList(projects=projects, default_project=default) + + +# --- Existing tests (updated for source labels) --- @pytest.mark.asyncio @@ -18,31 +53,21 @@ async def test_list_memory_projects_unconstrained(app, test_project): @pytest.mark.asyncio async def test_list_memory_projects_shows_display_name(app, client, test_project): """When a project has display_name set, list_memory_projects shows 'display_name (name)' format.""" - # Inject display_name into the project list response by patching the API response. - # In production, the cloud proxy adds display_name to the JSON before deserialization. - from unittest.mock import AsyncMock, patch - from basic_memory.schemas.project_info import ProjectItem, ProjectList - - mock_project = ProjectItem( + mock_project = _make_project( + "private-fb83af23", + "/tmp/private", id=1, - external_id="00000000-0000-0000-0000-000000000001", - name="private-fb83af23", - path="/tmp/private", - is_default=False, display_name="My Notes", is_private=True, ) - regular_project = ProjectItem( + regular_project = _make_project( + "main", + "/tmp/main", id=2, external_id="00000000-0000-0000-0000-000000000002", - name="main", - path="/tmp/main", is_default=True, ) - mock_list = ProjectList( - projects=[regular_project, mock_project], - default_project="main", - ) + mock_list = _make_list([regular_project, mock_project], default="main") with patch( "basic_memory.mcp.clients.project.ProjectClient.list_projects", @@ -51,26 +76,17 @@ async def test_list_memory_projects_shows_display_name(app, client, test_project ): result = await list_memory_projects() - # Regular project shows just the name - assert "• main\n" in result - # Private project shows display_name with slug in parentheses - assert "• My Notes (private-fb83af23)" in result + # Regular project shows name with source label + assert "• main (local)" in result + # Private project shows display_name with slug in parentheses, then source + assert "• My Notes (private-fb83af23) (local)" in result @pytest.mark.asyncio async def test_list_memory_projects_no_display_name_shows_name_only(app, client, test_project): """When a project has no display_name, list_memory_projects shows just the name.""" - from unittest.mock import AsyncMock, patch - from basic_memory.schemas.project_info import ProjectItem, ProjectList - - project = ProjectItem( - id=1, - external_id="00000000-0000-0000-0000-000000000001", - name="my-project", - path="/tmp/my-project", - is_default=True, - ) - mock_list = ProjectList(projects=[project], default_project="my-project") + project = _make_project("my-project", "/tmp/my-project", is_default=True) + mock_list = _make_list([project], default="my-project") with patch( "basic_memory.mcp.clients.project.ProjectClient.list_projects", @@ -79,9 +95,7 @@ async def test_list_memory_projects_no_display_name_shows_name_only(app, client, ): result = await list_memory_projects() - assert "• my-project\n" in result - # Should NOT have parenthetical format - assert "(" not in result.split("• my-project")[1].split("\n")[0] + assert "• my-project (local)" in result @pytest.mark.asyncio @@ -116,3 +130,381 @@ async def test_create_and_delete_project_and_name_match_branch( delete_result = await delete_project("My Project") assert delete_result.startswith("✓") + + +# --- Cloud merge tests --- + + +@pytest.mark.asyncio +async def test_list_memory_projects_local_and_cloud_merge(app, test_project): + """When cloud credentials exist, projects from both sources are merged by permalink.""" + local_main = _make_project("main", "/home/user/basic-memory", is_default=True) + local_specs = _make_project( + "specs", "/home/user/specs", id=2, external_id="00000000-0000-0000-0000-000000000002" + ) + local_list = _make_list([local_main, local_specs], default="main") + + cloud_main = _make_project("main", "/main", id=10, external_id="cloud-main-uuid") + cloud_llc = _make_project( + "basic-memory-llc", "/basic-memory-llc", id=11, external_id="cloud-llc-uuid" + ) + cloud_list = _make_list([cloud_main, cloud_llc], default="main") + + with ( + patch( + "basic_memory.mcp.clients.project.ProjectClient.list_projects", + new_callable=AsyncMock, + return_value=local_list, + ), + patch( + "basic_memory.mcp.tools.project_management.has_cloud_credentials", + return_value=True, + ), + patch( + "basic_memory.mcp.tools.project_management._fetch_cloud_projects", + new_callable=AsyncMock, + return_value=cloud_list, + ), + ): + result = await list_memory_projects() + + # Both local+cloud project shows merged source + assert "• main (local+cloud)" in result + # Local-only project + assert "• specs (local)" in result + # Cloud-only project + assert "• basic-memory-llc (cloud)" in result + + +@pytest.mark.asyncio +async def test_list_memory_projects_no_cloud_credentials(app, test_project): + """When no cloud credentials exist, only local projects are shown.""" + with patch( + "basic_memory.mcp.tools.project_management.has_cloud_credentials", + return_value=False, + ): + result = await list_memory_projects() + + assert "Available projects:" in result + assert f"• {test_project.name} (local)" in result + # No cloud source labels + assert "cloud)" not in result + + +@pytest.mark.asyncio +async def test_list_memory_projects_cloud_failure_graceful(app, test_project): + """When cloud fetch fails, local projects are still returned.""" + with ( + patch( + "basic_memory.mcp.tools.project_management.has_cloud_credentials", + return_value=True, + ), + patch( + "basic_memory.mcp.tools.project_management._fetch_cloud_projects", + new_callable=AsyncMock, + return_value=None, + ), + ): + result = await list_memory_projects() + + assert "Available projects:" in result + assert f"• {test_project.name} (local)" in result + + +@pytest.mark.asyncio +async def test_list_memory_projects_factory_mode(app, test_project): + """In factory mode (cloud app), only the factory client is used — no cloud merge.""" + factory_project = _make_project("cloud-proj", "/cloud-proj", is_default=True) + factory_list = _make_list([factory_project], default="cloud-proj") + + with ( + patch( + "basic_memory.mcp.tools.project_management.is_factory_mode", + return_value=True, + ), + patch( + "basic_memory.mcp.clients.project.ProjectClient.list_projects", + new_callable=AsyncMock, + return_value=factory_list, + ), + ): + result = await list_memory_projects() + + assert "• cloud-proj (local)" in result + # has_cloud_credentials should not be called in factory mode + # (no cloud merge attempt) + + +@pytest.mark.asyncio +async def test_list_memory_projects_json_with_cloud(app, test_project): + """JSON output includes local_path, cloud_path, and source fields.""" + local_main = _make_project("main", "/home/user/basic-memory", is_default=True) + local_list = _make_list([local_main], default="main") + + cloud_main = _make_project("main", "/main", id=10, external_id="cloud-main-uuid") + cloud_only = _make_project("cloud-only", "/cloud-only", id=11, external_id="cloud-only-uuid") + cloud_list = _make_list([cloud_main, cloud_only], default="main") + + with ( + patch( + "basic_memory.mcp.clients.project.ProjectClient.list_projects", + new_callable=AsyncMock, + return_value=local_list, + ), + patch( + "basic_memory.mcp.tools.project_management.has_cloud_credentials", + return_value=True, + ), + patch( + "basic_memory.mcp.tools.project_management._fetch_cloud_projects", + new_callable=AsyncMock, + return_value=cloud_list, + ), + ): + result = await list_memory_projects(output_format="json") + + assert isinstance(result, dict) + projects = result["projects"] + assert result["default_project"] == "main" + + # Find projects by name + by_name = {p["name"]: p for p in projects} + + # main: local+cloud + main_proj = by_name["main"] + assert main_proj["source"] == "local+cloud" + assert main_proj["local_path"] == "/home/user/basic-memory" + assert main_proj["cloud_path"] == "/main" + # Backward-compat: path prefers local + assert main_proj["path"] == "/home/user/basic-memory" + assert main_proj["is_default"] is True + + # cloud-only + cloud_proj = by_name["cloud-only"] + assert cloud_proj["source"] == "cloud" + assert cloud_proj["local_path"] is None + assert cloud_proj["cloud_path"] == "/cloud-only" + assert cloud_proj["path"] == "/cloud-only" + + +# --- Unit test for _merge_projects --- + + +def test_merge_projects_empty(): + """Merging two None lists produces an empty result.""" + assert _merge_projects(None, None) == [] + + +def test_merge_projects_local_only(): + """Merging with only local projects sets source to 'local', workspace fields are None.""" + local_list = _make_list( + [_make_project("alpha", "/alpha"), _make_project("beta", "/beta", id=2)], + default="alpha", + ) + merged = _merge_projects(local_list, None) + assert len(merged) == 2 + assert all(p["source"] == "local" for p in merged) + # Sorted by permalink + assert merged[0]["name"] == "alpha" + assert merged[1]["name"] == "beta" + # Local-only projects have no workspace info + assert all(p["workspace_name"] is None for p in merged) + assert all(p["workspace_type"] is None for p in merged) + assert all(p["workspace_tenant_id"] is None for p in merged) + + +def test_merge_projects_cloud_only(): + """Merging with only cloud projects sets source to 'cloud' with workspace info.""" + cloud_list = _make_list( + [_make_project("gamma", "/gamma")], + default="gamma", + ) + merged = _merge_projects( + None, + cloud_list, + cloud_workspace_name="Personal", + cloud_workspace_type="personal", + cloud_workspace_tenant_id="tenant-123", + ) + assert len(merged) == 1 + assert merged[0]["source"] == "cloud" + assert merged[0]["local_path"] is None + assert merged[0]["cloud_path"] == "/gamma" + assert merged[0]["workspace_name"] == "Personal" + assert merged[0]["workspace_type"] == "personal" + assert merged[0]["workspace_tenant_id"] == "tenant-123" + + +def test_merge_projects_overlap(): + """Overlapping projects carry workspace info from cloud side.""" + local_list = _make_list([_make_project("shared", "/local/shared")]) + cloud_list = _make_list([_make_project("shared", "/cloud/shared")]) + merged = _merge_projects( + local_list, + cloud_list, + cloud_workspace_name="Acme Corp", + cloud_workspace_type="organization", + cloud_workspace_tenant_id="org-456", + ) + assert len(merged) == 1 + assert merged[0]["source"] == "local+cloud" + assert merged[0]["local_path"] == "/local/shared" + assert merged[0]["cloud_path"] == "/cloud/shared" + # Backward compat: path prefers local + assert merged[0]["path"] == "/local/shared" + # Cloud workspace info is present because the project has a cloud source + assert merged[0]["workspace_name"] == "Acme Corp" + assert merged[0]["workspace_type"] == "organization" + assert merged[0]["workspace_tenant_id"] == "org-456" + + +# --- Workspace passthrough tests --- + + +def _make_workspace( + tenant_id: str, name: str, workspace_type: str = "personal", role: str = "owner" +): + """Create a WorkspaceInfo for testing.""" + from basic_memory.schemas.cloud import WorkspaceInfo + + return WorkspaceInfo( + tenant_id=tenant_id, + name=name, + workspace_type=workspace_type, + role=role, + has_active_subscription=True, + ) + + +@pytest.mark.asyncio +async def test_list_memory_projects_passes_explicit_workspace(app, test_project): + """Explicit workspace param is forwarded to _fetch_cloud_projects.""" + cloud_list = _make_list([_make_project("cloud-proj", "/cloud-proj")]) + + with ( + patch( + "basic_memory.mcp.tools.project_management.has_cloud_credentials", + return_value=True, + ), + patch( + "basic_memory.mcp.tools.project_management._fetch_cloud_projects", + new_callable=AsyncMock, + return_value=cloud_list, + ) as mock_fetch, + patch( + "basic_memory.mcp.project_context.get_available_workspaces", + new_callable=AsyncMock, + return_value=[_make_workspace("my-org-tenant-id", "My Org", "organization")], + ), + ): + await list_memory_projects(workspace="my-org-tenant-id") + + mock_fetch.assert_awaited_once_with("my-org-tenant-id", None) + + +@pytest.mark.asyncio +async def test_list_memory_projects_falls_back_to_config_workspace(app, test_project): + """When no explicit workspace is given, config.default_workspace is used.""" + cloud_list = _make_list([_make_project("cloud-proj", "/cloud-proj")]) + + with ( + patch("basic_memory.mcp.tools.project_management.ConfigManager") as mock_cm_cls, + patch( + "basic_memory.mcp.tools.project_management.has_cloud_credentials", + return_value=True, + ), + patch( + "basic_memory.mcp.tools.project_management._fetch_cloud_projects", + new_callable=AsyncMock, + return_value=cloud_list, + ) as mock_fetch, + patch( + "basic_memory.mcp.project_context.get_available_workspaces", + new_callable=AsyncMock, + return_value=[_make_workspace("config-default-ws", "Default WS")], + ), + ): + mock_config = mock_cm_cls.return_value.config + mock_config.default_workspace = "config-default-ws" + await list_memory_projects() + + mock_fetch.assert_awaited_once_with("config-default-ws", None) + + +@pytest.mark.asyncio +async def test_list_memory_projects_explicit_workspace_overrides_config(app, test_project): + """Explicit workspace takes precedence over config.default_workspace.""" + cloud_list = _make_list([_make_project("cloud-proj", "/cloud-proj")]) + + with ( + patch("basic_memory.mcp.tools.project_management.ConfigManager") as mock_cm_cls, + patch( + "basic_memory.mcp.tools.project_management.has_cloud_credentials", + return_value=True, + ), + patch( + "basic_memory.mcp.tools.project_management._fetch_cloud_projects", + new_callable=AsyncMock, + return_value=cloud_list, + ) as mock_fetch, + patch( + "basic_memory.mcp.project_context.get_available_workspaces", + new_callable=AsyncMock, + return_value=[_make_workspace("explicit-ws", "Explicit WS", "organization")], + ), + ): + mock_config = mock_cm_cls.return_value.config + mock_config.default_workspace = "config-default-ws" + await list_memory_projects(workspace="explicit-ws") + + # Explicit workspace wins over config default + mock_fetch.assert_awaited_once_with("explicit-ws", None) + + +@pytest.mark.asyncio +async def test_list_memory_projects_json_includes_workspace_info(app, test_project): + """JSON output includes workspace_name, workspace_type, workspace_tenant_id for cloud projects.""" + local_proj = _make_project("local-only", "/local/path", is_default=True) + local_list = _make_list([local_proj], default="local-only") + + cloud_proj = _make_project("cloud-proj", "/cloud/path", id=10, external_id="cloud-uuid") + cloud_list = _make_list([cloud_proj]) + + ws = _make_workspace("org-tenant-abc", "Acme Corp", "organization") + + with ( + patch( + "basic_memory.mcp.clients.project.ProjectClient.list_projects", + new_callable=AsyncMock, + return_value=local_list, + ), + patch( + "basic_memory.mcp.tools.project_management.has_cloud_credentials", + return_value=True, + ), + patch( + "basic_memory.mcp.tools.project_management._fetch_cloud_projects", + new_callable=AsyncMock, + return_value=cloud_list, + ), + patch( + "basic_memory.mcp.project_context.get_available_workspaces", + new_callable=AsyncMock, + return_value=[ws], + ), + ): + result = await list_memory_projects(output_format="json", workspace="org-tenant-abc") + + by_name = {p["name"]: p for p in result["projects"]} + + # Cloud project carries workspace info + cloud = by_name["cloud-proj"] + assert cloud["workspace_name"] == "Acme Corp" + assert cloud["workspace_type"] == "organization" + assert cloud["workspace_tenant_id"] == "org-tenant-abc" + + # Local-only project has no workspace info + local = by_name["local-only"] + assert local["workspace_name"] is None + assert local["workspace_type"] is None + assert local["workspace_tenant_id"] is None diff --git a/tests/test_config.py b/tests/test_config.py index 4fb5bc0d..15ad5908 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -934,3 +934,56 @@ class TestProjectMode: modes_by_name = {p.name: p.mode for p in project_list} assert modes_by_name["main"] == ProjectMode.LOCAL assert modes_by_name["research"] == ProjectMode.CLOUD + + def test_workspace_id_defaults_to_none(self): + """Test that workspace_id on ProjectEntry defaults to None.""" + entry = ProjectEntry(path="/tmp/test") + assert entry.workspace_id is None + + def test_workspace_id_can_be_set(self): + """Test that workspace_id can be configured on ProjectEntry.""" + entry = ProjectEntry( + path="/tmp/test", + workspace_id="11111111-1111-1111-1111-111111111111", + ) + assert entry.workspace_id == "11111111-1111-1111-1111-111111111111" + + def test_default_workspace_defaults_to_none(self): + """Test that default_workspace on BasicMemoryConfig defaults to None.""" + config = BasicMemoryConfig() + assert config.default_workspace is None + + def test_default_workspace_can_be_set(self): + """Test that default_workspace can be configured.""" + config = BasicMemoryConfig(default_workspace="22222222-2222-2222-2222-222222222222") + assert config.default_workspace == "22222222-2222-2222-2222-222222222222" + + def test_workspace_fields_round_trip(self): + """Test that workspace fields survive save/load cycle.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + config_manager = ConfigManager() + config_manager.config_dir = temp_path / "basic-memory" + config_manager.config_file = config_manager.config_dir / "config.json" + config_manager.config_dir.mkdir(parents=True, exist_ok=True) + + test_config = BasicMemoryConfig( + projects={ + "main": {"path": str(temp_path / "main")}, + "research": { + "path": str(temp_path / "research"), + "mode": "cloud", + "workspace_id": "11111111-1111-1111-1111-111111111111", + }, + }, + default_workspace="22222222-2222-2222-2222-222222222222", + ) + config_manager.save_config(test_config) + + loaded = config_manager.load_config() + assert loaded.default_workspace == "22222222-2222-2222-2222-222222222222" + assert ( + loaded.projects["research"].workspace_id == "11111111-1111-1111-1111-111111111111" + ) + assert loaded.projects["main"].workspace_id is None