feat: CLI refactoring + workspace-aware cloud project listing

Refactor CLI commands to use typed ProjectClient instead of raw HTTP calls,
and add workspace metadata to cloud project listings so users can distinguish
personal vs organization projects.

Key changes:
- 🔧 CLI commands now use ProjectClient typed API clients instead of
  call_get/call_post with manual URL construction
- 🏢 Cloud project listings include workspace_name, workspace_type, and
  workspace_tenant_id for each cloud-sourced project
- Pass config.default_workspace when fetching cloud projects via
  _fetch_cloud_projects() and CLI list_projects
- Add --workspace flag to `bm project list` for explicit workspace override
- Add "Workspace" column to CLI project list table
- Add `bm tool list-projects` and `bm tool list-workspaces` JSON commands
- Comprehensive tests for workspace passthrough, merge behavior, and CLI routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2026-02-21 20:04:03 -06:00
parent 2cde8d2659
commit 2e5813d31e
29 changed files with 1895 additions and 319 deletions
@@ -11,7 +11,6 @@ import typer
from rich.console import Console
from basic_memory.cli.app import cloud_app
from basic_memory.cli.auth import CLIAuth
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
from basic_memory.cli.commands.cloud.rclone_commands import (
RcloneError,
@@ -25,8 +24,8 @@ from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing
from basic_memory.config import ConfigManager, ProjectEntry
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_get, call_post
from basic_memory.schemas.project_info import ProjectItem, ProjectList
from basic_memory.mcp.clients import ProjectClient
from basic_memory.schemas.project_info import ProjectItem
from basic_memory.utils import generate_permalink, normalize_project_path
console = Console()
@@ -37,11 +36,9 @@ console = Console()
def _has_cloud_credentials(config) -> bool:
"""Return whether cloud credentials are available (API key or OAuth token)."""
if config.cloud_api_key:
return True
from basic_memory.config import has_cloud_credentials
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
return auth.load_tokens() is not None
return has_cloud_credentials(config)
def _require_cloud_credentials(config) -> None:
@@ -57,8 +54,7 @@ def _require_cloud_credentials(config) -> None:
async def _get_cloud_project(name: str) -> ProjectItem | None:
"""Fetch a project by name from the cloud API."""
async with get_client() as client:
response = await call_get(client, "/v2/projects/")
projects_list = ProjectList.model_validate(response.json())
projects_list = await ProjectClient(client).list_projects()
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
return proj
@@ -132,12 +128,9 @@ def sync_project_command(
async def _trigger_db_sync():
async with get_client() as client:
response = await call_post(
client,
f"/v2/projects/{project_data.external_id}/sync?force_full=true",
json={},
return await ProjectClient(client).sync(
project_data.external_id, force_full=True
)
return response.json()
try:
with force_routing(cloud=True):
@@ -210,12 +203,9 @@ def bisync_project_command(
async def _trigger_db_sync():
async with get_client() as client:
response = await call_post(
client,
f"/v2/projects/{project_data.external_id}/sync?force_full=true",
json={},
return await ProjectClient(client).sync(
project_data.external_id, force_full=True
)
return response.json()
try:
with force_routing(cloud=True):
@@ -329,9 +319,8 @@ def setup_project_sync(
async def _verify_project_exists():
"""Verify the project exists on cloud by listing all projects."""
async with get_client() as client:
response = await call_get(client, "/v2/projects/")
project_list = response.json()
project_names = [p["name"] for p in project_list["projects"]]
projects_list = await ProjectClient(client).list_projects()
project_names = [p.name for p in projects_list.projects]
if name not in project_names:
raise ValueError(f"Project '{name}' not found on cloud")
return True
+15 -5
View File
@@ -10,7 +10,6 @@ import httpx
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_put
# Archive file extensions that should be skipped during upload
ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".tbz2"}
@@ -24,7 +23,7 @@ async def upload_path(
dry_run: bool = False,
*,
client_cm_factory: Callable[[], AbstractAsyncContextManager[httpx.AsyncClient]] | None = None,
put_func=call_put,
put_func: Callable | None = None,
) -> bool:
"""
Upload a file or directory to cloud project via WebDAV.
@@ -117,9 +116,20 @@ async def upload_path(
# Upload via HTTP PUT to WebDAV endpoint with mtime header
# Using X-OC-Mtime (ownCloud/Nextcloud standard)
response = await put_func(
client, remote_path, content=content, headers={"X-OC-Mtime": str(mtime)}
)
if put_func is not None:
# Test injection path
response = await put_func(
client,
remote_path,
content=content,
headers={"X-OC-Mtime": str(mtime)},
)
else:
response = await client.put(
remote_path,
content=content,
headers={"X-OC-Mtime": str(mtime)},
)
response.raise_for_status()
# Format total size based on magnitude
@@ -4,9 +4,13 @@ import typer
from rich.console import Console
from rich.table import Table
from basic_memory.cli.app import cloud_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.mcp.project_context import get_available_workspaces
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
_workspace_choices,
_workspace_matches_identifier,
get_available_workspaces,
)
console = Console()
@@ -33,18 +37,77 @@ def list_workspaces() -> None:
console.print("[yellow]No accessible workspaces found.[/yellow]")
return
config = ConfigManager().config
default_ws = config.default_workspace
table = Table(title="Available Workspaces")
table.add_column("Name", style="cyan")
table.add_column("Type", style="blue")
table.add_column("Role", style="green")
table.add_column("Tenant ID", style="yellow")
table.add_column("Default", style="magenta")
for workspace in workspaces:
is_default = "[X]" if workspace.tenant_id == default_ws else ""
table.add_row(
workspace.name,
workspace.workspace_type,
workspace.role,
workspace.tenant_id,
is_default,
)
console.print(table)
@workspace_app.command("set-default")
def set_default_workspace(
identifier: str = typer.Argument(..., help="Workspace name or tenant_id to set as default"),
) -> None:
"""Set the default cloud workspace.
The default workspace is used as fallback when no per-project workspace
is configured. Resolves the identifier against available workspaces.
Examples:
bm cloud workspace set-default Personal
bm cloud workspace set-default 11111111-1111-1111-1111-111111111111
"""
async def _list():
return await get_available_workspaces()
try:
workspaces = run_with_cleanup(_list())
except RuntimeError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1)
if not workspaces:
console.print("[yellow]No accessible workspaces found.[/yellow]")
raise typer.Exit(1)
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, identifier)]
if not matches:
console.print(f"[red]Error: Workspace '{identifier}' not found[/red]")
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 '{identifier}' matches multiple workspaces. "
f"Use tenant_id instead.[/red]"
)
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
raise typer.Exit(1)
selected = matches[0]
config_manager = ConfigManager()
config = config_manager.config
config.default_workspace = selected.tenant_id
config_manager.save_config(config)
console.print(
f"[green]Default workspace set to '{selected.name}' ({selected.tenant_id})[/green]"
)