mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix: basic-memory project list does not list projects from all workspaces (#822)
Signed-off-by: Drew Cain <groksrc@gmail.com>
This commit is contained in:
@@ -6,10 +6,11 @@ from rich.table import Table
|
||||
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.project_context import (
|
||||
_workspace_choices,
|
||||
_workspace_matches_identifier,
|
||||
get_available_workspaces,
|
||||
from basic_memory.mcp.project_context import get_available_workspaces
|
||||
from basic_memory.schemas.cloud import (
|
||||
format_workspace_choices,
|
||||
format_workspace_selection_choices,
|
||||
workspace_matches_identifier,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
@@ -62,7 +63,10 @@ def list_workspaces() -> None:
|
||||
|
||||
@workspace_app.command("set-default")
|
||||
def set_default_workspace(
|
||||
identifier: str = typer.Argument(..., help="Workspace name or tenant_id to set as default"),
|
||||
identifier: str = typer.Argument(
|
||||
...,
|
||||
help="Workspace name, slug, type, or tenant_id to set as default",
|
||||
),
|
||||
) -> None:
|
||||
"""Set the default cloud workspace.
|
||||
|
||||
@@ -71,6 +75,7 @@ def set_default_workspace(
|
||||
|
||||
Examples:
|
||||
bm cloud workspace set-default Personal
|
||||
bm cloud workspace set-default organization
|
||||
bm cloud workspace set-default 11111111-1111-1111-1111-111111111111
|
||||
"""
|
||||
|
||||
@@ -87,19 +92,21 @@ def set_default_workspace(
|
||||
console.print("[yellow]No accessible workspaces found.[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, identifier)]
|
||||
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]")
|
||||
console.print(f"[dim]Available:\n{format_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]"
|
||||
f"[red]Error: Workspace '{identifier}' matches multiple workspaces.[/red]"
|
||||
)
|
||||
console.print(
|
||||
"[dim]Choose one of these matching workspaces by slug:\n"
|
||||
f"{format_workspace_selection_choices(matches)}[/dim]"
|
||||
)
|
||||
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
selected = matches[0]
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console, Group
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
@@ -33,6 +34,10 @@ from basic_memory.schemas.cloud import (
|
||||
CloudProjectIndexStatus,
|
||||
CloudTenantIndexStatusResponse,
|
||||
ProjectVisibility,
|
||||
WorkspaceInfo,
|
||||
format_workspace_choices,
|
||||
format_workspace_selection_choices,
|
||||
workspace_matches_identifier,
|
||||
)
|
||||
from basic_memory.schemas.project_info import ProjectItem, ProjectList
|
||||
from basic_memory.utils import generate_permalink, normalize_project_path
|
||||
@@ -281,27 +286,27 @@ def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility:
|
||||
|
||||
|
||||
def _resolve_workspace_id(config, workspace: str | None) -> str | None:
|
||||
"""Resolve a workspace name or tenant_id to a tenant_id."""
|
||||
"""Resolve a workspace name, slug, type, or tenant_id to a tenant_id."""
|
||||
from basic_memory.mcp.project_context import (
|
||||
_workspace_choices,
|
||||
_workspace_matches_identifier,
|
||||
get_available_workspaces,
|
||||
)
|
||||
|
||||
if workspace is not None:
|
||||
workspaces = run_with_cleanup(get_available_workspaces())
|
||||
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, workspace)]
|
||||
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]")
|
||||
console.print(f"[dim]Available:\n{format_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]"
|
||||
f"[red]Error: Workspace '{workspace}' matches multiple workspaces.[/red]"
|
||||
)
|
||||
console.print(
|
||||
"[dim]Choose one of these matching workspaces by slug:\n"
|
||||
f"{format_workspace_selection_choices(matches)}[/dim]"
|
||||
)
|
||||
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
|
||||
raise typer.Exit(1)
|
||||
return matches[0].tenant_id
|
||||
|
||||
@@ -312,9 +317,9 @@ def _resolve_workspace_id(config, workspace: str | None) -> str | None:
|
||||
workspaces = run_with_cleanup(get_available_workspaces())
|
||||
if len(workspaces) == 1:
|
||||
return workspaces[0].tenant_id
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
# Workspace resolution is optional until a command needs a specific tenant.
|
||||
pass
|
||||
logger.debug("Workspace resolution failed: {}", exc)
|
||||
|
||||
return None
|
||||
|
||||
@@ -323,7 +328,11 @@ def _resolve_workspace_id(config, workspace: str | None) -> str | None:
|
||||
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"),
|
||||
workspace: str = typer.Option(
|
||||
None,
|
||||
"--workspace",
|
||||
help="Cloud workspace name, slug, type, or tenant_id",
|
||||
),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
) -> None:
|
||||
"""List Basic Memory projects from local and (when available) cloud."""
|
||||
@@ -339,17 +348,90 @@ def list_projects(
|
||||
|
||||
try:
|
||||
config = ConfigManager().config
|
||||
# Use explicit workspace, fall back to config default
|
||||
effective_workspace = workspace or config.default_workspace
|
||||
workspace_filter = workspace
|
||||
workspace_filter_requested = workspace_filter is not None
|
||||
|
||||
local_result: ProjectList | None = None
|
||||
cloud_result: ProjectList | None = None
|
||||
cloud_results: list[tuple[WorkspaceInfo | None, ProjectList]] = []
|
||||
available_cloud_workspaces: list[WorkspaceInfo] = []
|
||||
cloud_error: Exception | None = None
|
||||
cloud_workspace_error: Exception | None = None
|
||||
failed_cloud_workspaces: list[tuple[WorkspaceInfo, Exception]] = []
|
||||
|
||||
def _fetch_cloud_workspace_results() -> tuple[
|
||||
list[tuple[WorkspaceInfo | None, ProjectList]],
|
||||
list[WorkspaceInfo],
|
||||
Exception | None,
|
||||
list[tuple[WorkspaceInfo, Exception]],
|
||||
]:
|
||||
from basic_memory.mcp.project_context import (
|
||||
get_available_workspaces,
|
||||
)
|
||||
|
||||
try:
|
||||
workspaces = run_with_cleanup(get_available_workspaces())
|
||||
except Exception as exc:
|
||||
fallback_workspace = workspace_filter or config.default_workspace
|
||||
return (
|
||||
[(None, run_with_cleanup(_list_projects(fallback_workspace)))],
|
||||
[],
|
||||
exc,
|
||||
[],
|
||||
)
|
||||
|
||||
selected_workspaces = workspaces
|
||||
if workspace_filter is not None:
|
||||
matches = [
|
||||
ws for ws in workspaces if workspace_matches_identifier(ws, workspace_filter)
|
||||
]
|
||||
if not matches:
|
||||
console.print(f"[red]Error: Workspace '{workspace_filter}' not found[/red]")
|
||||
if workspaces:
|
||||
console.print(
|
||||
f"[dim]Available:\n{format_workspace_choices(workspaces)}[/dim]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if len(matches) > 1:
|
||||
console.print(
|
||||
f"[red]Error: Workspace '{workspace_filter}' matches multiple workspaces.[/red]"
|
||||
)
|
||||
console.print(
|
||||
"[dim]Choose one of these matching workspaces by slug:\n"
|
||||
f"{format_workspace_selection_choices(matches)}[/dim]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
selected_workspaces = matches
|
||||
|
||||
if not selected_workspaces:
|
||||
return [], workspaces, None, []
|
||||
|
||||
results: list[tuple[WorkspaceInfo | None, ProjectList]] = []
|
||||
failed_workspaces: list[tuple[WorkspaceInfo, Exception]] = []
|
||||
for cloud_workspace in selected_workspaces:
|
||||
try:
|
||||
results.append(
|
||||
(
|
||||
cloud_workspace,
|
||||
run_with_cleanup(_list_projects(cloud_workspace.tenant_id)),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
failed_workspaces.append((cloud_workspace, exc))
|
||||
|
||||
if not results and failed_workspaces:
|
||||
raise failed_workspaces[0][1]
|
||||
|
||||
return results, workspaces, None, failed_workspaces
|
||||
|
||||
if cloud:
|
||||
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
|
||||
with force_routing(cloud=True):
|
||||
cloud_result = run_with_cleanup(_list_projects(effective_workspace))
|
||||
(
|
||||
cloud_results,
|
||||
available_cloud_workspaces,
|
||||
cloud_workspace_error,
|
||||
failed_cloud_workspaces,
|
||||
) = _fetch_cloud_workspace_results()
|
||||
elif local:
|
||||
with force_routing(local=True):
|
||||
local_result = run_with_cleanup(_list_projects())
|
||||
@@ -362,29 +444,17 @@ def list_projects(
|
||||
try:
|
||||
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
|
||||
with force_routing(cloud=True):
|
||||
cloud_result = run_with_cleanup(_list_projects(effective_workspace))
|
||||
(
|
||||
cloud_results,
|
||||
available_cloud_workspaces,
|
||||
cloud_workspace_error,
|
||||
failed_cloud_workspaces,
|
||||
) = _fetch_cloud_workspace_results()
|
||||
except typer.Exit:
|
||||
raise
|
||||
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
|
||||
|
||||
with console.status("[bold blue]Resolving workspace...", spinner="dots"):
|
||||
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")
|
||||
@@ -395,29 +465,128 @@ def list_projects(
|
||||
table.add_column("Sync", style="green")
|
||||
table.add_column("Default", style="magenta")
|
||||
|
||||
project_names_by_permalink: dict[str, str] = {}
|
||||
row_names_by_key: dict[tuple[str | None, str], str] = {}
|
||||
local_projects_by_permalink: dict[str, ProjectItem] = {}
|
||||
cloud_projects_by_permalink: dict[str, ProjectItem] = {}
|
||||
cloud_projects_by_key: dict[tuple[str | None, str], ProjectItem] = {}
|
||||
cloud_workspaces_by_key: dict[tuple[str | None, str], WorkspaceInfo | None] = {}
|
||||
|
||||
if local_result:
|
||||
for project in local_result.projects:
|
||||
permalink = generate_permalink(project.name)
|
||||
project_names_by_permalink[permalink] = project.name
|
||||
local_projects_by_permalink[permalink] = project
|
||||
|
||||
if cloud_result:
|
||||
for cloud_workspace, cloud_result in cloud_results:
|
||||
workspace_key = cloud_workspace.tenant_id if cloud_workspace else None
|
||||
for project in cloud_result.projects:
|
||||
permalink = generate_permalink(project.name)
|
||||
project_names_by_permalink[permalink] = project.name
|
||||
cloud_projects_by_permalink[permalink] = project
|
||||
row_key = (workspace_key, permalink)
|
||||
row_names_by_key[row_key] = project.name
|
||||
cloud_projects_by_key[row_key] = project
|
||||
cloud_workspaces_by_key[row_key] = cloud_workspace
|
||||
|
||||
cloud_permalinks = {permalink for _, permalink in cloud_projects_by_key}
|
||||
for permalink, project in local_projects_by_permalink.items():
|
||||
if permalink not in cloud_permalinks:
|
||||
row_names_by_key[(None, permalink)] = project.name
|
||||
|
||||
cloud_keys_by_permalink: dict[str, list[tuple[str | None, str]]] = {}
|
||||
for row_key in cloud_projects_by_key:
|
||||
cloud_keys_by_permalink.setdefault(row_key[1], []).append(row_key)
|
||||
|
||||
configured_names_by_permalink = {
|
||||
generate_permalink(project_name): project_name for project_name in config.projects
|
||||
}
|
||||
|
||||
def _workspace_priority(row_key: tuple[str | None, str]) -> tuple[bool, int, str, str]:
|
||||
"""Prefer the user's default/personal workspace when a project is duplicated."""
|
||||
workspace = cloud_workspaces_by_key.get(row_key)
|
||||
if workspace is None:
|
||||
return (True, 2, "", row_key[0] or "")
|
||||
workspace_type_rank = 0 if workspace.workspace_type == "personal" else 1
|
||||
return (
|
||||
not workspace.is_default,
|
||||
workspace_type_rank,
|
||||
workspace.name.casefold(),
|
||||
row_key[0] or "",
|
||||
)
|
||||
|
||||
def _select_attached_row_key(
|
||||
permalink: str, entry: ProjectEntry | None
|
||||
) -> tuple[str | None, str] | None:
|
||||
"""Choose the single row that owns local config/default/sync state."""
|
||||
cloud_keys = cloud_keys_by_permalink.get(permalink, [])
|
||||
if not cloud_keys:
|
||||
return (None, permalink)
|
||||
|
||||
preferred_workspace_ids: list[str] = []
|
||||
if entry and entry.workspace_id:
|
||||
preferred_workspace_ids.append(entry.workspace_id)
|
||||
if config.default_workspace and config.default_workspace not in preferred_workspace_ids:
|
||||
preferred_workspace_ids.append(config.default_workspace)
|
||||
default_cloud_workspace = next(
|
||||
(item for item in available_cloud_workspaces if item.is_default),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
default_cloud_workspace
|
||||
and default_cloud_workspace.tenant_id not in preferred_workspace_ids
|
||||
):
|
||||
preferred_workspace_ids.append(default_cloud_workspace.tenant_id)
|
||||
|
||||
for workspace_id in preferred_workspace_ids:
|
||||
for row_key in cloud_keys:
|
||||
if row_key[0] == workspace_id:
|
||||
return row_key
|
||||
|
||||
if workspace_filter_requested and preferred_workspace_ids:
|
||||
# A filtered list can exclude the workspace that owns local config state.
|
||||
# In that case, do not attach local/default/sync state to another workspace row.
|
||||
return None
|
||||
|
||||
default_workspace_keys = [
|
||||
row_key
|
||||
for row_key in cloud_keys
|
||||
if (row_workspace := cloud_workspaces_by_key.get(row_key)) is not None
|
||||
and row_workspace.is_default
|
||||
]
|
||||
if len(default_workspace_keys) == 1:
|
||||
return default_workspace_keys[0]
|
||||
|
||||
if len(cloud_keys) == 1:
|
||||
return cloud_keys[0]
|
||||
|
||||
return sorted(cloud_keys, key=_workspace_priority)[0]
|
||||
|
||||
attached_row_by_permalink: dict[str, tuple[str | None, str] | None] = {}
|
||||
for permalink in set(local_projects_by_permalink) | set(configured_names_by_permalink):
|
||||
configured_name = configured_names_by_permalink.get(permalink)
|
||||
local_project = local_projects_by_permalink.get(permalink)
|
||||
entry_name = configured_name or (local_project.name if local_project else None)
|
||||
entry = config.projects.get(entry_name) if entry_name else None
|
||||
attached_row_by_permalink[permalink] = _select_attached_row_key(permalink, entry)
|
||||
|
||||
# --- Build unified project list ---
|
||||
project_rows: list[dict] = []
|
||||
for permalink in sorted(project_names_by_permalink):
|
||||
project_name = project_names_by_permalink[permalink]
|
||||
local_project = local_projects_by_permalink.get(permalink)
|
||||
cloud_project = cloud_projects_by_permalink.get(permalink)
|
||||
entry = config.projects.get(project_name)
|
||||
sorted_row_keys = sorted(
|
||||
row_names_by_key,
|
||||
key=lambda key: (row_names_by_key[key], key[0] or ""),
|
||||
)
|
||||
for row_key in sorted_row_keys:
|
||||
_, permalink = row_key
|
||||
project_name = row_names_by_key[row_key]
|
||||
is_attached_row = attached_row_by_permalink.get(permalink) == row_key
|
||||
local_project = (
|
||||
local_projects_by_permalink.get(permalink) if is_attached_row else None
|
||||
)
|
||||
cloud_project = cloud_projects_by_key.get(row_key)
|
||||
cloud_workspace = cloud_workspaces_by_key.get(row_key)
|
||||
configured_name = configured_names_by_permalink.get(permalink)
|
||||
configured_entry = (
|
||||
config.projects.get(configured_name)
|
||||
if configured_name
|
||||
else config.projects.get(project_name)
|
||||
)
|
||||
entry = configured_entry if is_attached_row else None
|
||||
|
||||
local_path = ""
|
||||
if local_project is not None:
|
||||
@@ -447,9 +616,15 @@ def list_projects(
|
||||
else:
|
||||
cli_route = ProjectMode.LOCAL.value
|
||||
|
||||
is_default = config.default_project == project_name
|
||||
default_permalink = (
|
||||
generate_permalink(config.default_project) if config.default_project else None
|
||||
)
|
||||
is_default = bool(is_attached_row and permalink == default_permalink)
|
||||
|
||||
has_sync = bool(entry and entry.local_sync_path)
|
||||
sync_supported = (
|
||||
cloud_workspace is None or cloud_workspace.workspace_type == "personal"
|
||||
)
|
||||
has_sync = bool(is_attached_row and entry and entry.local_sync_path and sync_supported)
|
||||
# Determine MCP transport based on project routing mode
|
||||
if entry and entry.mode == ProjectMode.CLOUD:
|
||||
mcp_transport = "https"
|
||||
@@ -459,9 +634,8 @@ def list_projects(
|
||||
mcp_transport = "stdio"
|
||||
|
||||
# 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
|
||||
cloud_ws_name = cloud_workspace.name if cloud_workspace else None
|
||||
cloud_ws_type = cloud_workspace.workspace_type if cloud_workspace else None
|
||||
|
||||
# display_name is a human label for private UUID-named projects (e.g., "My Project").
|
||||
# Keep "name" as the canonical identifier for scripting/JSON consumers;
|
||||
@@ -481,8 +655,8 @@ def list_projects(
|
||||
}
|
||||
if display_name:
|
||||
row_data["display_name"] = display_name
|
||||
if ws_label:
|
||||
row_data["workspace"] = cloud_ws_name or ""
|
||||
if cloud_project is not None and cloud_ws_name:
|
||||
row_data["workspace"] = cloud_ws_name
|
||||
if cloud_ws_type:
|
||||
row_data["workspace_type"] = cloud_ws_type
|
||||
|
||||
@@ -514,6 +688,20 @@ def list_projects(
|
||||
"[dim]Showing local projects only. "
|
||||
"Run 'bm cloud login' or 'bm cloud api-key save <key>' if this is a credentials issue.[/dim]"
|
||||
)
|
||||
if cloud_workspace_error is not None:
|
||||
console.print(
|
||||
f"[yellow]Cloud workspace discovery failed: {cloud_workspace_error}[/yellow]"
|
||||
)
|
||||
console.print(
|
||||
"[dim]Showing cloud projects from the configured/default workspace only.[/dim]"
|
||||
)
|
||||
for failed_workspace, error in failed_cloud_workspaces:
|
||||
console.print(
|
||||
f"[yellow]Cloud project discovery failed for workspace "
|
||||
f"{failed_workspace.name}: {error}[/yellow]"
|
||||
)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error listing projects: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
@@ -531,7 +719,7 @@ def add_project(
|
||||
workspace: str = typer.Option(
|
||||
None,
|
||||
"--workspace",
|
||||
help="Cloud workspace name or tenant_id (cloud mode only)",
|
||||
help="Cloud workspace name, slug, type, or tenant_id (cloud mode only)",
|
||||
),
|
||||
visibility: str = typer.Option(
|
||||
None,
|
||||
@@ -914,7 +1102,7 @@ def set_cloud(
|
||||
workspace: str = typer.Option(
|
||||
None,
|
||||
"--workspace",
|
||||
help="Cloud workspace name or tenant_id to associate with this project",
|
||||
help="Cloud workspace name, slug, type, or tenant_id to associate with this project",
|
||||
),
|
||||
) -> None:
|
||||
"""Set a project to cloud mode (route through cloud API).
|
||||
|
||||
Reference in New Issue
Block a user