Skip to content

Commit cff31c5

Browse files
authored
feat(cli): support cloud project visibility on add (#715)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 2d1ccfa commit cff31c5

File tree

5 files changed

+315
-52
lines changed

5 files changed

+315
-52
lines changed

src/basic_memory/cli/commands/cloud/cloud_utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
CloudProjectList,
88
CloudProjectCreateRequest,
99
CloudProjectCreateResponse,
10+
ProjectVisibility,
1011
)
1112
from basic_memory.utils import generate_permalink
1213

@@ -63,13 +64,15 @@ async def create_cloud_project(
6364
project_name: str,
6465
*,
6566
workspace: str | None = None,
67+
visibility: ProjectVisibility = "workspace",
6668
api_request=make_api_request,
6769
) -> CloudProjectCreateResponse:
6870
"""Create a new project on cloud.
6971
7072
Args:
7173
project_name: Name of project to create
7274
workspace: Optional workspace override for tenant-scoped project creation
75+
visibility: Visibility for the created cloud project
7376
7477
Returns:
7578
CloudProjectCreateResponse with project details from API
@@ -86,6 +89,7 @@ async def create_cloud_project(
8689
name=project_name,
8790
path=project_path,
8891
set_default=False,
92+
visibility=visibility,
8993
)
9094

9195
response = await api_request(

src/basic_memory/cli/commands/project.py

Lines changed: 100 additions & 50 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,6 +58,57 @@ def make_bar(value: int, max_value: int, width: int = 40) -> Text:
5658
return bar
5759

5860

61+
def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility:
62+
"""Normalize CLI visibility input to the cloud API contract."""
63+
if visibility is None:
64+
return "workspace"
65+
66+
normalized = visibility.strip().lower()
67+
if normalized in {"workspace", "shared", "private"}:
68+
return cast(ProjectVisibility, normalized)
69+
70+
raise ValueError("Invalid visibility. Expected one of: workspace, shared, private.")
71+
72+
73+
def _resolve_workspace_id(config, workspace: str | None) -> str | None:
74+
"""Resolve a workspace name or tenant_id to a tenant_id."""
75+
from basic_memory.mcp.project_context import (
76+
_workspace_choices,
77+
_workspace_matches_identifier,
78+
get_available_workspaces,
79+
)
80+
81+
if workspace is not None:
82+
workspaces = run_with_cleanup(get_available_workspaces())
83+
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, workspace)]
84+
if not matches:
85+
console.print(f"[red]Error: Workspace '{workspace}' not found[/red]")
86+
if workspaces:
87+
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
88+
raise typer.Exit(1)
89+
if len(matches) > 1:
90+
console.print(
91+
f"[red]Error: Workspace name '{workspace}' matches multiple workspaces. "
92+
f"Use tenant_id instead.[/red]"
93+
)
94+
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
95+
raise typer.Exit(1)
96+
return matches[0].tenant_id
97+
98+
if config.default_workspace:
99+
return config.default_workspace
100+
101+
try:
102+
workspaces = run_with_cleanup(get_available_workspaces())
103+
if len(workspaces) == 1:
104+
return workspaces[0].tenant_id
105+
except Exception:
106+
# Workspace resolution is optional until a command needs a specific tenant.
107+
pass
108+
109+
return None
110+
111+
59112
@project_app.command("list")
60113
def list_projects(
61114
local: bool = typer.Option(False, "--local", help="Force local routing for this command"),
@@ -257,6 +310,16 @@ def add_project(
257310
local_path: str = typer.Option(
258311
None, "--local-path", help="Local sync path for cloud mode (optional)"
259312
),
313+
workspace: str = typer.Option(
314+
None,
315+
"--workspace",
316+
help="Cloud workspace name or tenant_id (cloud mode only)",
317+
),
318+
visibility: str = typer.Option(
319+
None,
320+
"--visibility",
321+
help="Cloud project visibility: workspace, shared, or private",
322+
),
260323
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
261324
local: bool = typer.Option(
262325
False, "--local", help="Force local API routing (ignore cloud mode)"
@@ -271,6 +334,8 @@ def add_project(
271334
Cloud mode examples:\n
272335
bm project add research # No local sync\n
273336
bm project add research --local-path ~/docs # With local sync\n
337+
bm project add research --cloud --visibility shared\n
338+
bm project add research --cloud --workspace Personal --visibility shared\n
274339
275340
Local mode example:\n
276341
bm project add research ~/Documents/research
@@ -285,6 +350,7 @@ def add_project(
285350

286351
# Determine effective mode: default local, cloud only when explicitly requested.
287352
effective_cloud_mode = cloud and not local
353+
resolved_workspace_id: str | None = None
288354

289355
# Resolve local sync path early (needed for both cloud and local mode)
290356
local_sync_path: str | None = None
@@ -293,18 +359,31 @@ def add_project(
293359

294360
if effective_cloud_mode:
295361
_require_cloud_credentials(config)
362+
try:
363+
resolved_visibility = _normalize_project_visibility(visibility)
364+
except ValueError as e:
365+
console.print(f"[red]Error: {e}[/red]")
366+
raise typer.Exit(1)
367+
resolved_workspace_id = _resolve_workspace_id(config, workspace)
296368
# Cloud mode: path auto-generated from name, local sync is optional
297369

298370
async def _add_project():
299-
async with get_client() as client:
371+
async with get_client(workspace=resolved_workspace_id) as client:
300372
data = {
301373
"name": name,
302374
"path": generate_permalink(name),
303375
"local_sync_path": local_sync_path,
304376
"set_default": set_default,
377+
"visibility": resolved_visibility,
305378
}
306379
return await ProjectClient(client).create_project(data)
307380
else:
381+
if workspace is not None:
382+
console.print("[red]Error: --workspace is only supported in cloud mode[/red]")
383+
raise typer.Exit(1)
384+
if visibility is not None:
385+
console.print("[red]Error: --visibility is only supported in cloud mode[/red]")
386+
raise typer.Exit(1)
308387
# Local mode: path is required
309388
if path is None:
310389
console.print("[red]Error: path argument is required in local mode[/red]")
@@ -323,25 +402,34 @@ async def _add_project():
323402
result = run_with_cleanup(_add_project())
324403
console.print(f"[green]{result.message}[/green]")
325404

326-
# Save local sync path to config if in cloud mode
327-
if effective_cloud_mode and local_sync_path:
328-
# Create local directory if it doesn't exist
329-
local_dir = Path(local_sync_path)
330-
local_dir.mkdir(parents=True, exist_ok=True)
331-
332-
# 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):
333409
entry = config.projects.get(name)
334410
if entry:
335-
entry.path = local_sync_path
336-
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
415+
if resolved_workspace_id:
416+
entry.workspace_id = resolved_workspace_id
337417
else:
338418
# Project may not be in local config yet (cloud-only add)
339419
config.projects[name] = ProjectEntry(
340-
path=local_sync_path,
420+
path=local_sync_path or "",
421+
mode=ProjectMode.CLOUD,
341422
local_sync_path=local_sync_path,
423+
workspace_id=resolved_workspace_id,
342424
)
343425
ConfigManager().save_config(config)
344426

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+
345433
console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
346434
console.print("\nNext steps:")
347435
console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run")
@@ -575,45 +663,7 @@ def set_cloud(
575663
console.print("[dim]Run 'bm cloud api-key save <key>' or 'bm cloud login' first[/dim]")
576664
raise typer.Exit(1)
577665

578-
# --- Resolve workspace to tenant_id ---
579-
resolved_workspace_id: str | None = None
580-
581-
if workspace is not None:
582-
# Explicit --workspace: resolve to tenant_id via cloud lookup
583-
from basic_memory.mcp.project_context import (
584-
get_available_workspaces,
585-
_workspace_matches_identifier,
586-
_workspace_choices,
587-
)
588-
589-
workspaces = run_with_cleanup(get_available_workspaces())
590-
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, workspace)]
591-
if not matches:
592-
console.print(f"[red]Error: Workspace '{workspace}' not found[/red]")
593-
if workspaces:
594-
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
595-
raise typer.Exit(1)
596-
if len(matches) > 1:
597-
console.print(
598-
f"[red]Error: Workspace name '{workspace}' matches multiple workspaces. "
599-
f"Use tenant_id instead.[/red]"
600-
)
601-
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
602-
raise typer.Exit(1)
603-
resolved_workspace_id = matches[0].tenant_id
604-
elif config.default_workspace:
605-
# Fall back to global default
606-
resolved_workspace_id = config.default_workspace
607-
else:
608-
# Try auto-select if single workspace
609-
try:
610-
from basic_memory.mcp.project_context import get_available_workspaces
611-
612-
workspaces = run_with_cleanup(get_available_workspaces())
613-
if len(workspaces) == 1:
614-
resolved_workspace_id = workspaces[0].tenant_id
615-
except Exception:
616-
pass # Workspace resolution is optional at set-cloud time
666+
resolved_workspace_id = _resolve_workspace_id(config, workspace)
617667

618668
config.set_project_mode(name, ProjectMode.CLOUD)
619669
if resolved_workspace_id:

src/basic_memory/schemas/cloud.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
"""Schemas for cloud-related API responses."""
22

3+
from typing import Literal
4+
35
from pydantic import BaseModel, Field
46

7+
type ProjectVisibility = Literal["workspace", "shared", "private"]
8+
59

610
class TenantMountInfo(BaseModel):
711
"""Response from /tenant/mount/info endpoint."""
@@ -36,6 +40,10 @@ class CloudProjectCreateRequest(BaseModel):
3640
name: str = Field(..., description="Project name")
3741
path: str = Field(..., description="Project path (permalink)")
3842
set_default: bool = Field(default=False, description="Set as default project")
43+
visibility: ProjectVisibility = Field(
44+
default="workspace",
45+
description="Project visibility for team workspaces",
46+
)
3947

4048

4149
class CloudProjectCreateResponse(BaseModel):

tests/cli/cloud/test_cloud_api_client_and_utils.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,45 @@ async def api_request(**kwargs):
165165
assert created.new_project["name"] == "My Project"
166166
# Path should be permalink-like (kebab)
167167
assert seen["create_payload"]["path"] == "my-project"
168+
assert seen["create_payload"]["visibility"] == "workspace"
169+
170+
171+
@pytest.mark.asyncio
172+
async def test_create_cloud_project_accepts_visibility_override(config_home, config_manager):
173+
"""Shared cloud helper should pass explicit visibility through to the API payload."""
174+
config = config_manager.load_config()
175+
config.cloud_host = "https://cloud.example.test"
176+
config_manager.save_config(config)
177+
178+
seen_payload: dict | None = None
179+
180+
async def api_request(**kwargs):
181+
nonlocal seen_payload
182+
seen_payload = kwargs["json_data"]
183+
return httpx.Response(
184+
200,
185+
json={
186+
"message": "created",
187+
"status": "success",
188+
"default": False,
189+
"old_project": None,
190+
"new_project": {"name": "shared-project", "path": "shared-project"},
191+
},
192+
)
193+
194+
created = await create_cloud_project(
195+
"Shared Project",
196+
visibility="shared",
197+
api_request=api_request,
198+
)
199+
200+
assert created.new_project is not None
201+
assert seen_payload == {
202+
"name": "Shared Project",
203+
"path": "shared-project",
204+
"set_default": False,
205+
"visibility": "shared",
206+
}
168207

169208

170209
@pytest.mark.asyncio

0 commit comments

Comments
 (0)