Skip to content

Commit 56b076c

Browse files
committed
fix(cli): list cloud projects across workspaces
1 parent c6fa185 commit 56b076c

2 files changed

Lines changed: 224 additions & 37 deletions

File tree

src/basic_memory/cli/commands/project.py

Lines changed: 99 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
CloudProjectIndexStatus,
3434
CloudTenantIndexStatusResponse,
3535
ProjectVisibility,
36+
WorkspaceInfo,
3637
)
3738
from basic_memory.schemas.project_info import ProjectItem, ProjectList
3839
from 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)

tests/cli/test_project_list_and_ls.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from basic_memory.cli.app import app
1212
from basic_memory.mcp.clients.project import ProjectClient
13+
from basic_memory.schemas.cloud import WorkspaceInfo
1314
from basic_memory.schemas.project_info import ProjectList
1415

1516
# Importing registers project subcommands on the shared app instance.
@@ -53,6 +54,26 @@ async def fake_get_client(workspace=None):
5354
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
5455

5556

57+
def _workspace(
58+
*,
59+
tenant_id: str,
60+
slug: str,
61+
name: str,
62+
workspace_type: str,
63+
is_default: bool = False,
64+
) -> WorkspaceInfo:
65+
return WorkspaceInfo(
66+
tenant_id=tenant_id,
67+
workspace_type=workspace_type,
68+
slug=slug,
69+
name=name,
70+
role="owner",
71+
is_default=is_default,
72+
organization_id=None,
73+
has_active_subscription=True,
74+
)
75+
76+
5677
def test_project_list_shows_local_cloud_presence_and_routes(
5778
runner: CliRunner, write_config, mock_client, tmp_path, monkeypatch
5879
):
@@ -215,6 +236,110 @@ async def fake_list_projects(self):
215236
assert private_project["display_name"] == "My Project"
216237

217238

239+
def test_project_list_cloud_fetches_all_workspaces_and_labels_duplicate_permalinks(
240+
runner: CliRunner, write_config, monkeypatch
241+
):
242+
"""Cloud project list should include every workspace without collapsing matching names."""
243+
write_config(
244+
{
245+
"env": "dev",
246+
"projects": {},
247+
"default_project": None,
248+
"cloud_api_key": "bmc_test_key_123",
249+
}
250+
)
251+
252+
personal = _workspace(
253+
tenant_id="tenant-personal",
254+
slug="personal",
255+
name="Personal",
256+
workspace_type="personal",
257+
is_default=True,
258+
)
259+
team = _workspace(
260+
tenant_id="tenant-team",
261+
slug="team",
262+
name="Team",
263+
workspace_type="organization",
264+
)
265+
266+
async def fake_get_available_workspaces():
267+
return [personal, team]
268+
269+
class FakeClient:
270+
def __init__(self, workspace: str | None):
271+
self.workspace = workspace
272+
273+
@asynccontextmanager
274+
async def fake_get_client(workspace=None):
275+
yield FakeClient(workspace)
276+
277+
payloads_by_workspace = {
278+
None: {"projects": [], "default_project": None},
279+
"tenant-personal": {
280+
"projects": [
281+
{
282+
"id": 1,
283+
"external_id": "11111111-1111-1111-1111-111111111111",
284+
"name": "shared",
285+
"path": "/personal/shared",
286+
"is_default": True,
287+
}
288+
],
289+
"default_project": "shared",
290+
},
291+
"tenant-team": {
292+
"projects": [
293+
{
294+
"id": 2,
295+
"external_id": "22222222-2222-2222-2222-222222222222",
296+
"name": "shared",
297+
"path": "/team/shared",
298+
"is_default": False,
299+
}
300+
],
301+
"default_project": None,
302+
},
303+
}
304+
seen_workspaces: list[str | None] = []
305+
306+
async def fake_list_projects(self):
307+
workspace = self.http_client.workspace
308+
seen_workspaces.append(workspace)
309+
return ProjectList.model_validate(payloads_by_workspace[workspace])
310+
311+
monkeypatch.setattr(
312+
"basic_memory.mcp.project_context.get_available_workspaces",
313+
fake_get_available_workspaces,
314+
)
315+
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
316+
monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects)
317+
318+
result = runner.invoke(app, ["project", "list", "--json"], env={"COLUMNS": "240"})
319+
320+
assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}"
321+
assert seen_workspaces == [None, "tenant-personal", "tenant-team"]
322+
323+
data = json.loads(result.stdout)
324+
shared_projects = [project for project in data["projects"] if project["name"] == "shared"]
325+
assert len(shared_projects) == 2
326+
assert {project["cloud_path"] for project in shared_projects} == {
327+
"/personal/shared",
328+
"/team/shared",
329+
}
330+
assert {project["workspace"] for project in shared_projects} == {"Personal", "Team"}
331+
assert {project["workspace_type"] for project in shared_projects} == {
332+
"personal",
333+
"organization",
334+
}
335+
336+
table_result = runner.invoke(app, ["project", "list"], env={"COLUMNS": "240"})
337+
338+
assert table_result.exit_code == 0
339+
assert "Personal (personal)" in table_result.stdout
340+
assert "Team (organization)" in table_result.stdout
341+
342+
218343
def test_project_ls_local_mode_defaults_to_local_route(
219344
runner: CliRunner, write_config, mock_client, tmp_path, monkeypatch
220345
):

0 commit comments

Comments
 (0)