mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(cli): expose project sync support metadata
Signed-off-by: Drew Cain <groksrc@gmail.com>
This commit is contained in:
committed by
Paul Hernandez
parent
8bf7bdbc0d
commit
f07643d3a8
@@ -617,7 +617,13 @@ def list_projects(
|
||||
)
|
||||
is_default = bool(is_attached_row and permalink == default_permalink)
|
||||
|
||||
sync_supported = cloud_workspace is None or cloud_workspace.workspace_type == "personal"
|
||||
# Show workspace name (type) for cloud-sourced projects
|
||||
cloud_ws_name = cloud_workspace.name if cloud_workspace else None
|
||||
cloud_ws_type = cloud_workspace.workspace_type if cloud_workspace else None
|
||||
|
||||
sync_supported = cloud_ws_type is None or cloud_ws_type == "personal"
|
||||
sync_reason = None if sync_supported else f"{cloud_ws_type} workspace"
|
||||
local_usage = "sync-supported" if sync_supported else "cloud-only"
|
||||
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:
|
||||
@@ -627,10 +633,6 @@ def list_projects(
|
||||
else:
|
||||
mcp_transport = "stdio"
|
||||
|
||||
# Show workspace name (type) for cloud-sourced projects
|
||||
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;
|
||||
# the Rich table uses display_name when available.
|
||||
@@ -645,6 +647,9 @@ def list_projects(
|
||||
"cli_route": cli_route,
|
||||
"mcp_stdio": mcp_transport,
|
||||
"sync": has_sync,
|
||||
"sync_supported": sync_supported,
|
||||
"sync_reason": sync_reason,
|
||||
"local_usage": local_usage,
|
||||
"is_default": is_default,
|
||||
}
|
||||
if display_name:
|
||||
@@ -663,6 +668,13 @@ def list_projects(
|
||||
|
||||
# --- Rich table output ---
|
||||
for row_data in project_rows:
|
||||
sync_display = (
|
||||
"[X]"
|
||||
if row_data["sync"]
|
||||
else "cloud-only"
|
||||
if not row_data["sync_supported"]
|
||||
else ""
|
||||
)
|
||||
table.add_row(
|
||||
row_data.get("display_name") or row_data["name"],
|
||||
row_data["local_path"],
|
||||
@@ -671,7 +683,7 @@ def list_projects(
|
||||
+ (f" ({row_data['workspace_type']})" if row_data.get("workspace_type") else ""),
|
||||
row_data["cli_route"],
|
||||
row_data["mcp_stdio"],
|
||||
"[X]" if row_data["sync"] else "",
|
||||
sync_display,
|
||||
"[X]" if row_data["is_default"] else "",
|
||||
)
|
||||
|
||||
|
||||
@@ -35,6 +35,16 @@ from basic_memory.utils import generate_permalink
|
||||
# --- Helpers for dual-fetch + merge ---
|
||||
|
||||
|
||||
def _sync_support_metadata(workspace_type: str | None) -> dict[str, object]:
|
||||
"""Return structured local sync support fields for project listings."""
|
||||
sync_supported = workspace_type is None or workspace_type == "personal"
|
||||
return {
|
||||
"sync_supported": sync_supported,
|
||||
"sync_reason": None if sync_supported else f"{workspace_type} workspace",
|
||||
"local_usage": "sync-supported" if sync_supported else "cloud-only",
|
||||
}
|
||||
|
||||
|
||||
def _merge_projects(
|
||||
local_list: ProjectList | None,
|
||||
cloud_list: ProjectList | None,
|
||||
@@ -125,6 +135,7 @@ def _merge_projects(
|
||||
"workspace_tenant_id": ws_tenant_id,
|
||||
"workspace_slug": cloud_workspace_slug if cloud_proj else None,
|
||||
"workspace_is_default": cloud_workspace_is_default if cloud_proj else False,
|
||||
**_sync_support_metadata(ws_type),
|
||||
"qualified_name": (
|
||||
f"{cloud_workspace_slug}/{permalink}"
|
||||
if cloud_proj and cloud_workspace_slug
|
||||
@@ -258,6 +269,7 @@ def _merge_workspace_projects(
|
||||
"workspace_tenant_id": entry.workspace.tenant_id,
|
||||
"workspace_slug": entry.workspace.slug,
|
||||
"workspace_is_default": entry.workspace.is_default,
|
||||
**_sync_support_metadata(entry.workspace.workspace_type),
|
||||
"qualified_name": entry.qualified_name,
|
||||
}
|
||||
)
|
||||
@@ -282,6 +294,7 @@ def _merge_workspace_projects(
|
||||
"workspace_tenant_id": None,
|
||||
"workspace_slug": None,
|
||||
"workspace_is_default": False,
|
||||
**_sync_support_metadata(None),
|
||||
"qualified_name": None,
|
||||
}
|
||||
)
|
||||
@@ -313,7 +326,10 @@ def _format_project_list_text(merged: list[dict]) -> str:
|
||||
source = project["source"]
|
||||
external_id = project.get("external_id", "")
|
||||
id_suffix = f" [{external_id}]" if external_id else ""
|
||||
result += f"- {label} ({source}){id_suffix}\n"
|
||||
usage_suffix = ""
|
||||
if project.get("sync_supported") is False:
|
||||
usage_suffix = " - cloud-only (local sync unsupported)"
|
||||
result += f"- {label} ({source}){id_suffix}{usage_suffix}\n"
|
||||
|
||||
result += "\n" + "─" * 40 + "\n"
|
||||
result += "Next: Ask which project to use for this session.\n"
|
||||
|
||||
@@ -333,12 +333,23 @@ def test_project_list_cloud_fetches_all_workspaces_and_labels_duplicate_permalin
|
||||
"personal",
|
||||
"organization",
|
||||
}
|
||||
personal_project = next(
|
||||
project for project in shared_projects if project["workspace"] == "Personal"
|
||||
)
|
||||
team_project = next(project for project in shared_projects if project["workspace"] == "Team")
|
||||
assert personal_project["sync_supported"] is True
|
||||
assert personal_project["sync_reason"] is None
|
||||
assert personal_project["local_usage"] == "sync-supported"
|
||||
assert team_project["sync_supported"] is False
|
||||
assert team_project["sync_reason"] == "organization workspace"
|
||||
assert team_project["local_usage"] == "cloud-only"
|
||||
|
||||
table_result = runner.invoke(app, ["project", "list"], env={"COLUMNS": "240"})
|
||||
|
||||
assert table_result.exit_code == 0
|
||||
assert "Personal (personal)" in table_result.stdout
|
||||
assert "Team (organization)" in table_result.stdout
|
||||
assert "cloud-only" in table_result.stdout
|
||||
|
||||
|
||||
def test_project_list_workspace_discovery_failure_warns_and_uses_fallback(
|
||||
@@ -907,6 +918,9 @@ def test_project_list_hides_bisync_flag_for_attached_team_workspace(
|
||||
assert team_row["cli_route"] == "cloud"
|
||||
assert team_row["mcp_stdio"] == "https"
|
||||
assert team_row["sync"] is False
|
||||
assert team_row["sync_supported"] is False
|
||||
assert team_row["sync_reason"] == "organization workspace"
|
||||
assert team_row["local_usage"] == "cloud-only"
|
||||
assert team_row["is_default"] is True
|
||||
|
||||
|
||||
|
||||
@@ -700,7 +700,9 @@ async def test_list_memory_projects_factory_mode(app, test_project):
|
||||
assert "Workspace: Personal (personal default)" in result
|
||||
assert "Workspace: Team Paul (team-paul)" in result
|
||||
assert "- personal-main (cloud) [personal-project-uuid]" in result
|
||||
assert "- team-specs (cloud) [team-project-uuid]" in result
|
||||
assert (
|
||||
"- team-specs (cloud) [team-project-uuid] - cloud-only (local sync unsupported)" in result
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -759,6 +761,9 @@ async def test_list_memory_projects_factory_mode_json_includes_workspace(app, te
|
||||
assert proj["workspace_slug"] == "my-org"
|
||||
assert proj["workspace_is_default"] is False
|
||||
assert proj["qualified_name"] == "my-org/cloud-proj"
|
||||
assert proj["sync_supported"] is False
|
||||
assert proj["sync_reason"] == "organization workspace"
|
||||
assert proj["local_usage"] == "cloud-only"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -828,6 +833,9 @@ async def test_list_memory_projects_json_with_cloud(app, test_project):
|
||||
# Backward-compat: path prefers local
|
||||
assert main_proj["path"] == "/home/user/basic-memory"
|
||||
assert main_proj["is_default"] is True
|
||||
assert main_proj["sync_supported"] is True
|
||||
assert main_proj["sync_reason"] is None
|
||||
assert main_proj["local_usage"] == "sync-supported"
|
||||
|
||||
# cloud-only
|
||||
cloud_proj = by_name["cloud-only"]
|
||||
@@ -837,6 +845,9 @@ async def test_list_memory_projects_json_with_cloud(app, test_project):
|
||||
assert cloud_proj["path"] == "/cloud-only"
|
||||
assert cloud_proj["workspace_slug"] == "personal"
|
||||
assert cloud_proj["qualified_name"] == "personal/cloud-only"
|
||||
assert cloud_proj["sync_supported"] is True
|
||||
assert cloud_proj["sync_reason"] is None
|
||||
assert cloud_proj["local_usage"] == "sync-supported"
|
||||
|
||||
|
||||
# --- Unit test for _merge_projects ---
|
||||
@@ -863,6 +874,9 @@ def test_merge_projects_local_only():
|
||||
assert all(p["workspace_name"] is None for p in merged)
|
||||
assert all(p["workspace_type"] is None for p in merged)
|
||||
assert all(p["workspace_tenant_id"] is None for p in merged)
|
||||
assert all(p["sync_supported"] is True for p in merged)
|
||||
assert all(p["sync_reason"] is None for p in merged)
|
||||
assert all(p["local_usage"] == "sync-supported" for p in merged)
|
||||
|
||||
|
||||
def test_merge_projects_cloud_only():
|
||||
@@ -885,6 +899,9 @@ def test_merge_projects_cloud_only():
|
||||
assert merged[0]["workspace_name"] == "Personal"
|
||||
assert merged[0]["workspace_type"] == "personal"
|
||||
assert merged[0]["workspace_tenant_id"] == "tenant-123"
|
||||
assert merged[0]["sync_supported"] is True
|
||||
assert merged[0]["sync_reason"] is None
|
||||
assert merged[0]["local_usage"] == "sync-supported"
|
||||
|
||||
|
||||
def test_merge_projects_overlap():
|
||||
@@ -908,6 +925,9 @@ def test_merge_projects_overlap():
|
||||
assert merged[0]["workspace_name"] == "Acme Corp"
|
||||
assert merged[0]["workspace_type"] == "organization"
|
||||
assert merged[0]["workspace_tenant_id"] == "org-456"
|
||||
assert merged[0]["sync_supported"] is False
|
||||
assert merged[0]["sync_reason"] == "organization workspace"
|
||||
assert merged[0]["local_usage"] == "cloud-only"
|
||||
|
||||
|
||||
def test_merge_workspace_projects_attaches_local_state_to_one_duplicate_workspace(tmp_path):
|
||||
@@ -959,6 +979,9 @@ def test_merge_workspace_projects_attaches_local_state_to_one_duplicate_workspac
|
||||
assert team_project["source"] == "cloud"
|
||||
assert team_project["local_path"] is None
|
||||
assert team_project["path"] == "/cloud/team-main"
|
||||
assert team_project["sync_supported"] is False
|
||||
assert team_project["sync_reason"] == "organization workspace"
|
||||
assert team_project["local_usage"] == "cloud-only"
|
||||
|
||||
|
||||
def test_merge_workspace_projects_uses_configured_workspace_for_local_state(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user