44import os
55from datetime import datetime
66from pathlib import Path
7+ from typing import cast
78
89import typer
910from rich .console import Console , Group
2728from basic_memory .config import ConfigManager , ProjectEntry , ProjectMode
2829from basic_memory .mcp .async_client import get_client
2930from basic_memory .mcp .clients import ProjectClient
31+ from basic_memory .schemas .cloud import ProjectVisibility
3032from basic_memory .schemas .project_info import ProjectItem , ProjectList
3133from 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" )
60113def 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 ("\n Next 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 :
0 commit comments