Skip to content

Commit 2e5813d

Browse files
phernandezclaude
andcommitted
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>
1 parent 2cde8d2 commit 2e5813d

29 files changed

Lines changed: 1896 additions & 320 deletions

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

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from rich.console import Console
1212

1313
from basic_memory.cli.app import cloud_app
14-
from basic_memory.cli.auth import CLIAuth
1514
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
1615
from basic_memory.cli.commands.cloud.rclone_commands import (
1716
RcloneError,
@@ -25,8 +24,8 @@
2524
from basic_memory.cli.commands.routing import force_routing
2625
from basic_memory.config import ConfigManager, ProjectEntry
2726
from basic_memory.mcp.async_client import get_client
28-
from basic_memory.mcp.tools.utils import call_get, call_post
29-
from basic_memory.schemas.project_info import ProjectItem, ProjectList
27+
from basic_memory.mcp.clients import ProjectClient
28+
from basic_memory.schemas.project_info import ProjectItem
3029
from basic_memory.utils import generate_permalink, normalize_project_path
3130

3231
console = Console()
@@ -37,11 +36,9 @@
3736

3837
def _has_cloud_credentials(config) -> bool:
3938
"""Return whether cloud credentials are available (API key or OAuth token)."""
40-
if config.cloud_api_key:
41-
return True
39+
from basic_memory.config import has_cloud_credentials
4240

43-
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
44-
return auth.load_tokens() is not None
41+
return has_cloud_credentials(config)
4542

4643

4744
def _require_cloud_credentials(config) -> None:
@@ -57,8 +54,7 @@ def _require_cloud_credentials(config) -> None:
5754
async def _get_cloud_project(name: str) -> ProjectItem | None:
5855
"""Fetch a project by name from the cloud API."""
5956
async with get_client() as client:
60-
response = await call_get(client, "/v2/projects/")
61-
projects_list = ProjectList.model_validate(response.json())
57+
projects_list = await ProjectClient(client).list_projects()
6258
for proj in projects_list.projects:
6359
if generate_permalink(proj.name) == generate_permalink(name):
6460
return proj
@@ -132,12 +128,9 @@ def sync_project_command(
132128

133129
async def _trigger_db_sync():
134130
async with get_client() as client:
135-
response = await call_post(
136-
client,
137-
f"/v2/projects/{project_data.external_id}/sync?force_full=true",
138-
json={},
131+
return await ProjectClient(client).sync(
132+
project_data.external_id, force_full=True
139133
)
140-
return response.json()
141134

142135
try:
143136
with force_routing(cloud=True):
@@ -210,12 +203,9 @@ def bisync_project_command(
210203

211204
async def _trigger_db_sync():
212205
async with get_client() as client:
213-
response = await call_post(
214-
client,
215-
f"/v2/projects/{project_data.external_id}/sync?force_full=true",
216-
json={},
206+
return await ProjectClient(client).sync(
207+
project_data.external_id, force_full=True
217208
)
218-
return response.json()
219209

220210
try:
221211
with force_routing(cloud=True):
@@ -329,9 +319,8 @@ def setup_project_sync(
329319
async def _verify_project_exists():
330320
"""Verify the project exists on cloud by listing all projects."""
331321
async with get_client() as client:
332-
response = await call_get(client, "/v2/projects/")
333-
project_list = response.json()
334-
project_names = [p["name"] for p in project_list["projects"]]
322+
projects_list = await ProjectClient(client).list_projects()
323+
project_names = [p.name for p in projects_list.projects]
335324
if name not in project_names:
336325
raise ValueError(f"Project '{name}' not found on cloud")
337326
return True

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
1212
from basic_memory.mcp.async_client import get_client
13-
from basic_memory.mcp.tools.utils import call_put
1413

1514
# Archive file extensions that should be skipped during upload
1615
ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".tbz2"}
@@ -24,7 +23,7 @@ async def upload_path(
2423
dry_run: bool = False,
2524
*,
2625
client_cm_factory: Callable[[], AbstractAsyncContextManager[httpx.AsyncClient]] | None = None,
27-
put_func=call_put,
26+
put_func: Callable | None = None,
2827
) -> bool:
2928
"""
3029
Upload a file or directory to cloud project via WebDAV.
@@ -117,9 +116,20 @@ async def upload_path(
117116

118117
# Upload via HTTP PUT to WebDAV endpoint with mtime header
119118
# Using X-OC-Mtime (ownCloud/Nextcloud standard)
120-
response = await put_func(
121-
client, remote_path, content=content, headers={"X-OC-Mtime": str(mtime)}
122-
)
119+
if put_func is not None:
120+
# Test injection path
121+
response = await put_func(
122+
client,
123+
remote_path,
124+
content=content,
125+
headers={"X-OC-Mtime": str(mtime)},
126+
)
127+
else:
128+
response = await client.put(
129+
remote_path,
130+
content=content,
131+
headers={"X-OC-Mtime": str(mtime)},
132+
)
123133
response.raise_for_status()
124134

125135
# Format total size based on magnitude

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

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
from rich.console import Console
55
from rich.table import Table
66

7-
from basic_memory.cli.app import cloud_app
87
from basic_memory.cli.commands.command_utils import run_with_cleanup
9-
from basic_memory.mcp.project_context import get_available_workspaces
8+
from basic_memory.config import ConfigManager
9+
from basic_memory.mcp.project_context import (
10+
_workspace_choices,
11+
_workspace_matches_identifier,
12+
get_available_workspaces,
13+
)
1014

1115
console = Console()
1216

@@ -33,18 +37,77 @@ async def _list():
3337
console.print("[yellow]No accessible workspaces found.[/yellow]")
3438
return
3539

40+
config = ConfigManager().config
41+
default_ws = config.default_workspace
42+
3643
table = Table(title="Available Workspaces")
3744
table.add_column("Name", style="cyan")
3845
table.add_column("Type", style="blue")
3946
table.add_column("Role", style="green")
4047
table.add_column("Tenant ID", style="yellow")
48+
table.add_column("Default", style="magenta")
4149

4250
for workspace in workspaces:
51+
is_default = "[X]" if workspace.tenant_id == default_ws else ""
4352
table.add_row(
4453
workspace.name,
4554
workspace.workspace_type,
4655
workspace.role,
4756
workspace.tenant_id,
57+
is_default,
4858
)
4959

5060
console.print(table)
61+
62+
63+
@workspace_app.command("set-default")
64+
def set_default_workspace(
65+
identifier: str = typer.Argument(..., help="Workspace name or tenant_id to set as default"),
66+
) -> None:
67+
"""Set the default cloud workspace.
68+
69+
The default workspace is used as fallback when no per-project workspace
70+
is configured. Resolves the identifier against available workspaces.
71+
72+
Examples:
73+
bm cloud workspace set-default Personal
74+
bm cloud workspace set-default 11111111-1111-1111-1111-111111111111
75+
"""
76+
77+
async def _list():
78+
return await get_available_workspaces()
79+
80+
try:
81+
workspaces = run_with_cleanup(_list())
82+
except RuntimeError as exc:
83+
console.print(f"[red]Error: {exc}[/red]")
84+
raise typer.Exit(1)
85+
86+
if not workspaces:
87+
console.print("[yellow]No accessible workspaces found.[/yellow]")
88+
raise typer.Exit(1)
89+
90+
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, identifier)]
91+
92+
if not matches:
93+
console.print(f"[red]Error: Workspace '{identifier}' not found[/red]")
94+
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
95+
raise typer.Exit(1)
96+
97+
if len(matches) > 1:
98+
console.print(
99+
f"[red]Error: Workspace name '{identifier}' matches multiple workspaces. "
100+
f"Use tenant_id instead.[/red]"
101+
)
102+
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
103+
raise typer.Exit(1)
104+
105+
selected = matches[0]
106+
config_manager = ConfigManager()
107+
config = config_manager.config
108+
config.default_workspace = selected.tenant_id
109+
config_manager.save_config(config)
110+
111+
console.print(
112+
f"[green]Default workspace set to '{selected.name}' ({selected.tenant_id})[/green]"
113+
)

src/basic_memory/cli/commands/command_utils.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,8 @@
1111
from basic_memory import db
1212
from basic_memory.config import ConfigManager
1313
from basic_memory.mcp.async_client import get_client
14-
from basic_memory.mcp.tools.utils import call_post, call_get
14+
from basic_memory.mcp.clients import ProjectClient
1515
from basic_memory.mcp.project_context import get_active_project
16-
from basic_memory.schemas import ProjectInfoResponse
1716

1817
console = Console()
1918

@@ -61,16 +60,12 @@ async def run_sync(
6160
try:
6261
async with get_client(project_name=project) as client:
6362
project_item = await get_active_project(client, project, None)
64-
url = f"/v2/projects/{project_item.external_id}/sync"
65-
params = []
66-
if force_full:
67-
params.append("force_full=true")
68-
if not run_in_background:
69-
params.append("run_in_background=false")
70-
if params:
71-
url += "?" + "&".join(params)
72-
response = await call_post(client, url)
73-
data = response.json()
63+
project_client = ProjectClient(client)
64+
data = await project_client.sync(
65+
project_item.external_id,
66+
force_full=force_full,
67+
run_in_background=run_in_background,
68+
)
7469
# Background mode returns {"message": "..."}, foreground returns SyncReportResponse
7570
if "message" in data:
7671
console.print(f"[green]{data['message']}[/green]")
@@ -94,8 +89,7 @@ async def get_project_info(project: str):
9489
try:
9590
async with get_client(project_name=project) as client:
9691
project_item = await get_active_project(client, project, None)
97-
response = await call_get(client, f"/v2/projects/{project_item.external_id}/info")
98-
return ProjectInfoResponse.model_validate(response.json())
92+
return await ProjectClient(client).get_info(project_item.external_id)
9993
except (ToolError, ValueError) as e:
10094
error_text = str(e)
10195
if "internal proxy error" in error_text.lower() and "not found in configuration" in (

src/basic_memory/cli/commands/doctor.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
2020
from basic_memory.mcp.async_client import get_client
2121
from basic_memory.mcp.clients import KnowledgeClient, ProjectClient, SearchClient
22-
from basic_memory.mcp.tools.utils import call_post
2322
from basic_memory.schemas.base import Entity
2423
from basic_memory.schemas.project_info import ProjectInfoRequest
2524
from basic_memory.schemas.search import SearchQuery
@@ -98,11 +97,10 @@ async def run_doctor() -> None:
9897
await processor.write_file(manual_path, manual_markdown)
9998
console.print("[green]OK[/green] Manual file written")
10099

101-
sync_response = await call_post(
102-
client,
103-
f"/v2/projects/{project_id}/sync?force_full=true&run_in_background=false",
100+
sync_data = await project_client.sync(
101+
project_id, force_full=True, run_in_background=False
104102
)
105-
sync_report = SyncReportResponse.model_validate(sync_response.json())
103+
sync_report = SyncReportResponse.model_validate(sync_data)
106104
if sync_report.total == 0:
107105
raise ValueError("Sync did not detect any changes")
108106

@@ -118,8 +116,7 @@ async def run_doctor() -> None:
118116

119117
console.print("[green]OK[/green] Search confirmed manual file")
120118

121-
status_response = await call_post(client, f"/v2/projects/{project_id}/status")
122-
status_report = SyncReportResponse.model_validate(status_response.json())
119+
status_report = await project_client.get_status(project_id)
123120
if status_report.total != 0:
124121
raise ValueError("Project status not clean after sync")
125122

0 commit comments

Comments
 (0)