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