From ea72cb1c8f8f6f4c4f3c4a517f6e5353d1d4bbd6 Mon Sep 17 00:00:00 2001 From: Joe P Date: Sat, 4 Apr 2026 09:22:04 -0600 Subject: [PATCH 1/5] Show display_name instead of UUID for private projects in CLI The `bm project list` table rendered the raw project name, which is a UUID for private projects in team workspaces. Now uses display_name from the cloud response (e.g., "My Project") with fallback to name. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joe P --- src/basic_memory/cli/commands/project.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 787f30cb..85d3edd1 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -253,8 +253,12 @@ async def _list_projects(ws: str | None = None): 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 + # Use display_name from cloud response (e.g., "My Project" for private UUID-named projects) + display_name = ( + cloud_project.display_name if cloud_project and cloud_project.display_name else None + ) row_data = { - "name": project_name, + "name": display_name or project_name, "permalink": permalink, "local_path": local_path, "cloud_path": cloud_path, From c090ebe9435e328baee71d65080b77934d81a217 Mon Sep 17 00:00:00 2001 From: Joe P Date: Sat, 4 Apr 2026 09:24:45 -0600 Subject: [PATCH 2/5] Add test for display_name in project list table Verifies that private projects with display_name show "My Project" in the Name column instead of the raw UUID. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joe P --- tests/cli/test_project_list_and_ls.py | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/cli/test_project_list_and_ls.py b/tests/cli/test_project_list_and_ls.py index ca4dca0a..545612dc 100644 --- a/tests/cli/test_project_list_and_ls.py +++ b/tests/cli/test_project_list_and_ls.py @@ -139,6 +139,77 @@ async def fake_list_projects(self): assert "/beta" in result.stdout +def test_project_list_shows_display_name_for_private_projects( + runner: CliRunner, write_config, mock_client, tmp_path, monkeypatch +): + """Private projects should show display_name ('My Project') instead of raw UUID name.""" + private_uuid = "f1df8f39-d5aa-4095-ae05-8c5a2883029a" + + write_config( + { + "env": "dev", + "projects": {}, + "default_project": "main", + "cloud_api_key": "bmc_test_key_123", + } + ) + + local_payload = { + "projects": [ + { + "id": 1, + "external_id": "11111111-1111-1111-1111-111111111111", + "name": "main", + "path": "/main", + "is_default": True, + } + ], + "default_project": "main", + } + + cloud_payload = { + "projects": [ + { + "id": 1, + "external_id": "11111111-1111-1111-1111-111111111111", + "name": "main", + "path": "/main", + "is_default": True, + }, + { + "id": 2, + "external_id": "22222222-2222-2222-2222-222222222222", + "name": private_uuid, + "path": f"/{private_uuid}", + "is_default": False, + "display_name": "My Project", + "is_private": True, + }, + ], + "default_project": "main", + } + + async def fake_list_projects(self): + if os.getenv("BASIC_MEMORY_FORCE_CLOUD", "").lower() in ("true", "1", "yes"): + return ProjectList.model_validate(cloud_payload) + return ProjectList.model_validate(local_payload) + + monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects) + + result = runner.invoke(app, ["project", "list"], env={"COLUMNS": "240"}) + + assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}" + # display_name should appear in the Name column instead of the raw UUID + assert "My Project" in result.stdout + # The Name column should show "My Project", not the UUID. + # The UUID may still appear in the Cloud Path column — that's expected. + lines = result.stdout.splitlines() + project_line = next(line for line in lines if "My Project" in line) + # The name cell is the first column — verify UUID is not the displayed name + name_cell = project_line.split("│")[1].strip() + assert name_cell == "My Project" + + def test_project_ls_local_mode_defaults_to_local_route( runner: CliRunner, write_config, mock_client, tmp_path, monkeypatch ): From d6855c5384c88560cc11674879de58c226faef94 Mon Sep 17 00:00:00 2001 From: Joe P Date: Sat, 4 Apr 2026 09:33:14 -0600 Subject: [PATCH 3/5] Remove trailing slash from project list endpoint path The cloud proxy route for project list filtering is registered at /v2/projects (no trailing slash). Requests to /v2/projects/ were falling through to the catch-all route, bypassing private project filtering and display_name enrichment. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joe P --- src/basic_memory/mcp/clients/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/basic_memory/mcp/clients/project.py b/src/basic_memory/mcp/clients/project.py index a5c2ad57..539c2d11 100644 --- a/src/basic_memory/mcp/clients/project.py +++ b/src/basic_memory/mcp/clients/project.py @@ -55,7 +55,7 @@ async def list_projects(self) -> ProjectList: """ response = await call_get( self.http_client, - "/v2/projects/", + "/v2/projects", ) return ProjectList.model_validate(response.json()) From 8907e48d522697e29a9783bde35a7aaa360f3322 Mon Sep 17 00:00:00 2001 From: Joe P Date: Mon, 6 Apr 2026 07:59:36 -0600 Subject: [PATCH 4/5] Revert trailing slash removal from project list client path The trailing slash on /v2/projects/ is required because the FastAPI route is defined as @router.get("/"). Removing it caused 307 redirects that broke call_get (which doesn't follow redirects), failing all project management tests in CI. The cloud proxy's defense-in-depth filtering already handles both slash variants via rstrip("/"), so the trailing slash was never causing a filtering bypass. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joe P --- src/basic_memory/mcp/clients/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/basic_memory/mcp/clients/project.py b/src/basic_memory/mcp/clients/project.py index 539c2d11..a5c2ad57 100644 --- a/src/basic_memory/mcp/clients/project.py +++ b/src/basic_memory/mcp/clients/project.py @@ -55,7 +55,7 @@ async def list_projects(self) -> ProjectList: """ response = await call_get( self.http_client, - "/v2/projects", + "/v2/projects/", ) return ProjectList.model_validate(response.json()) From 9e0385d5c37aa1cdaec7ae014fb68b2a6911f950 Mon Sep 17 00:00:00 2001 From: Joe P Date: Mon, 6 Apr 2026 09:31:18 -0600 Subject: [PATCH 5/5] fix(cli): preserve canonical name in JSON, use display_name only in table Keep row_data["name"] as the canonical project identifier (UUID for private projects) so that `bm project list --json` output can be piped into other commands. The human-friendly display_name is added as a separate field and used only in the Rich table rendering. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joe P --- src/basic_memory/cli/commands/project.py | 10 +++++++--- tests/cli/test_project_list_and_ls.py | 13 +++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 85d3edd1..cee14d59 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -253,12 +253,14 @@ async def _list_projects(ws: str | None = None): 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 - # Use display_name from cloud response (e.g., "My Project" for private UUID-named projects) + # 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; + # the Rich table uses display_name when available. display_name = ( cloud_project.display_name if cloud_project and cloud_project.display_name else None ) row_data = { - "name": display_name or project_name, + "name": project_name, "permalink": permalink, "local_path": local_path, "cloud_path": cloud_path, @@ -267,6 +269,8 @@ async def _list_projects(ws: str | None = None): "sync": has_sync, "is_default": is_default, } + if display_name: + row_data["display_name"] = display_name if ws_label: row_data["workspace"] = cloud_ws_name or "" if cloud_ws_type: @@ -282,7 +286,7 @@ async def _list_projects(ws: str | None = None): # --- Rich table output --- for row_data in project_rows: table.add_row( - row_data["name"], + row_data.get("display_name") or row_data["name"], row_data["local_path"], row_data["cloud_path"], row_data.get("workspace", "") diff --git a/tests/cli/test_project_list_and_ls.py b/tests/cli/test_project_list_and_ls.py index 545612dc..6e5cbbd1 100644 --- a/tests/cli/test_project_list_and_ls.py +++ b/tests/cli/test_project_list_and_ls.py @@ -199,16 +199,21 @@ async def fake_list_projects(self): result = runner.invoke(app, ["project", "list"], env={"COLUMNS": "240"}) assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}" - # display_name should appear in the Name column instead of the raw UUID + # Rich table should show display_name in the Name column assert "My Project" in result.stdout - # The Name column should show "My Project", not the UUID. - # The UUID may still appear in the Cloud Path column — that's expected. lines = result.stdout.splitlines() project_line = next(line for line in lines if "My Project" in line) - # The name cell is the first column — verify UUID is not the displayed name name_cell = project_line.split("│")[1].strip() assert name_cell == "My Project" + # JSON output should preserve canonical name for scripting, with display_name as separate field + json_result = runner.invoke(app, ["project", "list", "--json"], env={"COLUMNS": "240"}) + assert json_result.exit_code == 0 + data = json.loads(json_result.stdout) + private_project = next(p for p in data["projects"] if p.get("display_name") == "My Project") + assert private_project["name"] == private_uuid + assert private_project["display_name"] == "My Project" + def test_project_ls_local_mode_defaults_to_local_route( runner: CliRunner, write_config, mock_client, tmp_path, monkeypatch