Skip to content

Commit bb3331b

Browse files
committed
fix(cli): persist cloud workspace routing on add
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent e5c9df0 commit bb3331b

File tree

2 files changed

+81
-20
lines changed

2 files changed

+81
-20
lines changed

src/basic_memory/cli/commands/project.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
from datetime import datetime
66
from pathlib import Path
7+
from typing import cast
78

89
import typer
910
from rich.console import Console, Group
@@ -27,6 +28,7 @@
2728
from basic_memory.config import ConfigManager, ProjectEntry, ProjectMode
2829
from basic_memory.mcp.async_client import get_client
2930
from basic_memory.mcp.clients import ProjectClient
31+
from basic_memory.schemas.cloud import ProjectVisibility
3032
from basic_memory.schemas.project_info import ProjectItem, ProjectList
3133
from basic_memory.utils import generate_permalink, normalize_project_path
3234

@@ -56,27 +58,27 @@ def make_bar(value: int, max_value: int, width: int = 40) -> Text:
5658
return bar
5759

5860

59-
def _normalize_project_visibility(visibility: str | None) -> str:
61+
def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility:
6062
"""Normalize CLI visibility input to the cloud API contract."""
6163
if visibility is None:
6264
return "workspace"
6365

6466
normalized = visibility.strip().lower()
6567
if normalized in {"workspace", "shared", "private"}:
66-
return normalized
68+
return cast(ProjectVisibility, normalized)
6769

6870
raise ValueError("Invalid visibility. Expected one of: workspace, shared, private.")
6971

7072

7173
def _resolve_workspace_id(config, workspace: str | None) -> str | None:
7274
"""Resolve a workspace name or tenant_id to a tenant_id."""
73-
if workspace is not None:
74-
from basic_memory.mcp.project_context import (
75-
_workspace_choices,
76-
_workspace_matches_identifier,
77-
get_available_workspaces,
78-
)
75+
from basic_memory.mcp.project_context import (
76+
_workspace_choices,
77+
_workspace_matches_identifier,
78+
get_available_workspaces,
79+
)
7980

81+
if workspace is not None:
8082
workspaces = run_with_cleanup(get_available_workspaces())
8183
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, workspace)]
8284
if not matches:
@@ -97,8 +99,6 @@ def _resolve_workspace_id(config, workspace: str | None) -> str | None:
9799
return config.default_workspace
98100

99101
try:
100-
from basic_memory.mcp.project_context import get_available_workspaces
101-
102102
workspaces = run_with_cleanup(get_available_workspaces())
103103
if len(workspaces) == 1:
104104
return workspaces[0].tenant_id
@@ -402,28 +402,34 @@ async def _add_project():
402402
result = run_with_cleanup(_add_project())
403403
console.print(f"[green]{result.message}[/green]")
404404

405-
# Save local sync path to config if in cloud mode
406-
if effective_cloud_mode and local_sync_path:
407-
# Create local directory if it doesn't exist
408-
local_dir = Path(local_sync_path)
409-
local_dir.mkdir(parents=True, exist_ok=True)
410-
411-
# Update project entry — path is always the local directory
405+
# Trigger: local config needs enough metadata to route future commands back to cloud.
406+
# Why: explicit workspace selection and local sync state should persist across CLI sessions.
407+
# Outcome: cloud-backed projects keep cloud mode, workspace_id, and optional local sync path.
408+
if effective_cloud_mode and (local_sync_path or resolved_workspace_id):
412409
entry = config.projects.get(name)
413410
if entry:
414-
entry.path = local_sync_path
415-
entry.local_sync_path = local_sync_path
411+
entry.mode = ProjectMode.CLOUD
412+
if local_sync_path:
413+
entry.path = local_sync_path
414+
entry.local_sync_path = local_sync_path
416415
if resolved_workspace_id:
417416
entry.workspace_id = resolved_workspace_id
418417
else:
419418
# Project may not be in local config yet (cloud-only add)
420419
config.projects[name] = ProjectEntry(
421-
path=local_sync_path,
420+
path=local_sync_path or "",
421+
mode=ProjectMode.CLOUD,
422422
local_sync_path=local_sync_path,
423423
workspace_id=resolved_workspace_id,
424424
)
425425
ConfigManager().save_config(config)
426426

427+
# Save local sync path to config if in cloud mode
428+
if effective_cloud_mode and local_sync_path:
429+
# Create local directory if it doesn't exist
430+
local_dir = Path(local_sync_path)
431+
local_dir.mkdir(parents=True, exist_ok=True)
432+
427433
console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
428434
console.print("\nNext steps:")
429435
console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run")

tests/cli/test_project_add_with_local_path.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def test_project_add_with_local_path_saves_to_config(
115115
assert "test-project" in config_data["projects"]
116116
entry = config_data["projects"]["test-project"]
117117
# Use as_posix() for cross-platform compatibility (Windows uses backslashes)
118+
assert entry["mode"] == "cloud"
118119
assert entry["local_sync_path"] == local_sync_dir.as_posix()
119120
assert entry.get("last_sync") is None
120121
assert entry.get("bisync_initialized", False) is False
@@ -248,9 +249,63 @@ async def fake_get_available_workspaces():
248249

249250
config_data = json.loads(mock_config.read_text())
250251
entry = config_data["projects"]["team-notes"]
252+
assert entry["mode"] == "cloud"
251253
assert entry["workspace_id"] == "11111111-1111-1111-1111-111111111111"
252254

253255

256+
def test_project_add_cloud_workspace_persists_without_local_path(
257+
runner, mock_config, mock_api_client, monkeypatch
258+
):
259+
"""Cloud project add should persist workspace routing even without local sync."""
260+
from basic_memory.schemas.cloud import WorkspaceInfo
261+
262+
async def fake_get_available_workspaces():
263+
return [
264+
WorkspaceInfo(
265+
tenant_id="11111111-1111-1111-1111-111111111111",
266+
workspace_type="organization",
267+
name="Basic Memory",
268+
role="owner",
269+
),
270+
]
271+
272+
monkeypatch.setattr(
273+
"basic_memory.mcp.project_context.get_available_workspaces",
274+
fake_get_available_workspaces,
275+
)
276+
277+
result = runner.invoke(
278+
app,
279+
[
280+
"project",
281+
"add",
282+
"team-notes",
283+
"--cloud",
284+
"--workspace",
285+
"Basic Memory",
286+
],
287+
)
288+
289+
assert result.exit_code == 0
290+
assert mock_api_client["workspaces"] == ["11111111-1111-1111-1111-111111111111"]
291+
assert mock_api_client["calls"] == [
292+
{
293+
"name": "team-notes",
294+
"path": "team-notes",
295+
"local_sync_path": None,
296+
"set_default": False,
297+
"visibility": "workspace",
298+
}
299+
]
300+
301+
config_data = json.loads(mock_config.read_text())
302+
entry = config_data["projects"]["team-notes"]
303+
assert entry["path"] == ""
304+
assert entry["mode"] == "cloud"
305+
assert entry["workspace_id"] == "11111111-1111-1111-1111-111111111111"
306+
assert entry["local_sync_path"] is None
307+
308+
254309
def test_project_add_visibility_requires_cloud_mode(runner, mock_config, tmp_path):
255310
"""Visibility is a cloud-only option."""
256311
project_path = tmp_path / "local-project"

0 commit comments

Comments
 (0)