3333 CloudProjectIndexStatus ,
3434 CloudTenantIndexStatusResponse ,
3535 ProjectVisibility ,
36+ WorkspaceInfo ,
3637)
3738from basic_memory .schemas .project_info import ProjectItem , ProjectList
3839from basic_memory .utils import generate_permalink , normalize_project_path
@@ -339,17 +340,70 @@ async def _list_projects(ws: str | None = None):
339340
340341 try :
341342 config = ConfigManager ().config
342- # Use explicit workspace, fall back to config default
343- effective_workspace = workspace or config .default_workspace
344343
345344 local_result : ProjectList | None = None
346- cloud_result : ProjectList | None = None
345+ cloud_results : list [ tuple [ WorkspaceInfo | None , ProjectList ]] = []
347346 cloud_error : Exception | None = None
347+ cloud_workspace_error : Exception | None = None
348+ failed_cloud_workspaces : list [tuple [WorkspaceInfo , Exception ]] = []
349+
350+ def _fetch_cloud_workspace_results () -> list [tuple [WorkspaceInfo | None , ProjectList ]]:
351+ nonlocal cloud_workspace_error
352+
353+ from basic_memory .mcp .project_context import (
354+ _workspace_choices ,
355+ _workspace_matches_identifier ,
356+ get_available_workspaces ,
357+ )
358+
359+ try :
360+ workspaces = run_with_cleanup (get_available_workspaces ())
361+ except Exception as exc :
362+ cloud_workspace_error = exc
363+ fallback_workspace = workspace or config .default_workspace
364+ return [(None , run_with_cleanup (_list_projects (fallback_workspace )))]
365+
366+ selected_workspaces = workspaces
367+ if workspace is not None :
368+ matches = [ws for ws in workspaces if _workspace_matches_identifier (ws , workspace )]
369+ if not matches :
370+ console .print (f"[red]Error: Workspace '{ workspace } ' not found[/red]" )
371+ if workspaces :
372+ console .print (f"[dim]Available:\n { _workspace_choices (workspaces )} [/dim]" )
373+ raise typer .Exit (1 )
374+ if len (matches ) > 1 :
375+ console .print (
376+ f"[red]Error: Workspace name '{ workspace } ' matches multiple workspaces. "
377+ f"Use tenant_id instead.[/red]"
378+ )
379+ console .print (f"[dim]Available:\n { _workspace_choices (workspaces )} [/dim]" )
380+ raise typer .Exit (1 )
381+ selected_workspaces = matches
382+
383+ if not selected_workspaces :
384+ return []
385+
386+ results : list [tuple [WorkspaceInfo | None , ProjectList ]] = []
387+ for cloud_workspace in selected_workspaces :
388+ try :
389+ results .append (
390+ (
391+ cloud_workspace ,
392+ run_with_cleanup (_list_projects (cloud_workspace .tenant_id )),
393+ )
394+ )
395+ except Exception as exc :
396+ failed_cloud_workspaces .append ((cloud_workspace , exc ))
397+
398+ if not results and failed_cloud_workspaces :
399+ raise failed_cloud_workspaces [0 ][1 ]
400+
401+ return results
348402
349403 if cloud :
350404 with console .status ("[bold blue]Fetching cloud projects..." , spinner = "dots" ):
351405 with force_routing (cloud = True ):
352- cloud_result = run_with_cleanup ( _list_projects ( effective_workspace ) )
406+ cloud_results = _fetch_cloud_workspace_results ( )
353407 elif local :
354408 with force_routing (local = True ):
355409 local_result = run_with_cleanup (_list_projects ())
@@ -362,29 +416,10 @@ async def _list_projects(ws: str | None = None):
362416 try :
363417 with console .status ("[bold blue]Fetching cloud projects..." , spinner = "dots" ):
364418 with force_routing (cloud = True ):
365- cloud_result = run_with_cleanup ( _list_projects ( effective_workspace ) )
419+ cloud_results = _fetch_cloud_workspace_results ( )
366420 except Exception as exc : # pragma: no cover
367421 cloud_error = exc
368422
369- # Resolve workspace name for cloud projects (best-effort)
370- cloud_ws_name : str | None = None
371- cloud_ws_type : str | None = None
372- if cloud_result and effective_workspace :
373- try :
374- from basic_memory .mcp .project_context import get_available_workspaces
375-
376- with console .status ("[bold blue]Resolving workspace..." , spinner = "dots" ):
377- workspaces = run_with_cleanup (get_available_workspaces ())
378- matched = next (
379- (ws for ws in workspaces if ws .tenant_id == effective_workspace ),
380- None ,
381- )
382- if matched :
383- cloud_ws_name = matched .name
384- cloud_ws_type = matched .workspace_type
385- except Exception :
386- pass
387-
388423 table = Table (title = "Basic Memory Projects" )
389424 table .add_column ("Name" , style = "cyan" )
390425 table .add_column ("Local Path" , style = "yellow" , no_wrap = True , overflow = "fold" )
@@ -395,28 +430,42 @@ async def _list_projects(ws: str | None = None):
395430 table .add_column ("Sync" , style = "green" )
396431 table .add_column ("Default" , style = "magenta" )
397432
398- project_names_by_permalink : dict [str , str ] = {}
433+ row_names_by_key : dict [tuple [ str | None , str ] , str ] = {}
399434 local_projects_by_permalink : dict [str , ProjectItem ] = {}
400- cloud_projects_by_permalink : dict [str , ProjectItem ] = {}
435+ cloud_projects_by_key : dict [tuple [str | None , str ], ProjectItem ] = {}
436+ cloud_workspaces_by_key : dict [tuple [str | None , str ], WorkspaceInfo | None ] = {}
401437
402438 if local_result :
403439 for project in local_result .projects :
404440 permalink = generate_permalink (project .name )
405- project_names_by_permalink [permalink ] = project .name
406441 local_projects_by_permalink [permalink ] = project
407442
408- if cloud_result :
443+ for cloud_workspace , cloud_result in cloud_results :
444+ workspace_key = cloud_workspace .tenant_id if cloud_workspace else None
409445 for project in cloud_result .projects :
410446 permalink = generate_permalink (project .name )
411- project_names_by_permalink [permalink ] = project .name
412- cloud_projects_by_permalink [permalink ] = project
447+ row_key = (workspace_key , permalink )
448+ row_names_by_key [row_key ] = project .name
449+ cloud_projects_by_key [row_key ] = project
450+ cloud_workspaces_by_key [row_key ] = cloud_workspace
451+
452+ cloud_permalinks = {permalink for _ , permalink in cloud_projects_by_key }
453+ for permalink , project in local_projects_by_permalink .items ():
454+ if permalink not in cloud_permalinks :
455+ row_names_by_key [(None , permalink )] = project .name
413456
414457 # --- Build unified project list ---
415458 project_rows : list [dict ] = []
416- for permalink in sorted (project_names_by_permalink ):
417- project_name = project_names_by_permalink [permalink ]
459+ sorted_row_keys = sorted (
460+ row_names_by_key ,
461+ key = lambda key : (row_names_by_key [key ], key [0 ] or "" ),
462+ )
463+ for row_key in sorted_row_keys :
464+ _ , permalink = row_key
465+ project_name = row_names_by_key [row_key ]
418466 local_project = local_projects_by_permalink .get (permalink )
419- cloud_project = cloud_projects_by_permalink .get (permalink )
467+ cloud_project = cloud_projects_by_key .get (row_key )
468+ cloud_workspace = cloud_workspaces_by_key .get (row_key )
420469 entry = config .projects .get (project_name )
421470
422471 local_path = ""
@@ -459,9 +508,8 @@ async def _list_projects(ws: str | None = None):
459508 mcp_transport = "stdio"
460509
461510 # Show workspace name (type) for cloud-sourced projects
462- ws_label = ""
463- if cloud_project is not None and cloud_ws_name :
464- ws_label = f"{ cloud_ws_name } ({ cloud_ws_type } )" if cloud_ws_type else cloud_ws_name
511+ cloud_ws_name = cloud_workspace .name if cloud_workspace else None
512+ cloud_ws_type = cloud_workspace .workspace_type if cloud_workspace else None
465513
466514 # display_name is a human label for private UUID-named projects (e.g., "My Project").
467515 # Keep "name" as the canonical identifier for scripting/JSON consumers;
@@ -481,7 +529,7 @@ async def _list_projects(ws: str | None = None):
481529 }
482530 if display_name :
483531 row_data ["display_name" ] = display_name
484- if ws_label :
532+ if cloud_project is not None and cloud_ws_name :
485533 row_data ["workspace" ] = cloud_ws_name or ""
486534 if cloud_ws_type :
487535 row_data ["workspace_type" ] = cloud_ws_type
@@ -514,6 +562,20 @@ async def _list_projects(ws: str | None = None):
514562 "[dim]Showing local projects only. "
515563 "Run 'bm cloud login' or 'bm cloud api-key save <key>' if this is a credentials issue.[/dim]"
516564 )
565+ if cloud_workspace_error is not None :
566+ console .print (
567+ f"[yellow]Cloud workspace discovery failed: { cloud_workspace_error } [/yellow]"
568+ )
569+ console .print (
570+ "[dim]Showing cloud projects from the configured/default workspace only.[/dim]"
571+ )
572+ for failed_workspace , error in failed_cloud_workspaces :
573+ console .print (
574+ f"[yellow]Cloud project discovery failed for workspace "
575+ f"{ failed_workspace .name } : { error } [/yellow]"
576+ )
577+ except typer .Exit :
578+ raise
517579 except Exception as e :
518580 console .print (f"[red]Error listing projects: { str (e )} [/red]" )
519581 raise typer .Exit (1 )
0 commit comments