feat(cli): support cloud project visibility on add (#715)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-04-03 17:30:09 -05:00
committed by GitHub
parent 2d1ccfa36c
commit cff31c5797
5 changed files with 318 additions and 55 deletions
@@ -7,6 +7,7 @@ from basic_memory.schemas.cloud import (
CloudProjectList,
CloudProjectCreateRequest,
CloudProjectCreateResponse,
ProjectVisibility,
)
from basic_memory.utils import generate_permalink
@@ -63,6 +64,7 @@ async def create_cloud_project(
project_name: str,
*,
workspace: str | None = None,
visibility: ProjectVisibility = "workspace",
api_request=make_api_request,
) -> CloudProjectCreateResponse:
"""Create a new project on cloud.
@@ -70,6 +72,7 @@ async def create_cloud_project(
Args:
project_name: Name of project to create
workspace: Optional workspace override for tenant-scoped project creation
visibility: Visibility for the created cloud project
Returns:
CloudProjectCreateResponse with project details from API
@@ -86,6 +89,7 @@ async def create_cloud_project(
name=project_name,
path=project_path,
set_default=False,
visibility=visibility,
)
response = await api_request(
+103 -53
View File
@@ -4,6 +4,7 @@ import json
import os
from datetime import datetime
from pathlib import Path
from typing import cast
import typer
from rich.console import Console, Group
@@ -27,6 +28,7 @@ from basic_memory.cli.commands.routing import force_routing, validate_routing_fl
from basic_memory.config import ConfigManager, ProjectEntry, ProjectMode
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.cloud import ProjectVisibility
from basic_memory.schemas.project_info import ProjectItem, ProjectList
from basic_memory.utils import generate_permalink, normalize_project_path
@@ -56,6 +58,57 @@ def make_bar(value: int, max_value: int, width: int = 40) -> Text:
return bar
def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility:
"""Normalize CLI visibility input to the cloud API contract."""
if visibility is None:
return "workspace"
normalized = visibility.strip().lower()
if normalized in {"workspace", "shared", "private"}:
return cast(ProjectVisibility, normalized)
raise ValueError("Invalid visibility. Expected one of: workspace, shared, private.")
def _resolve_workspace_id(config, workspace: str | None) -> str | None:
"""Resolve a workspace name or tenant_id to a tenant_id."""
from basic_memory.mcp.project_context import (
_workspace_choices,
_workspace_matches_identifier,
get_available_workspaces,
)
if workspace is not None:
workspaces = run_with_cleanup(get_available_workspaces())
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, workspace)]
if not matches:
console.print(f"[red]Error: Workspace '{workspace}' not found[/red]")
if workspaces:
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
if len(matches) > 1:
console.print(
f"[red]Error: Workspace name '{workspace}' matches multiple workspaces. "
f"Use tenant_id instead.[/red]"
)
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
return matches[0].tenant_id
if config.default_workspace:
return config.default_workspace
try:
workspaces = run_with_cleanup(get_available_workspaces())
if len(workspaces) == 1:
return workspaces[0].tenant_id
except Exception:
# Workspace resolution is optional until a command needs a specific tenant.
pass
return None
@project_app.command("list")
def list_projects(
local: bool = typer.Option(False, "--local", help="Force local routing for this command"),
@@ -257,6 +310,16 @@ def add_project(
local_path: str = typer.Option(
None, "--local-path", help="Local sync path for cloud mode (optional)"
),
workspace: str = typer.Option(
None,
"--workspace",
help="Cloud workspace name or tenant_id (cloud mode only)",
),
visibility: str = typer.Option(
None,
"--visibility",
help="Cloud project visibility: workspace, shared, or private",
),
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
@@ -271,6 +334,8 @@ def add_project(
Cloud mode examples:\n
bm project add research # No local sync\n
bm project add research --local-path ~/docs # With local sync\n
bm project add research --cloud --visibility shared\n
bm project add research --cloud --workspace Personal --visibility shared\n
Local mode example:\n
bm project add research ~/Documents/research
@@ -285,6 +350,7 @@ def add_project(
# Determine effective mode: default local, cloud only when explicitly requested.
effective_cloud_mode = cloud and not local
resolved_workspace_id: str | None = None
# Resolve local sync path early (needed for both cloud and local mode)
local_sync_path: str | None = None
@@ -293,18 +359,31 @@ def add_project(
if effective_cloud_mode:
_require_cloud_credentials(config)
try:
resolved_visibility = _normalize_project_visibility(visibility)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
resolved_workspace_id = _resolve_workspace_id(config, workspace)
# Cloud mode: path auto-generated from name, local sync is optional
async def _add_project():
async with get_client() as client:
async with get_client(workspace=resolved_workspace_id) as client:
data = {
"name": name,
"path": generate_permalink(name),
"local_sync_path": local_sync_path,
"set_default": set_default,
"visibility": resolved_visibility,
}
return await ProjectClient(client).create_project(data)
else:
if workspace is not None:
console.print("[red]Error: --workspace is only supported in cloud mode[/red]")
raise typer.Exit(1)
if visibility is not None:
console.print("[red]Error: --visibility is only supported in cloud mode[/red]")
raise typer.Exit(1)
# Local mode: path is required
if path is None:
console.print("[red]Error: path argument is required in local mode[/red]")
@@ -323,25 +402,34 @@ def add_project(
result = run_with_cleanup(_add_project())
console.print(f"[green]{result.message}[/green]")
# Trigger: local config needs enough metadata to route future commands back to cloud.
# Why: explicit workspace selection and local sync state should persist across CLI sessions.
# Outcome: cloud-backed projects keep cloud mode, workspace_id, and optional local sync path.
if effective_cloud_mode and (local_sync_path or resolved_workspace_id):
entry = config.projects.get(name)
if entry:
entry.mode = ProjectMode.CLOUD
if local_sync_path:
entry.path = local_sync_path
entry.local_sync_path = local_sync_path
if resolved_workspace_id:
entry.workspace_id = resolved_workspace_id
else:
# Project may not be in local config yet (cloud-only add)
config.projects[name] = ProjectEntry(
path=local_sync_path or "",
mode=ProjectMode.CLOUD,
local_sync_path=local_sync_path,
workspace_id=resolved_workspace_id,
)
ConfigManager().save_config(config)
# Save local sync path to config if in cloud mode
if effective_cloud_mode and local_sync_path:
# Create local directory if it doesn't exist
local_dir = Path(local_sync_path)
local_dir.mkdir(parents=True, exist_ok=True)
# Update project entry — path is always the local directory
entry = config.projects.get(name)
if entry:
entry.path = local_sync_path
entry.local_sync_path = local_sync_path
else:
# Project may not be in local config yet (cloud-only add)
config.projects[name] = ProjectEntry(
path=local_sync_path,
local_sync_path=local_sync_path,
)
ConfigManager().save_config(config)
console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run")
@@ -575,45 +663,7 @@ def set_cloud(
console.print("[dim]Run 'bm cloud api-key save <key>' or 'bm cloud login' first[/dim]")
raise typer.Exit(1)
# --- Resolve workspace to tenant_id ---
resolved_workspace_id: str | None = None
if workspace is not None:
# Explicit --workspace: resolve to tenant_id via cloud lookup
from basic_memory.mcp.project_context import (
get_available_workspaces,
_workspace_matches_identifier,
_workspace_choices,
)
workspaces = run_with_cleanup(get_available_workspaces())
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, workspace)]
if not matches:
console.print(f"[red]Error: Workspace '{workspace}' not found[/red]")
if workspaces:
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
if len(matches) > 1:
console.print(
f"[red]Error: Workspace name '{workspace}' matches multiple workspaces. "
f"Use tenant_id instead.[/red]"
)
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
resolved_workspace_id = matches[0].tenant_id
elif config.default_workspace:
# Fall back to global default
resolved_workspace_id = config.default_workspace
else:
# Try auto-select if single workspace
try:
from basic_memory.mcp.project_context import get_available_workspaces
workspaces = run_with_cleanup(get_available_workspaces())
if len(workspaces) == 1:
resolved_workspace_id = workspaces[0].tenant_id
except Exception:
pass # Workspace resolution is optional at set-cloud time
resolved_workspace_id = _resolve_workspace_id(config, workspace)
config.set_project_mode(name, ProjectMode.CLOUD)
if resolved_workspace_id: