feat: add per-project local/cloud routing with API key auth (#555)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2026-02-13 09:52:50 -06:00
committed by GitHub
parent 1428d18de1
commit d84708ca7f
30 changed files with 1319 additions and 212 deletions
+7 -4
View File
@@ -37,10 +37,11 @@ jobs:
run: |
pip install uv
- name: Install just (Linux/macOS)
- name: Install just (Linux)
if: runner.os != 'Windows'
run: |
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
sudo apt-get update
sudo apt-get install -y just
- name: Install just (Windows)
if: runner.os == 'Windows'
@@ -97,7 +98,8 @@ jobs:
- name: Install just
run: |
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
sudo apt-get update
sudo apt-get install -y just
- name: Create virtual env
run: |
@@ -133,7 +135,8 @@ jobs:
- name: Install just
run: |
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
sudo apt-get update
sudo apt-get install -y just
- name: Create virtual env
run: |
+45 -8
View File
@@ -189,27 +189,46 @@ Flow: MCP Tool → Typed Client → HTTP API → Router → Service → Reposito
### Async Client Pattern (Important!)
**All MCP tools and CLI commands use the context manager pattern for HTTP clients:**
**MCP tools use `get_project_client()` for per-project routing:**
```python
from basic_memory.mcp.project_context import get_project_client
@mcp.tool()
async def my_tool(project: str | None = None, context: Context | None = None):
async with get_project_client(project, context) as (client, active_project):
# client is routed based on project's mode (local ASGI or cloud HTTP)
response = await call_get(client, "/path")
return response
```
**CLI commands and non-project-scoped code use `get_client()` directly:**
```python
from basic_memory.mcp.async_client import get_client
async def my_mcp_tool():
async def my_cli_command():
async with get_client() as client:
# Use client for API calls
response = await call_get(client, "/path")
return response
# Per-project routing (when project name is known):
async with get_client(project_name="research") as client:
...
```
**Do NOT use:**
-`from basic_memory.mcp.async_client import client` (deprecated module-level client)
- ❌ Manual auth header management
-`inject_auth_header()` (deleted)
- ❌ Separate `get_client()` + `get_active_project()` in MCP tools (use `get_project_client()` instead)
**Key principles:**
- Auth happens at client creation, not per-request
- Proper resource management via context managers
- Supports three modes: Local (ASGI), CLI cloud (HTTP + auth), Cloud app (factory injection)
- Per-project routing: each project can be LOCAL or CLOUD independently
- Cloud projects use API key (`cloud_api_key` in config) as Bearer token
- Routing priority: factory injection > force-local > per-project cloud > global cloud > local ASGI
- Factory pattern enables dependency injection for cloud consolidation
**For cloud app integration:**
@@ -250,15 +269,19 @@ See SPEC-16 for full context manager refactor details.
- List projects: `basic-memory project list`
- Add project: `basic-memory project add "name" ~/path`
- Project info: `basic-memory project info`
- Set cloud mode: `basic-memory project set-cloud "name"`
- Set local mode: `basic-memory project set-local "name"`
- One-way sync (local -> cloud): `basic-memory project sync`
- Bidirectional sync: `basic-memory project bisync`
- Integrity check: `basic-memory project check`
**Cloud Commands (requires subscription):**
- Authenticate: `basic-memory cloud login`
- Logout: `basic-memory cloud logout`
- Authenticate (global): `basic-memory cloud login`
- Logout (global): `basic-memory cloud logout`
- Check cloud status: `basic-memory cloud status`
- Setup cloud sync: `basic-memory cloud setup`
- Save API key: `basic-memory cloud set-key bmc_...`
- Create API key: `basic-memory cloud create-key "name"`
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
@@ -329,9 +352,22 @@ Basic Memory now supports cloud synchronization and storage (requires active sub
- Background relation resolution (non-blocking startup)
- API performance optimizations (SPEC-11)
**CLI Routing Flags:**
**Per-Project Cloud Routing:**
When cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
Individual projects can be routed through the cloud while others stay local, using an API key:
```bash
# Save API key and set project to cloud mode
basic-memory cloud set-key bmc_abc123...
basic-memory project set-cloud research # route through cloud
basic-memory project set-local research # revert to local
```
MCP tools use `get_project_client()` which automatically routes based on the project's mode. Cloud projects use the `cloud_api_key` from config as Bearer token.
**CLI Routing Flags (Global Cloud Mode):**
When global cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
```bash
# Force local routing (ignore cloud mode)
@@ -348,6 +384,7 @@ Key behaviors:
- This allows simultaneous use of local Claude Desktop and cloud-based clients
- Some commands (like `project default`, `project sync-config`, `project move`) require `--local` in cloud mode since they modify local configuration
- Environment variable `BASIC_MEMORY_FORCE_LOCAL=true` forces local routing globally
- Per-project cloud routing via API key works independently of global cloud mode
## AI-Human Collaborative Development
+23 -3
View File
@@ -344,7 +344,7 @@ basic-memory sync --watch
3. Cloud features (optional, requires subscription):
```bash
# Authenticate with cloud
# Authenticate with cloud (global cloud mode via OAuth)
basic-memory cloud login
# Bidirectional sync with cloud
@@ -357,9 +357,29 @@ basic-memory cloud check
basic-memory cloud mount
```
**Routing Flags** (for users with cloud subscriptions):
**Per-Project Cloud Routing** (API key based):
When cloud mode is enabled, CLI commands communicate with the cloud API by default. Use routing flags to override this:
Individual projects can be routed through the cloud while others stay local. This uses an API key instead of OAuth:
```bash
# Save an API key (create one in the web app or via CLI)
basic-memory cloud set-key bmc_abc123...
# Or create one via CLI (requires OAuth login first)
basic-memory cloud create-key "my-laptop"
# Set a project to route through cloud
basic-memory project set-cloud research
# Revert a project to local mode
basic-memory project set-local research
# List projects with mode column (local/cloud)
basic-memory project list
```
**Routing Flags** (for users with global cloud mode):
When global cloud mode is enabled, CLI commands communicate with the cloud API by default. Use routing flags to override this:
```bash
# Force local routing (useful for local MCP server while cloud mode is enabled)
+22 -3
View File
@@ -110,6 +110,8 @@ def resolve_runtime_mode(cloud_mode_enabled: bool, is_test_env: bool) -> Runtime
return RuntimeMode.LOCAL
```
**Note**: `RuntimeMode` determines global behavior (e.g., whether to start file sync). Per-project routing is orthogonal — individual projects can be set to `cloud` mode via `ProjectMode` in config, which affects client routing in `get_client(project_name=...)` without changing the global runtime mode.
## Dependencies Package
### Structure
@@ -221,9 +223,7 @@ async def search_notes(
tags: list[str] | None = None,
status: str | None = None,
) -> SearchResponse:
async with get_client() as client:
active_project = await get_active_project(client, project)
async with get_project_client(project, context) as (client, active_project):
# Import client inside function to avoid circular imports
from basic_memory.mcp.clients import SearchClient
from basic_memory.schemas.search import SearchQuery
@@ -238,6 +238,25 @@ async def search_notes(
return await search_client.search(search_query.model_dump())
```
### Per-Project Client Routing
`get_project_client()` from `mcp/project_context.py` is an async context manager that:
1. Resolves the project name from config (no network call)
2. Creates the correctly-routed client based on the project's mode (local ASGI or cloud HTTP with API key)
3. Validates the project via the API
4. Yields `(client, active_project)` tuple
This solves the bootstrap problem: you need the project name to choose the right client (local vs cloud), but you need the client to validate the project exists.
```python
from basic_memory.mcp.project_context import get_project_client
async with get_project_client(project, context) as (client, active_project):
# client is routed based on project's mode (local or cloud)
# active_project is validated via the API
...
```
## Sync Coordination
### SyncCoordinator
+175
View File
@@ -0,0 +1,175 @@
# Per-Project Local/Cloud Routing
## Context
basic-memory's cloud/local mode is currently a global toggle (`cloud_mode: bool`). When enabled, ALL projects route through the cloud proxy via OAuth. This is too coarse — users should be able to keep some projects local and route others through cloud.
The cloud API already supports API key auth (`bmc_`-prefixed keys, `POST /api/keys` to create, `HybridTokenVerifier` routes them automatically). API keys are per-tenant (account-level), not per-project — there are no per-project permissions in the cloud yet.
**Goal**: Users can set each project to `local` or `cloud` mode. Local projects use the existing ASGI in-process transport. Cloud projects use the cloud API with a single account-level API key. No OAuth dance needed for cloud project access.
## UX Flow
**Option A — Create key in web app:**
1. User creates API key in cloud web app (already supported)
2. Copies the key
3. Runs `bm cloud set-key bmc_abc123...` → saves to config.json
**Option B — Create key via CLI:**
1. User is already logged in via OAuth (`bm cloud login`)
2. Runs `bm cloud create-key "my-laptop"` → calls `POST /api/keys` with JWT auth → gets key back → saves to config.json
3. OAuth login is no longer needed for day-to-day use — the API key handles auth
**Setting project mode:**
```bash
bm project set-cloud research # route "research" project through cloud
bm project set-local research # revert to local
bm project list # shows mode column (local/cloud)
```
## Implementation Plan
### Step 1: Config model changes
**File: `src/basic_memory/config.py`**
- Add `ProjectMode` enum: `LOCAL = "local"`, `CLOUD = "cloud"`
- Add `ProjectConfigEntry` Pydantic model: `path: str`, `mode: ProjectMode = LOCAL`
- Evolve `BasicMemoryConfig.projects` from `Dict[str, str]` to `Dict[str, ProjectConfigEntry]`
- Add `model_validator(mode="before")` to auto-migrate old `{"name": "/path"}` format to `{"name": {"path": "/path", "mode": "local"}}`
- Add `cloud_api_key: Optional[str] = None` field to `BasicMemoryConfig` (account-level, not per-project)
- Update `ProjectConfig` dataclass to carry `mode` from config entry
- Add helpers: `get_project_entry(name)`, `get_project_mode(name)`
- Keep global `cloud_mode` as deprecated fallback
- Update all code that reads `config.projects` as `Dict[str, str]` to handle `ProjectConfigEntry`
### Step 2: Client routing
**File: `src/basic_memory/mcp/async_client.py`**
- Add optional `project_name: Optional[str] = None` parameter to `get_client()`
- Routing logic (priority order):
1. Factory injection (`_client_factory`) — unchanged
2. Force-local (`_force_local_mode()`) — unchanged
3. **New**: If `project_name` provided and project's mode is `CLOUD` → HTTP client with `cloud_api_key` as Bearer token, hitting `cloud_host/proxy`
4. Global `cloud_mode_enabled` fallback — existing OAuth flow (deprecated)
5. Default: local ASGI transport
- Error if cloud project but no `cloud_api_key` in config — actionable message pointing to `bm cloud set-key` or `bm cloud create-key`
### Step 3: Project-aware client helper
**File: `src/basic_memory/mcp/project_context.py`**
- Add `get_project_client(project, context)` async context manager
- Combines `resolve_project_parameter()` (config-only, no network) + `get_client(project_name=resolved)` + `get_active_project(client, resolved, context)`
- Returns `(client, active_project)` tuple
- Solves bootstrap problem: resolve project name first, create correct client, then validate
### Step 4: Simplify ProjectResolver
**File: `src/basic_memory/project_resolver.py`**
- Remove global `cloud_mode` parameter — routing mode is orthogonal to project resolution
- Resolution becomes purely: constrained env var → explicit param → default project
- Update `resolve_project_parameter()` in `project_context.py` to drop `cloud_mode` param
### Step 5: Update MCP tools
**Files: `src/basic_memory/mcp/tools/*.py` (~15 files)**
Mechanical change per tool:
```python
# Before
async with get_client() as client:
active_project = await get_active_project(client, project, context)
# After
async with get_project_client(project, context) as (client, active_project):
```
Special handling for `recent_activity.py` discovery mode: iterate projects, create per-project client for each.
### Step 6: Sync coordinator
**Files: `src/basic_memory/sync/coordinator.py`, `src/basic_memory/mcp/container.py`**
- Filter file watchers to local-mode projects only
- Cloud projects skip sync
### Step 7: CLI commands
**File: `src/basic_memory/cli/commands/cloud/core_commands.py`**
- `bm cloud set-key <api-key>` — saves API key to config.json
- `bm cloud create-key <name>` — calls `POST {cloud_host}/api/keys` using existing JWT auth (from `make_api_request`), saves returned key to config. Uses existing `api_client.py:make_api_request()` for the authenticated call.
**File: `src/basic_memory/cli/commands/project.py`**
- `bm project set-cloud <name>` — sets project mode to cloud (validates API key exists in config)
- `bm project set-local <name>` — reverts project to local mode
- Extend `bm project list` / `bm project info` to show mode column
### Step 8: RuntimeMode simplification
**File: `src/basic_memory/runtime.py`**
- `resolve_runtime_mode()` drops `cloud_mode_enabled` parameter
- Simplifies to: TEST if test env, otherwise LOCAL
- `RuntimeMode.CLOUD` kept for backward compat but not used in global resolution
### Step 9: Tests
- Config: migration from old format, round-trip serialization, `get_project_mode()`
- `get_client()`: local project → ASGI, cloud project → HTTP+API key, missing key → error
- `get_project_client()`: resolve + route combined
- MCP tools: representative sample with new helper
- Sync: cloud projects skipped, local projects synced
- CLI: `set-key`, `create-key`, `set-cloud`, `set-local`
## Key Files
| File | Change |
|------|--------|
| `src/basic_memory/config.py` | `ProjectMode`, `ProjectConfigEntry`, migration, `cloud_api_key` field |
| `src/basic_memory/mcp/async_client.py` | `get_client(project_name=)` per-project routing |
| `src/basic_memory/mcp/project_context.py` | `get_project_client()` helper |
| `src/basic_memory/project_resolver.py` | Remove global `cloud_mode` concern |
| `src/basic_memory/mcp/tools/*.py` | Mechanical swap to `get_project_client()` |
| `src/basic_memory/sync/coordinator.py` | Filter to local-mode projects |
| `src/basic_memory/mcp/container.py` | Update should_sync logic |
| `src/basic_memory/cli/commands/cloud/core_commands.py` | `set-key`, `create-key` commands |
| `src/basic_memory/cli/commands/project.py` | `set-cloud`, `set-local` commands |
| `src/basic_memory/runtime.py` | Drop cloud_mode from global resolution |
## Config Example
```json
{
"projects": {
"personal": {"path": "/Users/me/notes", "mode": "local"},
"research": {"path": "/Users/me/research", "mode": "cloud"}
},
"cloud_api_key": "bmc_abc123...",
"cloud_host": "https://cloud.basicmemory.com",
"default_project": "personal"
}
```
## Edge Cases
| Case | Handling |
|------|----------|
| No API key + cloud project | `get_client()` raises error: "Run `bm cloud set-key` first" |
| Old config format loaded | `model_validator` auto-migrates `Dict[str,str]` to new format |
| Default project is cloud | Works — resolver returns name, routing uses API key |
| Global `cloud_mode=true` (legacy) | Deprecated fallback still works via OAuth |
| Factory-injected client (cloud app) | Factory takes priority, unaffected |
| `--local` CLI flag on cloud project | Force-local override still works |
## Verification
1. `just fast-check` — lint/format/typecheck + impacted tests
2. `just test` — full suite (SQLite + Postgres)
3. Manual: `bm cloud set-key bmc_...`, `bm project set-cloud test`, run MCP tools against it
4. Manual: verify local projects work unchanged
5. Manual: `bm project list` shows mode column
+98 -7
View File
@@ -436,20 +436,97 @@ bm project bisync --name work
**Result:** Fine-grained control over what syncs.
## Per-Project Cloud Routing (API Key)
Instead of toggling global cloud mode, you can route individual projects through the cloud using an API key. This lets you keep some projects local while others route through the cloud.
### Setting Up API Key Auth
**Option A: Create a key in the web app, then save it locally:**
```bash
bm cloud set-key bmc_abc123...
```
**Option B: Create a key via CLI (requires OAuth login first):**
```bash
bm cloud login # One-time OAuth login
bm cloud create-key "my-laptop" # Creates key and saves it locally
```
The API key is account-level — it grants access to all your cloud projects. It's stored in `~/.basic-memory/config.json` as `cloud_api_key`.
### Setting Project Modes
```bash
# Route a project through cloud
bm project set-cloud research
# Revert to local mode
bm project set-local research
# View project modes
bm project list
```
**What happens:**
- `set-cloud`: validates the API key exists, then sets the project mode to `cloud` in config
- `set-local`: reverts the project to local mode (removes the mode entry from config)
- MCP tools and CLI commands for that project will route to `cloud_host/proxy` with the API key as Bearer token
### How It Works
When an MCP tool or CLI command runs for a cloud-mode project:
1. `get_client(project_name="research")` checks the project's mode in config
2. If mode is `cloud`, creates an HTTP client pointed at `cloud_host/proxy` with `Authorization: Bearer bmc_...`
3. If mode is `local` (default), uses the in-process ASGI transport as usual
**Routing priority** (highest to lowest):
1. Factory injection (cloud app, tests)
2. `BASIC_MEMORY_FORCE_LOCAL` env var
3. Per-project cloud mode (API key)
4. Global cloud mode (OAuth — deprecated fallback)
5. Local ASGI transport (default)
### Configuration Example
```json
{
"projects": {
"personal": "/Users/me/notes",
"research": "/Users/me/research"
},
"project_modes": {
"research": "cloud"
},
"cloud_api_key": "bmc_abc123...",
"cloud_host": "https://cloud.basicmemory.com",
"default_project": "personal"
}
```
In this example, `personal` stays local and `research` routes through cloud. Projects not listed in `project_modes` default to local.
### Sync Behavior
Cloud-mode projects are automatically skipped during local file sync (background sync and file watching). Their files live on the cloud instance, not locally.
## Disable Cloud Mode
Return to local mode:
Return to local mode (global):
```bash
bm cloud logout
```
**What this does:**
1. Disables cloud mode in config
2. All commands now work locally
1. Disables global cloud mode in config
2. All commands now work locally (unless individual projects are set to cloud via `set-cloud`)
3. Auth token remains (can re-enable with login)
**Result:** All `bm` commands work with local projects again.
**Result:** All `bm` commands work with local projects again. Per-project cloud routing via API key continues to work independently of global cloud mode.
## Filter Configuration
@@ -659,12 +736,19 @@ If instance is down, wait a few minutes and retry.
### Cloud Mode Management
```bash
bm cloud login # Authenticate and enable cloud mode
bm cloud logout # Disable cloud mode
bm cloud login # Authenticate and enable global cloud mode (OAuth)
bm cloud logout # Disable global cloud mode
bm cloud status # Check cloud mode and instance health
bm cloud promo --off # Disable CLI cloud promo notices
```
### API Key Management
```bash
bm cloud set-key <key> # Save a cloud API key (bmc_ prefixed)
bm cloud create-key <name> # Create API key via cloud API (requires OAuth login)
```
### Setup
```bash
@@ -676,13 +760,20 @@ bm cloud setup # Install rclone and configure credentials
When cloud mode is enabled:
```bash
bm project list # List cloud projects
bm project list # List projects with mode column
bm project add <name> # Create cloud project (no sync)
bm project add <name> --local-path <path> # Create with local sync
bm project sync-setup <name> <path> # Add sync to existing project
bm project rm <name> # Delete project
```
### Per-Project Routing
```bash
bm project set-cloud <name> # Route project through cloud (requires API key)
bm project set-local <name> # Revert project to local mode
```
### File Synchronization
```bash
+3 -3
View File
@@ -110,9 +110,9 @@ test-benchmark:
# Compare two search benchmark JSONL outputs
# Usage:
# just benchmark-compare .benchmarks/search-baseline.jsonl .benchmarks/search-candidate.jsonl
# just benchmark-compare .benchmarks/search-baseline.jsonl .benchmarks/search-candidate.jsonl markdown --show-missing
benchmark-compare baseline candidate format="table" *args:
uv run python test-int/compare_search_benchmarks.py "{{baseline}}" "{{candidate}}" --format "{{format}}" {{args}}
# just benchmark-compare .benchmarks/search-baseline.jsonl .benchmarks/search-candidate.jsonl --format markdown --show-missing
benchmark-compare baseline candidate *args:
uv run python test-int/compare_search_benchmarks.py "{{baseline}}" "{{candidate}}" --format table {{args}}
# Run all tests including Windows, Postgres, and Benchmarks (for CI/comprehensive testing)
# Use this before releasing to ensure everything works across all backends and platforms
@@ -210,3 +210,79 @@ def promo(enabled: bool = typer.Option(True, "--on/--off", help="Enable or disab
console.print("[green]Cloud promo messages enabled[/green]")
else:
console.print("[yellow]Cloud promo messages disabled[/yellow]")
@cloud_app.command("set-key")
def set_key(
api_key: str = typer.Argument(..., help="API key (bmc_ prefixed) for cloud access"),
) -> None:
"""Save a cloud API key for per-project cloud routing.
The API key is account-level and used by projects set to cloud mode.
Create a key in the web app or use 'bm cloud create-key'.
Example:
bm cloud set-key bmc_abc123...
"""
if not api_key.startswith("bmc_"):
console.print("[red]Error: API key must start with 'bmc_'[/red]")
raise typer.Exit(1)
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_api_key = api_key
config_manager.save_config(config)
console.print("[green]API key saved[/green]")
console.print("[dim]Projects set to cloud mode will use this key for authentication[/dim]")
console.print("[dim]Set a project to cloud mode: bm project set-cloud <name>[/dim]")
@cloud_app.command("create-key")
def create_key(
name: str = typer.Argument(..., help="Human-readable name for the API key"),
) -> None:
"""Create a new cloud API key and save it locally.
Requires active OAuth session (run 'bm cloud login' first).
The key is created via the cloud API and saved to local config.
Example:
bm cloud create-key "my-laptop"
"""
async def _create_key():
_, _, host_url = get_cloud_config()
host_url = host_url.rstrip("/")
console.print(f"[dim]Creating API key '{name}'...[/dim]")
response = await make_api_request(
method="POST",
url=f"{host_url}/api/keys",
json_data={"name": name},
)
key_data = response.json()
api_key = key_data.get("key")
if not api_key:
console.print("[red]Error: No key returned from API[/red]")
raise typer.Exit(1)
# Save to config
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_api_key = api_key
config_manager.save_config(config)
console.print(f"[green]API key '{name}' created and saved[/green]")
console.print("[dim]Projects set to cloud mode will use this key for authentication[/dim]")
console.print("[dim]Set a project to cloud mode: bm project set-cloud <name>[/dim]")
try:
run_with_cleanup(_create_key())
except CloudAPIError as e:
console.print(f"[red]Error creating API key: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
+72 -2
View File
@@ -13,7 +13,7 @@ from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
from basic_memory.config import ConfigManager, ProjectMode
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_delete, call_get, call_patch, call_post, call_put
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
@@ -77,6 +77,7 @@ def list_projects(
table = Table(title="Basic Memory Projects")
table.add_column("Name", style="cyan")
table.add_column("Path", style="green")
table.add_column("Mode", style="blue")
# Add Local Path column if in cloud mode and not forcing local
if config.cloud_mode_enabled and not local:
@@ -90,9 +91,10 @@ def list_projects(
for project in result.projects:
is_default = "[X]" if project.is_default else ""
normalized_path = normalize_project_path(project.path)
project_mode = config.get_project_mode(project.name).value
# Build row based on mode
row = [project.name, format_path(normalized_path)]
row = [project.name, format_path(normalized_path), project_mode]
# Add local path if in cloud mode and not forcing local
if config.cloud_mode_enabled and not local:
@@ -511,6 +513,74 @@ def move_project(
raise typer.Exit(1)
@project_app.command("set-cloud")
def set_cloud(
name: str = typer.Argument(..., help="Name of the project to route through cloud"),
) -> None:
"""Set a project to cloud mode (route through cloud API).
Requires either an API key or an active OAuth session.
Examples:
bm cloud set-key bmc_abc123... # save API key, then:
bm project set-cloud research # route "research" through cloud
bm cloud login # OAuth login, then:
bm project set-cloud research # route "research" through cloud
"""
from basic_memory.cli.auth import CLIAuth
config_manager = ConfigManager()
config = config_manager.config
# Validate project exists in config
if name not in config.projects:
console.print(f"[red]Error: Project '{name}' not found in config[/red]")
raise typer.Exit(1)
# Validate credentials: API key or OAuth session
has_api_key = bool(config.cloud_api_key)
has_oauth = False
if not has_api_key:
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
has_oauth = auth.load_tokens() is not None
if not has_api_key and not has_oauth:
console.print("[red]Error: No cloud credentials found[/red]")
console.print("[dim]Run 'bm cloud set-key <key>' or 'bm cloud login' first[/dim]")
raise typer.Exit(1)
config.set_project_mode(name, ProjectMode.CLOUD)
config_manager.save_config(config)
console.print(f"[green]Project '{name}' set to cloud mode[/green]")
console.print("[dim]MCP tools and CLI commands for this project will route through cloud[/dim]")
@project_app.command("set-local")
def set_local(
name: str = typer.Argument(..., help="Name of the project to revert to local mode"),
) -> None:
"""Revert a project to local mode (use in-process ASGI transport).
Example:
bm project set-local research
"""
config_manager = ConfigManager()
config = config_manager.config
# Validate project exists in config
if name not in config.projects:
console.print(f"[red]Error: Project '{name}' not found in config[/red]")
raise typer.Exit(1)
config.set_project_mode(name, ProjectMode.LOCAL)
config_manager.save_config(config)
console.print(f"[green]Project '{name}' set to local mode[/green]")
console.print("[dim]MCP tools and CLI commands for this project will use local transport[/dim]")
@project_app.command("sync")
def sync_project_command(
name: str = typer.Option(..., "--name", help="Project name to sync"),
+37 -1
View File
@@ -24,6 +24,13 @@ WATCH_STATUS_JSON = "watch-status.json"
Environment = Literal["test", "dev", "user"]
class ProjectMode(str, Enum):
"""Per-project routing mode."""
LOCAL = "local"
CLOUD = "cloud"
class DatabaseBackend(str, Enum):
"""Supported database backends."""
@@ -37,6 +44,7 @@ class ProjectConfig:
name: str
home: Path
mode: ProjectMode = ProjectMode.LOCAL
@property
def project(self):
@@ -264,6 +272,16 @@ class BasicMemoryConfig(BaseSettings):
description="Most recent cloud promo version shown in CLI.",
)
cloud_api_key: Optional[str] = Field(
default=None,
description="API key for cloud access (bmc_ prefixed). Account-level, not per-project.",
)
project_modes: Dict[str, ProjectMode] = Field(
default_factory=dict,
description="Per-project routing mode. Projects not listed default to LOCAL.",
)
@property
def is_test_env(self) -> bool:
"""Check if running in a test environment.
@@ -297,6 +315,21 @@ class BasicMemoryConfig(BaseSettings):
# Fall back to config file value
return self.cloud_mode
def get_project_mode(self, project_name: str) -> ProjectMode:
"""Get the routing mode for a project.
Returns the per-project mode if set, otherwise LOCAL.
"""
return self.project_modes.get(project_name, ProjectMode.LOCAL)
def set_project_mode(self, project_name: str, mode: ProjectMode) -> None:
"""Set the routing mode for a project."""
if mode == ProjectMode.LOCAL:
# Remove from dict to keep config clean — LOCAL is the default
self.project_modes.pop(project_name, None)
else:
self.project_modes[project_name] = mode
@classmethod
def for_cloud_tenant(
cls,
@@ -387,7 +420,10 @@ class BasicMemoryConfig(BaseSettings):
@property
def project_list(self) -> List[ProjectConfig]: # pragma: no cover
"""Get all configured projects as ProjectConfig objects."""
return [ProjectConfig(name=name, home=Path(path)) for name, path in self.projects.items()]
return [
ProjectConfig(name=name, home=Path(path), mode=self.get_project_mode(name))
for name, path in self.projects.items()
]
@model_validator(mode="after")
def ensure_project_paths_exists(self) -> "BasicMemoryConfig": # pragma: no cover
+76 -10
View File
@@ -6,7 +6,7 @@ from httpx import ASGITransport, AsyncClient, Timeout
from loguru import logger
from basic_memory.api.app import app as fastapi_app
from basic_memory.config import ConfigManager
from basic_memory.config import ConfigManager, ProjectMode
def _force_local_mode() -> bool:
@@ -45,31 +45,54 @@ def set_client_factory(factory: Callable[[], AbstractAsyncContextManager[AsyncCl
@asynccontextmanager
async def get_client() -> AsyncIterator[AsyncClient]:
async def get_client(
project_name: Optional[str] = None,
) -> AsyncIterator[AsyncClient]:
"""Get an AsyncClient as a context manager.
This function provides proper resource management for HTTP clients,
ensuring connections are closed after use. It supports three modes:
ensuring connections are closed after use. Routing priority:
1. **Factory injection** (cloud app, tests):
If a custom factory is set via set_client_factory(), use that.
2. **CLI cloud mode**:
When cloud_mode_enabled is True, create HTTP client with auth
token from CLIAuth for requests to cloud proxy endpoint.
2. **Per-project cloud mode** (project_name provided):
If the project's mode is CLOUD, routes to cloud using API key or
OAuth token. Honored even when FORCE_LOCAL is set, because the user
explicitly declared this project as cloud.
3. **Local mode** (default):
3. **Per-project local mode** (project_name provided):
If the project's mode is LOCAL (or unspecified, default LOCAL), route
to local ASGI transport. This allows mixed local/cloud routing even when
global cloud mode is enabled.
4. **Force-local** (BASIC_MEMORY_FORCE_LOCAL env var):
Routes to local ASGI transport, ignoring global cloud settings.
5. **Global cloud mode** (deprecated fallback):
When cloud_mode_enabled is True, uses OAuth JWT token.
6. **Local mode** (default):
Use ASGI transport for in-process requests to local FastAPI app.
Args:
project_name: Optional project name for per-project routing.
If provided and the project's mode is CLOUD, routes to cloud
using the API key or OAuth token.
Usage:
async with get_client() as client:
response = await client.get("/path")
# Per-project routing
async with get_client(project_name="research") as client:
response = await client.get("/path")
Yields:
AsyncClient: Configured HTTP client for the current mode
Raises:
RuntimeError: If cloud mode is enabled but user is not authenticated
RuntimeError: If cloud routing needed but no API key / not authenticated
"""
if _client_factory:
# Use injected factory (cloud app, tests)
@@ -85,18 +108,61 @@ async def get_client() -> AsyncIterator[AsyncClient]:
pool=30.0, # 30 seconds for connection pool
)
# Trigger: project has per-project cloud mode set
# Why: per-project CLOUD is an explicit user declaration that should be
# honored even from the MCP server (which sets FORCE_LOCAL)
# Outcome: HTTP client with API key or OAuth auth to cloud proxy
if project_name and config.get_project_mode(project_name) == ProjectMode.CLOUD:
# Try API key first (explicit, no network)
token = config.cloud_api_key
if not token:
# Fall back to OAuth session (may refresh token)
from basic_memory.cli.auth import CLIAuth
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
token = await auth.get_valid_token()
if not token:
raise RuntimeError(
f"Project '{project_name}' is set to cloud mode but no credentials found. "
"Run 'bm cloud set-key <key>' or 'bm cloud login' first."
)
proxy_base_url = f"{config.cloud_host}/proxy"
logger.info(
f"Creating HTTP client for cloud project '{project_name}' at: {proxy_base_url}"
)
async with AsyncClient(
base_url=proxy_base_url,
headers={"Authorization": f"Bearer {token}"},
timeout=timeout,
) as client:
yield client
# Trigger: project is not explicitly cloud (LOCAL is the default)
# Why: project-scoped routing should honor local mode even when global
# cloud mode is enabled for backward compatibility
# Outcome: uses ASGI transport for in-process local API calls
elif project_name and config.get_project_mode(project_name) == ProjectMode.LOCAL:
logger.info(f"Project '{project_name}' is set to local mode - using ASGI transport")
async with AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
) as client:
yield client
# Trigger: BASIC_MEMORY_FORCE_LOCAL env var is set
# Why: allows local MCP server and CLI commands to route locally
# even when cloud_mode_enabled is True
# Outcome: uses ASGI transport for in-process local API calls
if _force_local_mode():
elif _force_local_mode():
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
async with AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
) as client:
yield client
elif config.cloud_mode_enabled:
# CLI cloud mode: inject auth when creating client
# Global cloud mode (deprecated fallback): inject OAuth auth when creating client
from basic_memory.cli.auth import CLIAuth
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
+48 -1
View File
@@ -8,7 +8,9 @@ The resolve_project_parameter function is a thin wrapper for backwards
compatibility with existing MCP tools.
"""
from typing import Optional, List
from contextlib import asynccontextmanager
from typing import AsyncIterator, Optional, List, Tuple
from httpx import AsyncClient
from httpx._types import (
HeaderTypes,
@@ -162,3 +164,48 @@ def add_project_metadata(result: str, project_name: str) -> str:
Result with project session tracking metadata
"""
return f"{result}\n\n[Session: Using project '{project_name}']"
@asynccontextmanager
async def get_project_client(
project: Optional[str] = None,
context: Optional[Context] = None,
) -> AsyncIterator[Tuple[AsyncClient, ProjectItem]]:
"""Resolve project, create correctly-routed client, and validate project.
Solves the bootstrap problem: we need to know the project name to choose
the right client (local vs cloud), but we need the client to validate
the project. This helper resolves the project from config first (no
network), creates the correctly-routed client, then validates via API.
Args:
project: Optional explicit project parameter
context: Optional FastMCP context for caching
Yields:
Tuple of (client, active_project)
Raises:
ValueError: If no project can be resolved
RuntimeError: If cloud project but no API key configured
"""
# Deferred import to avoid circular dependency
from basic_memory.mcp.async_client import get_client
# Step 1: Resolve project name from config (no network call)
resolved_project = await resolve_project_parameter(project)
if not resolved_project:
# Fall back to local client to discover projects and raise helpful error
async with get_client() as client:
project_names = await get_project_names(client)
raise ValueError(
"No project specified. "
"Either set 'default_project_mode=true' in config, or use 'project' argument.\n"
f"Available projects: {project_names}"
)
# Step 2: Create client routed based on project's mode
async with get_client(project_name=resolved_project) as client:
# Step 3: Validate project exists via API
active_project = await get_active_project(client, resolved_project, context)
yield client, active_project
+2 -6
View File
@@ -5,8 +5,7 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
@@ -100,10 +99,7 @@ async def build_context(
# URL is already validated and normalized by MemoryUrl type annotation
async with get_client() as client:
# Get the active project using the new stateless approach
active_project = await get_active_project(client, project, context)
async with get_project_client(project, context) as (client, active_project):
# Import here to avoid circular import
from basic_memory.mcp.clients import MemoryClient
+2 -5
View File
@@ -9,8 +9,7 @@ from typing import Dict, List, Any, Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
@@ -94,9 +93,7 @@ async def canvas(
Raises:
ToolError: If project doesn't exist or directory path is invalid
"""
async with get_client() as client:
active_project = await get_active_project(client, project, context)
async with get_project_client(project, context) as (client, active_project):
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
file_path = f"{directory}/{file_title}"
+3 -6
View File
@@ -5,9 +5,8 @@ from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import get_client
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
@@ -216,13 +215,11 @@ async def delete_note(
with suggestions for finding the correct identifier, including search
commands and alternative formats to try.
"""
async with get_client() as client:
async with get_project_client(project, context) as (client, active_project):
logger.debug(
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {project}"
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {active_project.name}"
)
active_project = await get_active_project(client, project, context)
# Import here to avoid circular import
from basic_memory.mcp.clients import KnowledgeClient
+2 -5
View File
@@ -5,8 +5,7 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
from basic_memory.mcp.server import mcp
@@ -212,9 +211,7 @@ async def edit_note(
search_notes() first to find the correct identifier. The tool provides detailed
error messages with suggestions if operations fail.
"""
async with get_client() as client:
active_project = await get_active_project(client, project, context)
async with get_project_client(project, context) as (client, active_project):
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
# Validate operation
+2 -5
View File
@@ -5,8 +5,7 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
@@ -62,9 +61,7 @@ async def list_directory(
Raises:
ToolError: If project doesn't exist or directory path is invalid
"""
async with get_client() as client:
active_project = await get_active_project(client, project, context)
async with get_project_client(project, context) as (client, active_project):
logger.debug(
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
)
+3 -6
View File
@@ -6,9 +6,8 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.project_context import get_project_client
from basic_memory.utils import validate_project_path
@@ -412,13 +411,11 @@ async def move_note(
- Re-indexes the entity for search
- Maintains all observations and relations
"""
async with get_client() as client:
async with get_project_client(project, context) as (client, active_project):
logger.debug(
f"Moving {'directory' if is_directory else 'note'}: {identifier} to {destination_path} in project: {project}"
f"Moving {'directory' if is_directory else 'note'}: {identifier} to {destination_path} in project: {active_project.name}"
)
active_project = await get_active_project(client, project, context)
# Validate destination path to prevent path traversal attacks
project_path = active_project.home
if not validate_project_path(destination_path, project_path):
+2 -5
View File
@@ -15,9 +15,8 @@ from PIL import Image as PILImage
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
@@ -202,9 +201,7 @@ async def read_content(
"""
logger.info("Reading file", path=path, project=project)
async with get_client() as client:
active_project = await get_active_project(client, project, context)
async with get_project_client(project, context) as (client, active_project):
url = memory_url_path(path)
# Validate path to prevent path traversal attacks
+2 -6
View File
@@ -6,8 +6,7 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search_notes
from basic_memory.schemas.memory import memory_url_path
@@ -77,10 +76,7 @@ async def read_note(
If the exact note isn't found, this tool provides helpful suggestions
including related notes, search commands, and note creation templates.
"""
async with get_client() as client:
# Get and validate the project
active_project = await get_active_project(client, project, context)
async with get_project_client(project, context) as (client, active_project):
# Validate identifier to prevent path traversal attacks
# We need to check both the raw identifier and the processed path
processed_path = memory_url_path(identifier)
+95 -99
View File
@@ -7,7 +7,10 @@ from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter
from basic_memory.mcp.project_context import (
get_project_client,
resolve_project_parameter,
)
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.base import TimeFrame
@@ -99,50 +102,51 @@ async def recent_activity(
- For focused queries, consider using build_context with a specific URI
- Max timeframe is 1 year in the past
"""
async with get_client() as client:
# Build common parameters for API calls
params = {
"page": 1,
"page_size": 10,
"max_related": 10,
}
if depth:
params["depth"] = depth
if timeframe:
params["timeframe"] = timeframe # pyright: ignore
# Build common parameters for API calls
params: dict = {
"page": 1,
"page_size": 10,
"max_related": 10,
}
if depth:
params["depth"] = depth
if timeframe:
params["timeframe"] = timeframe # pyright: ignore
# Validate and convert type parameter
if type:
# Convert single string to list
if isinstance(type, str):
type_list = [type]
else:
type_list = type
# Validate and convert type parameter
if type:
# Convert single string to list
if isinstance(type, str):
type_list = [type]
else:
type_list = type
# Validate each type against SearchItemType enum
validated_types = []
for t in type_list:
try:
# Try to convert string to enum
if isinstance(t, str):
validated_types.append(SearchItemType(t.lower()))
except ValueError:
valid_types = [t.value for t in SearchItemType]
raise ValueError(f"Invalid type: {t}. Valid types are: {valid_types}")
# Validate each type against SearchItemType enum
validated_types = []
for t in type_list:
try:
# Try to convert string to enum
if isinstance(t, str):
validated_types.append(SearchItemType(t.lower()))
except ValueError:
valid_types = [t.value for t in SearchItemType]
raise ValueError(f"Invalid type: {t}. Valid types are: {valid_types}")
# Add validated types to params
params["type"] = [t.value for t in validated_types] # pyright: ignore
# Add validated types to params
params["type"] = [t.value for t in validated_types] # pyright: ignore
# Resolve project parameter using the three-tier hierarchy
# allow_discovery=True enables Discovery Mode, so a project is not required
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
# Resolve project parameter using the three-tier hierarchy
# allow_discovery=True enables Discovery Mode, so a project is not required
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
if resolved_project is None:
# Discovery Mode: Get activity across all projects
logger.info(
f"Getting recent activity across all projects: type={type}, depth={depth}, timeframe={timeframe}"
)
if resolved_project is None:
# Discovery Mode: Get activity across all projects
# Uses plain get_client() since we iterate across all projects (no single project routing)
logger.info(
f"Getting recent activity across all projects: type={type}, depth={depth}, timeframe={timeframe}"
)
async with get_client() as client:
# Get list of all projects
response = await call_get(client, "/v2/projects/")
project_list = ProjectList.model_validate(response.json())
@@ -181,76 +185,68 @@ async def recent_activity(
most_active_count = item_count
most_active_project = project_info.name
# Build summary stats
summary = ActivityStats(
total_projects=len(project_list.projects),
active_projects=active_projects,
most_active_project=most_active_project,
total_items=total_items,
total_entities=total_entities,
total_relations=total_relations,
total_observations=total_observations,
)
# Build summary stats
summary = ActivityStats(
total_projects=len(project_list.projects),
active_projects=active_projects,
most_active_project=most_active_project,
total_items=total_items,
total_entities=total_entities,
total_relations=total_relations,
total_observations=total_observations,
)
# Generate guidance for the assistant
guidance_lines = ["\n" + "" * 40]
if active_projects == 0:
# No recent activity
guidance_lines.extend(
[
"No recent activity found in any project.",
"Consider: Ask which project to use or if they want to create a new one.",
]
)
else:
# At least one project has activity: suggest the most active project.
suggested_project = most_active_project or next(
(
name
for name, activity in projects_activity.items()
if activity.item_count > 0
),
None,
)
if suggested_project:
suffix = (
f"(most active with {most_active_count} items)"
if most_active_count > 0
else ""
)
guidance_lines.append(
f"Suggested project: '{suggested_project}' {suffix}".strip()
)
if active_projects == 1:
guidance_lines.append(
f"Ask user: 'Should I use {suggested_project} for this task?'"
)
else:
guidance_lines.append(
f"Ask user: 'Should I use {suggested_project} for this task, or would you prefer a different project?'"
)
# Generate guidance for the assistant
guidance_lines = ["\n" + "" * 40]
if active_projects == 0:
# No recent activity
guidance_lines.extend(
[
"",
"Session reminder: Remember their project choice throughout this conversation.",
"No recent activity found in any project.",
"Consider: Ask which project to use or if they want to create a new one.",
]
)
guidance = "\n".join(guidance_lines)
# Format discovery mode output
return _format_discovery_output(projects_activity, summary, timeframe, guidance)
else:
# Project-Specific Mode: Get activity for specific project
logger.info(
f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
# At least one project has activity: suggest the most active project.
suggested_project = most_active_project or next(
(name for name, activity in projects_activity.items() if activity.item_count > 0),
None,
)
if suggested_project:
suffix = (
f"(most active with {most_active_count} items)" if most_active_count > 0 else ""
)
guidance_lines.append(f"Suggested project: '{suggested_project}' {suffix}".strip())
if active_projects == 1:
guidance_lines.append(
f"Ask user: 'Should I use {suggested_project} for this task?'"
)
else:
guidance_lines.append(
f"Ask user: 'Should I use {suggested_project} for this task, or would you prefer a different project?'"
)
active_project = await get_active_project(client, resolved_project, context)
guidance_lines.extend(
[
"",
"Session reminder: Remember their project choice throughout this conversation.",
]
)
guidance = "\n".join(guidance_lines)
# Format discovery mode output
return _format_discovery_output(projects_activity, summary, timeframe, guidance)
else:
# Project-Specific Mode: Get activity for specific project
# Uses get_project_client() for per-project routing (local vs cloud)
logger.info(
f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
)
async with get_project_client(resolved_project, context) as (client, active_project):
response = await call_get(
client,
f"/v2/projects/{active_project.external_id}/memory/recent",
+3 -7
View File
@@ -6,8 +6,7 @@ from typing import List, Optional, Dict, Any
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
from basic_memory.schemas.search import (
SearchItemType,
@@ -430,9 +429,7 @@ async def search_notes(
if status:
search_query.status = status
async with get_client() as client:
active_project = await get_active_project(client, project, context)
async with get_project_client(project, context) as (client, active_project):
logger.info(f"Searching for {search_query} in project {active_project.name}")
try:
@@ -498,8 +495,7 @@ async def search_by_metadata(
page = (offset // limit) + 1
offset_within_page = offset % limit
async with get_client() as client:
active_project = await get_active_project(client, project, context)
async with get_project_client(project, context) as (client, active_project):
logger.info(
f"Structured search in project {active_project.name} filters={filters} limit={limit} offset={offset}"
)
+3 -7
View File
@@ -4,8 +4,7 @@ from typing import List, Union, Optional
from loguru import logger
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
from basic_memory.mcp.server import mcp
from fastmcp import Context
from basic_memory.schemas.base import Entity
@@ -110,14 +109,11 @@ async def write_note(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If directory path attempts path traversal
"""
async with get_client() as client:
async with get_project_client(project, context) as (client, active_project):
logger.info(
f"MCP tool call tool=write_note project={project} directory={directory}, title={title}, tags={tags}"
f"MCP tool call tool=write_note project={active_project.name} directory={directory}, title={title}, tags={tags}"
)
# Get and validate the project (supports optional project parameter)
active_project = await get_active_project(client, project, context)
# Normalize "/" to empty string for root directory (must happen before validation)
if directory == "/":
directory = ""
+11 -1
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from loguru import logger
from basic_memory import db
from basic_memory.config import BasicMemoryConfig
from basic_memory.config import BasicMemoryConfig, ProjectMode
from basic_memory.models import Project
from basic_memory.repository import (
ProjectRepository,
@@ -115,6 +115,16 @@ async def initialize_file_sync(
active_projects = [p for p in active_projects if p.name == constrained_project]
logger.info(f"Background sync constrained to project: {constrained_project}")
# Skip cloud-mode projects — their files live on the cloud instance, not locally
cloud_projects = [
p.name for p in active_projects if app_config.get_project_mode(p.name) == ProjectMode.CLOUD
]
if cloud_projects:
active_projects = [
p for p in active_projects if app_config.get_project_mode(p.name) != ProjectMode.CLOUD
]
logger.info(f"Skipping cloud-mode projects for local sync: {cloud_projects}")
# Start sync for all projects as background tasks (non-blocking)
async def sync_project_background(project: Project):
"""Sync a single project in the background."""
+17 -1
View File
@@ -10,7 +10,7 @@ from typing import List, Optional, Set, Sequence, Callable, Awaitable, TYPE_CHEC
if TYPE_CHECKING:
from basic_memory.sync.sync_service import SyncService
from basic_memory.config import BasicMemoryConfig, WATCH_STATUS_JSON
from basic_memory.config import BasicMemoryConfig, ProjectMode, WATCH_STATUS_JSON
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
from basic_memory.models import Project
from basic_memory.repository import ProjectRepository
@@ -177,6 +177,22 @@ class WatchService:
# Reload projects to catch any new/removed projects
projects = await self.project_repository.get_active_projects()
# Trigger: project is configured for cloud routing
# Why: cloud projects should not be watched/synced by local file watchers
# Outcome: watch cycle only observes local-mode projects
cloud_projects = [
p.name
for p in projects
if self.app_config.get_project_mode(p.name) == ProjectMode.CLOUD
]
if cloud_projects:
projects = [
p
for p in projects
if self.app_config.get_project_mode(p.name) != ProjectMode.CLOUD
]
logger.info(f"Skipping cloud-mode projects in watch cycle: {cloud_projects}")
project_paths = [project.path for project in projects]
logger.debug(f"Starting watch cycle for directories: {project_paths}")
+157
View File
@@ -0,0 +1,157 @@
"""Tests for bm project set-cloud and bm project set-local commands."""
import json
import pytest
from typer.testing import CliRunner
from basic_memory.cli.app import app
# Importing the commands module registers the project subcommands with the app
import basic_memory.cli.commands.project # noqa: F401
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def mock_config(tmp_path, monkeypatch):
"""Create a mock config with projects for testing set-cloud/set-local."""
from basic_memory import config as config_module
config_module._CONFIG_CACHE = None
config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / "config.json"
config_data = {
"env": "dev",
"projects": {
"main": str(tmp_path / "main"),
"research": str(tmp_path / "research"),
},
"default_project": "main",
"cloud_api_key": "bmc_test_key_123",
"project_modes": {},
}
config_file.write_text(json.dumps(config_data, indent=2))
monkeypatch.setenv("HOME", str(tmp_path))
yield config_file
class TestSetCloud:
"""Tests for bm project set-cloud command."""
def test_set_cloud_success(self, runner, mock_config):
"""Test setting a project to cloud mode."""
result = runner.invoke(app, ["project", "set-cloud", "research"])
assert result.exit_code == 0
assert "cloud mode" in result.stdout.lower()
# Verify config was updated
config_data = json.loads(mock_config.read_text())
assert config_data["project_modes"]["research"] == "cloud"
def test_set_cloud_nonexistent_project(self, runner, mock_config):
"""Test set-cloud with a project that doesn't exist in config."""
result = runner.invoke(app, ["project", "set-cloud", "nonexistent"])
assert result.exit_code == 1
assert "not found" in result.stdout.lower()
def test_set_cloud_no_credentials(self, runner, tmp_path, monkeypatch):
"""Test set-cloud when neither API key nor OAuth session is available."""
from basic_memory import config as config_module
config_module._CONFIG_CACHE = None
config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / "config.json"
# Config without cloud_api_key
config_data = {
"env": "dev",
"projects": {"research": str(tmp_path / "research")},
"default_project": "research",
}
config_file.write_text(json.dumps(config_data, indent=2))
monkeypatch.setenv("HOME", str(tmp_path))
result = runner.invoke(app, ["project", "set-cloud", "research"])
assert result.exit_code == 1
assert "no cloud credentials" in result.stdout.lower()
def test_set_cloud_with_oauth_session(self, runner, tmp_path, monkeypatch):
"""Test set-cloud succeeds with OAuth token but no API key."""
from basic_memory import config as config_module
config_module._CONFIG_CACHE = None
config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / "config.json"
# Config without cloud_api_key but with a project
config_data = {
"env": "dev",
"projects": {"research": str(tmp_path / "research")},
"default_project": "research",
}
config_file.write_text(json.dumps(config_data, indent=2))
monkeypatch.setenv("HOME", str(tmp_path))
# Write OAuth token file so CLIAuth.load_tokens() returns something
token_file = config_dir / "basic-memory-cloud.json"
token_data = {
"access_token": "oauth-token-789",
"refresh_token": None,
"expires_at": 9999999999,
"token_type": "Bearer",
}
token_file.write_text(json.dumps(token_data, indent=2))
result = runner.invoke(app, ["project", "set-cloud", "research"])
assert result.exit_code == 0
assert "cloud mode" in result.stdout.lower()
# Verify config was updated
config_data = json.loads(config_file.read_text())
assert config_data["project_modes"]["research"] == "cloud"
class TestSetLocal:
"""Tests for bm project set-local command."""
def test_set_local_success(self, runner, mock_config):
"""Test reverting a project to local mode."""
# First set to cloud
runner.invoke(app, ["project", "set-cloud", "research"])
config_data = json.loads(mock_config.read_text())
assert config_data["project_modes"]["research"] == "cloud"
# Now set back to local
result = runner.invoke(app, ["project", "set-local", "research"])
assert result.exit_code == 0
assert "local mode" in result.stdout.lower()
# Verify config was updated — LOCAL removes the entry
config_data = json.loads(mock_config.read_text())
assert "research" not in config_data.get("project_modes", {})
def test_set_local_nonexistent_project(self, runner, mock_config):
"""Test set-local with a project that doesn't exist in config."""
result = runner.invoke(app, ["project", "set-local", "nonexistent"])
assert result.exit_code == 1
assert "not found" in result.stdout.lower()
def test_set_local_already_local(self, runner, mock_config):
"""Test set-local on a project that's already local (no-op, should succeed)."""
result = runner.invoke(app, ["project", "set-local", "main"])
assert result.exit_code == 0
assert "local mode" in result.stdout.lower()
+165
View File
@@ -4,6 +4,7 @@ import httpx
import pytest
from basic_memory.cli.auth import CLIAuth
from basic_memory.config import ProjectMode
from basic_memory.mcp import async_client as async_client_module
from basic_memory.mcp.async_client import get_client, set_client_factory
@@ -78,3 +79,167 @@ async def test_get_client_local_mode_uses_asgi_transport(config_manager):
async with get_client() as client:
# httpx stores ASGITransport privately, but we can still sanity-check type
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
# --- Per-project cloud routing tests ---
@pytest.mark.asyncio
async def test_get_client_per_project_cloud_mode_uses_api_key(config_manager, config_home):
"""Test that a cloud-mode project routes through cloud with API key auth."""
cfg = config_manager.load_config()
cfg.cloud_mode = False # Global cloud mode off
cfg.cloud_host = "https://cloud.example.test"
cfg.cloud_api_key = "bmc_test_key_123"
cfg.set_project_mode("research", ProjectMode.CLOUD)
config_manager.save_config(cfg)
async with get_client(project_name="research") as client:
assert str(client.base_url).rstrip("/") == "https://cloud.example.test/proxy"
assert client.headers.get("Authorization") == "Bearer bmc_test_key_123"
@pytest.mark.asyncio
async def test_get_client_per_project_cloud_mode_raises_without_credentials(
config_manager, config_home
):
"""Test that a cloud-mode project raises error when no credentials are available."""
cfg = config_manager.load_config()
cfg.cloud_mode = False
cfg.cloud_api_key = None # No API key
cfg.set_project_mode("research", ProjectMode.CLOUD)
config_manager.save_config(cfg)
# No OAuth token file either → should raise
with pytest.raises(
RuntimeError,
match="no credentials found",
):
async with get_client(project_name="research"):
pass
@pytest.mark.asyncio
async def test_get_client_local_project_uses_asgi_transport(config_manager, config_home):
"""Test that a local-mode project uses ASGI transport even when API key exists."""
cfg = config_manager.load_config()
cfg.cloud_mode = False
cfg.cloud_api_key = "bmc_test_key_123"
# "main" defaults to LOCAL since we didn't set_project_mode
config_manager.save_config(cfg)
async with get_client(project_name="main") as client:
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
@pytest.mark.asyncio
async def test_get_client_local_project_honored_with_global_cloud_enabled(config_manager, config_home):
"""LOCAL project mode should take priority over global cloud mode fallback."""
cfg = config_manager.load_config()
cfg.cloud_mode = True
cfg.cloud_host = "https://cloud.example.test"
cfg.cloud_api_key = None
# "main" defaults to LOCAL since we didn't set_project_mode
config_manager.save_config(cfg)
# Should use ASGI transport without requiring OAuth token.
async with get_client(project_name="main") as client:
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
@pytest.mark.asyncio
async def test_get_client_no_project_name_uses_default_routing(config_manager, config_home):
"""Test that get_client without project_name falls through to default routing."""
cfg = config_manager.load_config()
cfg.cloud_mode = False
cfg.cloud_api_key = "bmc_test_key_123"
cfg.set_project_mode("research", ProjectMode.CLOUD)
config_manager.save_config(cfg)
# No project_name → should use local ASGI transport (cloud_mode is False)
async with get_client() as client:
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
@pytest.mark.asyncio
async def test_get_client_factory_overrides_per_project_routing(config_manager, config_home):
"""Test that injected factory takes priority over per-project routing."""
cfg = config_manager.load_config()
cfg.cloud_api_key = "bmc_test_key_123"
cfg.set_project_mode("research", ProjectMode.CLOUD)
config_manager.save_config(cfg)
@asynccontextmanager
async def factory():
async with httpx.AsyncClient(base_url="https://factory.test") as client:
yield client
set_client_factory(factory)
# Even though project is CLOUD, factory should take priority
async with get_client(project_name="research") as client:
assert str(client.base_url) == "https://factory.test"
# --- Per-project cloud routing with force-local ---
@pytest.mark.asyncio
async def test_get_client_per_project_cloud_bypasses_force_local(
config_manager, config_home, monkeypatch
):
"""CLOUD project routes to cloud even when BASIC_MEMORY_FORCE_LOCAL is set."""
cfg = config_manager.load_config()
cfg.cloud_mode = False
cfg.cloud_host = "https://cloud.example.test"
cfg.cloud_api_key = "bmc_test_key_123"
cfg.set_project_mode("research", ProjectMode.CLOUD)
config_manager.save_config(cfg)
monkeypatch.setenv("BASIC_MEMORY_FORCE_LOCAL", "true")
async with get_client(project_name="research") as client:
assert str(client.base_url).rstrip("/") == "https://cloud.example.test/proxy"
assert client.headers.get("Authorization") == "Bearer bmc_test_key_123"
@pytest.mark.asyncio
async def test_get_client_local_project_respects_force_local(
config_manager, config_home, monkeypatch
):
"""LOCAL project still uses ASGI transport when BASIC_MEMORY_FORCE_LOCAL is set."""
cfg = config_manager.load_config()
cfg.cloud_mode = False
cfg.cloud_api_key = "bmc_test_key_123"
# "main" defaults to LOCAL
config_manager.save_config(cfg)
monkeypatch.setenv("BASIC_MEMORY_FORCE_LOCAL", "true")
async with get_client(project_name="main") as client:
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
@pytest.mark.asyncio
async def test_get_client_per_project_cloud_oauth_fallback(config_manager, config_home):
"""CLOUD project uses OAuth token when no API key is configured."""
cfg = config_manager.load_config()
cfg.cloud_mode = False
cfg.cloud_host = "https://cloud.example.test"
cfg.cloud_api_key = None # No API key
cfg.cloud_client_id = "cid"
cfg.cloud_domain = "https://auth.example.test"
cfg.set_project_mode("research", ProjectMode.CLOUD)
config_manager.save_config(cfg)
# Write OAuth token file so CLIAuth.get_valid_token() returns it
auth = CLIAuth(client_id=cfg.cloud_client_id, authkit_domain=cfg.cloud_domain)
auth.token_file.parent.mkdir(parents=True, exist_ok=True)
auth.token_file.write_text(
'{"access_token":"oauth-token-456","refresh_token":null,"expires_at":9999999999,"token_type":"Bearer"}',
encoding="utf-8",
)
async with get_client(project_name="research") as client:
assert str(client.base_url).rstrip("/") == "https://cloud.example.test/proxy"
assert client.headers.get("Authorization") == "Bearer oauth-token-456"
+15 -9
View File
@@ -315,6 +315,7 @@ class TestSearchToolErrorHandling:
async def test_search_notes_exception_handling(self, monkeypatch):
"""Test exception handling in search_notes."""
import importlib
from contextlib import asynccontextmanager
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
clients_mod = importlib.import_module("basic_memory.mcp.clients")
@@ -325,8 +326,9 @@ class TestSearchToolErrorHandling:
id = 1
external_id = "test-external-id"
async def fake_get_active_project(*args, **kwargs):
return StubProject()
@asynccontextmanager
async def fake_get_project_client(*args, **kwargs):
yield (object(), StubProject())
# Mock SearchClient to raise an exception
class MockSearchClient:
@@ -336,7 +338,7 @@ class TestSearchToolErrorHandling:
async def search(self, *args, **kwargs):
raise Exception("syntax error")
monkeypatch.setattr(search_mod, "get_active_project", fake_get_active_project)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
# Patch at the clients module level where the import happens
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
@@ -348,6 +350,7 @@ class TestSearchToolErrorHandling:
async def test_search_notes_permission_error(self, monkeypatch):
"""Test search_notes with permission error."""
import importlib
from contextlib import asynccontextmanager
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
clients_mod = importlib.import_module("basic_memory.mcp.clients")
@@ -358,8 +361,9 @@ class TestSearchToolErrorHandling:
id = 1
external_id = "test-external-id"
async def fake_get_active_project(*args, **kwargs):
return StubProject()
@asynccontextmanager
async def fake_get_project_client(*args, **kwargs):
yield (object(), StubProject())
# Mock SearchClient to raise a permission error
class MockSearchClient:
@@ -369,7 +373,7 @@ class TestSearchToolErrorHandling:
async def search(self, *args, **kwargs):
raise Exception("permission denied")
monkeypatch.setattr(search_mod, "get_active_project", fake_get_active_project)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
# Patch at the clients module level where the import happens
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
@@ -383,6 +387,7 @@ class TestSearchToolErrorHandling:
async def test_search_notes_sets_retrieval_mode_for_semantic_types(monkeypatch, search_type):
"""Vector/hybrid search types should populate retrieval_mode in API payload."""
import importlib
from contextlib import asynccontextmanager
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
clients_mod = importlib.import_module("basic_memory.mcp.clients")
@@ -393,8 +398,9 @@ async def test_search_notes_sets_retrieval_mode_for_semantic_types(monkeypatch,
id = 1
external_id = "test-external-id"
async def fake_get_active_project(*args, **kwargs):
return StubProject()
@asynccontextmanager
async def fake_get_project_client(*args, **kwargs):
yield (object(), StubProject())
captured_payload: dict = {}
@@ -406,7 +412,7 @@ async def test_search_notes_sets_retrieval_mode_for_semantic_types(monkeypatch,
captured_payload.update(payload)
return SearchResponse(results=[], current_page=page, page_size=page_size)
monkeypatch.setattr(search_mod, "get_active_project", fake_get_active_project)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes.fn(
+38 -1
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass
import pytest
from basic_memory.config import BasicMemoryConfig
from basic_memory.config import BasicMemoryConfig, ProjectMode
from basic_memory.models.project import Project
from basic_memory.sync.watch_service import WatchService
@@ -143,6 +143,43 @@ async def test_run_reloads_projects_each_cycle(monkeypatch, tmp_path):
assert cycle_count == 2
@pytest.mark.asyncio
async def test_run_filters_cloud_projects_each_cycle(monkeypatch, tmp_path):
config = BasicMemoryConfig(
watch_project_reload_interval=1,
project_modes={"cloud-project": ProjectMode.CLOUD},
)
repo = _Repo(
projects_return=[
Project(id=1, name="local-project", path=str(tmp_path / "local"), permalink="local"),
Project(
id=2,
name="cloud-project",
path=str(tmp_path / "cloud"),
permalink="cloud",
),
]
)
watch_service = WatchService(config, repo, quiet=True)
seen_project_names: list[list[str]] = []
async def watch_cycle_stub(projects, stop_event):
seen_project_names.append([p.name for p in projects])
watch_service.state.running = False
stop_event.set()
async def fake_write_status():
return None
monkeypatch.setattr(watch_service, "_watch_projects_cycle", watch_cycle_stub)
monkeypatch.setattr(watch_service, "write_status", fake_write_status)
await watch_service.run()
assert seen_project_names == [["local-project"]]
@pytest.mark.asyncio
async def test_run_continues_after_cycle_error(monkeypatch, tmp_path):
config = BasicMemoryConfig()
+115 -1
View File
@@ -4,7 +4,7 @@ import tempfile
import pytest
from datetime import datetime
from basic_memory.config import BasicMemoryConfig, CloudProjectConfig, ConfigManager
from basic_memory.config import BasicMemoryConfig, CloudProjectConfig, ConfigManager, ProjectMode
from pathlib import Path
@@ -615,3 +615,117 @@ class TestFormattingConfig:
"json": "prettier --write {file}",
}
assert loaded_config.formatter_timeout == 10.0
class TestProjectMode:
"""Test per-project routing mode configuration."""
def test_project_mode_defaults(self):
"""Test that ProjectMode enum has expected values."""
assert ProjectMode.LOCAL.value == "local"
assert ProjectMode.CLOUD.value == "cloud"
def test_get_project_mode_defaults_to_local(self):
"""Test that unknown projects default to LOCAL mode."""
config = BasicMemoryConfig()
assert config.get_project_mode("nonexistent") == ProjectMode.LOCAL
def test_set_project_mode_cloud(self):
"""Test setting a project to cloud mode."""
config = BasicMemoryConfig()
config.set_project_mode("research", ProjectMode.CLOUD)
assert config.get_project_mode("research") == ProjectMode.CLOUD
def test_set_project_mode_local_removes_entry(self):
"""Test that setting a project back to LOCAL removes it from project_modes dict."""
config = BasicMemoryConfig()
config.set_project_mode("research", ProjectMode.CLOUD)
assert "research" in config.project_modes
config.set_project_mode("research", ProjectMode.LOCAL)
# LOCAL is the default, so the entry is removed to keep config clean
assert "research" not in config.project_modes
assert config.get_project_mode("research") == ProjectMode.LOCAL
def test_cloud_api_key_defaults_to_none(self):
"""Test that cloud_api_key defaults to None."""
config = BasicMemoryConfig()
assert config.cloud_api_key is None
def test_cloud_api_key_can_be_set(self):
"""Test that cloud_api_key can be configured."""
config = BasicMemoryConfig(cloud_api_key="bmc_test123")
assert config.cloud_api_key == "bmc_test123"
def test_project_modes_defaults_to_empty(self):
"""Test that project_modes defaults to empty dict."""
config = BasicMemoryConfig()
assert config.project_modes == {}
def test_project_modes_round_trip(self):
"""Test that project_modes survives save/load cycle."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Create config with project_modes and cloud_api_key
test_config = BasicMemoryConfig(
projects={"main": str(temp_path / "main"), "research": str(temp_path / "research")},
cloud_api_key="bmc_test123",
project_modes={"research": ProjectMode.CLOUD},
)
config_manager.save_config(test_config)
# Load and verify
loaded = config_manager.load_config()
assert loaded.cloud_api_key == "bmc_test123"
assert loaded.get_project_mode("research") == ProjectMode.CLOUD
assert loaded.get_project_mode("main") == ProjectMode.LOCAL
def test_backward_compat_loading_without_project_modes(self):
"""Test that old config files without project_modes field can be loaded."""
import json
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Write old-style config without project_modes or cloud_api_key
old_config_data = {
"env": "dev",
"projects": {"main": str(temp_path / "main")},
"default_project": "main",
"log_level": "INFO",
}
config_manager.config_file.write_text(json.dumps(old_config_data, indent=2))
# Clear config cache
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
# Should load successfully with defaults
config = config_manager.load_config()
assert config.project_modes == {}
assert config.cloud_api_key is None
assert config.get_project_mode("main") == ProjectMode.LOCAL
def test_project_list_includes_mode(self, config_home):
"""Test that project_list property includes mode information."""
config = BasicMemoryConfig(
projects={"main": str(config_home / "main"), "research": str(config_home / "research")},
project_modes={"research": ProjectMode.CLOUD},
)
project_list = config.project_list
modes_by_name = {p.name: p.mode for p in project_list}
assert modes_by_name["main"] == ProjectMode.LOCAL
assert modes_by_name["research"] == ProjectMode.CLOUD