Skip to content

Commit 4a80123

Browse files
phernandezclaude
andcommitted
feat: add cloud mode integration to CLI commands with integration tests
Add runtime cloud_mode_enabled checks to all CLI commands that require authentication. Commands now conditionally authenticate based on whether cloud mode is enabled, allowing seamless operation in both local and cloud modes. Changes: - Add cloud_mode_enabled checks to list_projects, remove_project, run_sync, get_project_info, run_status - Remove auth from set_default_project (local-only command) - Create 9 CLI integration tests in test-int/cli/ (project, sync, version commands) - Replace mock-heavy CLI tests with integration tests (deleted 5 mock test files) - Update SPEC-9 Phase 4.2 with completed cloud mode integration tasks All tests passing: 58 total (49 existing + 9 new integration tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d48b1dc commit 4a80123

26 files changed

+523
-887
lines changed

specs/SPEC-9 Multi-Project Bidirectional Sync Architecture.md

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ This spec affects:
165165
- [x] `bm cloud logout`: Clear `BASIC_MEMORY_PROXY_URL` environment variable
166166
- [x] `bm cloud status`: Show current mode (local/cloud), connection status
167167

168+
**1.4 Skip Initialization in Cloud Mode**
169+
- [x] Update `ensure_initialization()` to check `cloud_mode` and return early
170+
- [x] Document that `config.projects` is only used in local mode
171+
- [x] Cloud manages its own projects via API, no local reconciliation needed
172+
168173
### Phase 2: Bisync Updates (Multi-Project)
169174

170175
**2.1 Remove RCLONE_TEST Files**
@@ -192,6 +197,16 @@ This spec affects:
192197
- [x] Add `validate_bisync_directory()` safety check
193198
- [x] Update `get_default_mount_path()` to return fixed `~/basic-memory-cloud/`
194199

200+
**2.5 Sync/Status API Infrastructure** ✅ (commit d48b1dc)
201+
- [x] Create `POST /{project}/project/sync` endpoint for background sync
202+
- [x] Create `POST /{project}/project/status` endpoint for scan-only status
203+
- [x] Create `SyncReportResponse` Pydantic schema
204+
- [x] Refactor CLI `sync` command to use API endpoint
205+
- [x] Refactor CLI `status` command to use API endpoint
206+
- [x] Create `command_utils.py` with shared `run_sync()` function
207+
- [x] Update `notify_container_sync()` to call `run_sync()` for each project
208+
- [x] Update all tests to match new API-based implementation
209+
195210
### Phase 3: Sync Command Dual Mode
196211

197212
**3.1 Update `bm sync` Command**
@@ -207,15 +222,36 @@ This spec affects:
207222
- [ ] Handle errors gracefully, continue on failure
208223
- [ ] Show sync progress and status
209224

210-
### Phase 4: Remove Duplicate Commands
211-
212-
**4.1 Delete `bm cloud project` Commands**
213-
- [ ] Remove `bm cloud project list` (use `bm project list`)
214-
- [ ] Remove `bm cloud project add` (use `bm project add`)
215-
- [ ] Update `core_commands.py` to remove project_app subcommands
216-
- [ ] Keep only: `login`, `logout`, `status`, `setup`, `mount`, `unmount`
217-
218-
**4.2 Update Documentation**
225+
### Phase 4: Remove Duplicate Commands & Cloud Mode Auth ✅
226+
227+
**4.0 Cloud Mode Authentication**
228+
- [x] Update `async_client.py` to support dual auth sources
229+
- [x] FastMCP context auth (cloud service mode) via `inject_auth_header()`
230+
- [x] JWT token file auth (CLI cloud mode) via `CLIAuth.get_valid_token()`
231+
- [x] Automatic token refresh for CLI cloud mode
232+
- [x] Remove `BASIC_MEMORY_PROXY_URL` environment variable dependency
233+
- [x] Simplify to use only `config.cloud_mode` + `config.cloud_host`
234+
235+
**4.1 Delete `bm cloud project` Commands**
236+
- [x] Remove `bm cloud project list` (use `bm project list`)
237+
- [x] Remove `bm cloud project add` (use `bm project add`)
238+
- [x] Update `core_commands.py` to remove project_app subcommands
239+
- [x] Keep only: `login`, `logout`, `status`, `setup`, `mount`, `unmount`, bisync commands
240+
- [x] Remove unused imports (Table, generate_permalink, os)
241+
- [x] Clean up environment variable references in login/logout
242+
243+
**4.2 CLI Command Cloud Mode Integration**
244+
- [x] Add runtime `cloud_mode_enabled` checks to all CLI commands
245+
- [x] Update `list_projects()` to conditionally authenticate based on cloud mode
246+
- [x] Update `remove_project()` to conditionally authenticate based on cloud mode
247+
- [x] Update `run_sync()` to conditionally authenticate based on cloud mode
248+
- [x] Update `get_project_info()` to conditionally authenticate based on cloud mode
249+
- [x] Update `run_status()` to conditionally authenticate based on cloud mode
250+
- [x] Remove auth from `set_default_project()` (local-only command, no cloud version)
251+
- [x] Create CLI integration tests (`test-int/cli/`) to validate both local and cloud modes
252+
- [x] Replace mock-heavy CLI tests with integration tests (deleted 5 mock test files)
253+
254+
**4.3 Update Documentation**
219255
- [ ] Update `cloud-cli.md` with cloud mode toggle workflow
220256
- [ ] Document `bm cloud login` → use normal commands
221257
- [ ] Add examples of cloud mode usage
@@ -389,10 +425,13 @@ Bidirectional sync via rclone bisync
389425
# Syncs ALL project subdirectories bidirectionally
390426
```
391427

392-
6. **Notify cloud to refresh**
428+
6. **Notify cloud to refresh** (commit d48b1dc)
393429
```python
394-
# POST /proxy/sync (via async_client)
395-
# Cloud sync service updates database from /app/data/
430+
# After rclone bisync completes, sync each project's database
431+
for project in cloud_projects:
432+
# POST /{project}/project/sync (via async_client)
433+
# Triggers background sync for this project
434+
await run_sync(project=project_name)
396435
```
397436

398437
### Key Changes

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ def get_cloud_config() -> tuple[str, str, str]:
2626

2727

2828
async def get_authenticated_headers() -> dict[str, str]:
29-
"""Get authentication headers with JWT token."""
29+
"""
30+
Get authentication headers with JWT token.
31+
handles jwt refresh if needed.
32+
"""
3033
client_id, domain, _ = get_cloud_config()
3134
auth = CLIAuth(client_id=client_id, authkit_domain=domain)
3235
token = await auth.get_valid_token()

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

Lines changed: 0 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
"""Core cloud commands for Basic Memory CLI."""
22

33
import asyncio
4-
import os
54
from pathlib import Path
65
from typing import Optional
76

87
import httpx
98
import typer
109
from rich.console import Console
11-
from rich.table import Table
1210

1311
from basic_memory.cli.app import cloud_app
1412
from basic_memory.cli.auth import CLIAuth
@@ -34,7 +32,6 @@
3432
from basic_memory.cli.commands.cloud.rclone_config import MOUNT_PROFILES
3533
from basic_memory.cli.commands.cloud.bisync_commands import BISYNC_PROFILES
3634
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
37-
from basic_memory.utils import generate_permalink
3835

3936
console = Console()
4037

@@ -58,9 +55,6 @@ async def _login():
5855
config.cloud_mode = True
5956
config_manager.save_config(config)
6057

61-
# Set environment variable for current session
62-
os.environ["BASIC_MEMORY_PROXY_URL"] = host_url
63-
6458
console.print("[green]✓ Cloud mode enabled[/green]")
6559
console.print(f"[dim]All CLI commands now work against {host_url}[/dim]")
6660

@@ -77,127 +71,10 @@ def logout():
7771
config.cloud_mode = False
7872
config_manager.save_config(config)
7973

80-
# Clear environment variable
81-
os.environ.pop("BASIC_MEMORY_PROXY_URL", None)
82-
8374
console.print("[green]✓ Cloud mode disabled[/green]")
8475
console.print("[dim]All CLI commands now work locally[/dim]")
8576

8677

87-
# Project commands
88-
89-
project_app = typer.Typer(help="Manage Basic Memory Cloud Projects")
90-
cloud_app.add_typer(project_app, name="project")
91-
92-
93-
@project_app.command("list")
94-
def list_projects() -> None:
95-
"""List projects on the cloud instance."""
96-
97-
try:
98-
# Get cloud configuration
99-
_, _, host_url = get_cloud_config()
100-
host_url = host_url.rstrip("/")
101-
102-
console.print(f"[blue]Fetching projects from {host_url}...[/blue]")
103-
104-
# Make API request to list projects
105-
response = asyncio.run(
106-
make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
107-
)
108-
109-
projects_data = response.json()
110-
111-
if not projects_data.get("projects"):
112-
console.print("[yellow]No projects found on the cloud instance.[/yellow]")
113-
return
114-
115-
# Create table for display
116-
table = Table(
117-
title="Cloud Projects", show_header=True, header_style="bold blue", min_width=60
118-
)
119-
table.add_column("Name", style="green", min_width=20)
120-
table.add_column("Path", style="dim", min_width=30)
121-
122-
for project in projects_data["projects"]:
123-
# Format the path for display
124-
path = project.get("path", "")
125-
if path.startswith("/"):
126-
path = f"~{path}" if path.startswith(str(Path.home())) else path
127-
128-
table.add_row(
129-
project.get("name", "unnamed"),
130-
path,
131-
)
132-
133-
console.print(table)
134-
console.print(f"\n[green]Found {len(projects_data['projects'])} project(s)[/green]")
135-
136-
except CloudAPIError as e:
137-
console.print(f"[red]Error: {e}[/red]")
138-
raise typer.Exit(1)
139-
except Exception as e:
140-
console.print(f"[red]Unexpected error: {e}[/red]")
141-
raise typer.Exit(1)
142-
143-
144-
@project_app.command("add")
145-
def add_project(
146-
name: str = typer.Argument(..., help="Name of the project to add"),
147-
set_default: bool = typer.Option(False, "--default", "-d", help="Set as default project"),
148-
) -> None:
149-
"""Create a new project on the cloud instance."""
150-
151-
# Get cloud configuration
152-
_, _, host_url = get_cloud_config()
153-
host_url = host_url.rstrip("/")
154-
155-
# Prepare headers
156-
headers = {"Content-Type": "application/json"}
157-
158-
project_path = generate_permalink(name)
159-
# Prepare project data
160-
project_data = {
161-
"name": name,
162-
"path": project_path,
163-
"set_default": set_default,
164-
}
165-
166-
console.print(project_data)
167-
168-
try:
169-
console.print(f"[blue]Creating project '{name}' on {host_url}...[/blue]")
170-
171-
# Make API request to create project
172-
response = asyncio.run(
173-
make_api_request(
174-
method="POST",
175-
url=f"{host_url}/proxy/projects/projects",
176-
headers=headers,
177-
json_data=project_data,
178-
)
179-
)
180-
181-
result = response.json()
182-
183-
console.print(f"[green]Project '{name}' created successfully![/green]")
184-
185-
# Display project details
186-
if "project" in result:
187-
project = result["project"]
188-
console.print(f" Name: {project.get('name', name)}")
189-
console.print(f" Path: {project.get('path', 'unknown')}")
190-
if project.get("id"):
191-
console.print(f" ID: {project['id']}")
192-
193-
except CloudAPIError as e:
194-
console.print(f"[red]Error creating project: {e}[/red]")
195-
raise typer.Exit(1)
196-
except Exception as e:
197-
console.print(f"[red]Unexpected error: {e}[/red]")
198-
raise typer.Exit(1)
199-
200-
20178
@cloud_app.command("upload")
20279
def upload_files(
20380
project: str = typer.Argument(..., help="Project name to upload to"),

src/basic_memory/cli/commands/command_utils.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
from mcp.server.fastmcp.exceptions import ToolError
66
import typer
7+
78
from rich.console import Console
9+
10+
from basic_memory.cli.commands.cloud import get_authenticated_headers
811
from basic_memory.mcp.async_client import client
912

10-
from basic_memory.mcp.tools.utils import call_post
13+
from basic_memory.mcp.tools.utils import call_post, call_get
1114
from basic_memory.mcp.project_context import get_active_project
15+
from basic_memory.schemas import ProjectInfoResponse
1216

1317
console = Console()
1418

@@ -17,10 +21,40 @@ async def run_sync(project: Optional[str] = None):
1721
"""Run sync operation via API endpoint."""
1822

1923
try:
20-
project_item = await get_active_project(client, project, None)
21-
response = await call_post(client, f"{project_item.project_url}/project/sync")
24+
from basic_memory.config import ConfigManager
25+
26+
config = ConfigManager().config
27+
auth_headers = {}
28+
if config.cloud_mode_enabled:
29+
auth_headers = await get_authenticated_headers()
30+
31+
project_item = await get_active_project(client, project, None, headers=auth_headers)
32+
response = await call_post(
33+
client, f"{project_item.project_url}/project/sync", headers=auth_headers
34+
)
2235
data = response.json()
2336
console.print(f"[green]✓ {data['message']}[/green]")
2437
except (ToolError, ValueError) as e:
2538
console.print(f"[red]✗ Sync failed: {e}[/red]")
2639
raise typer.Exit(1)
40+
41+
42+
async def get_project_info(project: str):
43+
"""Run sync operation via API endpoint."""
44+
45+
try:
46+
from basic_memory.config import ConfigManager
47+
48+
config = ConfigManager().config
49+
auth_headers = {}
50+
if config.cloud_mode_enabled:
51+
auth_headers = await get_authenticated_headers()
52+
53+
project_item = await get_active_project(client, project, None, headers=auth_headers)
54+
response = await call_get(
55+
client, f"{project_item.project_url}/project/info", headers=auth_headers
56+
)
57+
return ProjectInfoResponse.model_validate(response.json())
58+
except (ToolError, ValueError) as e:
59+
console.print(f"[red]✗ Sync failed: {e}[/red]")
60+
raise typer.Exit(1)

0 commit comments

Comments
 (0)