diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 787f30cb..cee14d59 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -253,6 +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 + # 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": project_name, "permalink": permalink, @@ -263,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: @@ -278,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 ca4dca0a..6e5cbbd1 100644 --- a/tests/cli/test_project_list_and_ls.py +++ b/tests/cli/test_project_list_and_ls.py @@ -139,6 +139,82 @@ 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}" + # Rich table should show display_name in the Name column + assert "My Project" in result.stdout + lines = result.stdout.splitlines() + project_line = next(line for line in lines if "My Project" in line) + 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 ):