From 0239f4abb44d42077144b8666ec3b53aa5fe8ba7 Mon Sep 17 00:00:00 2001 From: phernandez Date: Mon, 16 Feb 2026 21:42:25 -0600 Subject: [PATCH] Simplify local/cloud routing and clarify project targeting Signed-off-by: phernandez --- README.md | 39 ++- docs/ARCHITECTURE.md | 18 +- docs/SPEC-PER-PROJECT-ROUTING.md | 256 ++++++-------- docs/cloud-cli.md | 166 +++++---- src/basic_memory/api/container.py | 1 - .../cli/commands/cloud/core_commands.py | 77 ++--- src/basic_memory/cli/commands/mcp.py | 10 +- src/basic_memory/cli/commands/project.py | 325 ++++++++++------- src/basic_memory/cli/commands/routing.py | 22 +- src/basic_memory/cli/container.py | 1 - src/basic_memory/cli/promo.py | 6 +- src/basic_memory/config.py | 30 +- src/basic_memory/mcp/async_client.py | 326 ++++++------------ src/basic_memory/mcp/container.py | 1 - src/basic_memory/mcp/project_context.py | 13 +- src/basic_memory/mcp/server.py | 20 +- src/basic_memory/project_resolver.py | 124 +------ src/basic_memory/runtime.py | 8 - src/basic_memory/services/initialization.py | 2 +- src/basic_memory/services/project_service.py | 7 +- src/basic_memory/utils.py | 9 +- test-int/cli/test_routing_integration.py | 19 + test-int/conftest.py | 1 - tests/cli/test_cloud_authentication.py | 31 +- tests/cli/test_cloud_promo.py | 4 +- tests/cli/test_project_add_with_local_path.py | 11 +- tests/cli/test_project_list_and_ls.py | 245 +++++++++++++ tests/cli/test_routing.py | 19 +- tests/mcp/test_async_client_modes.py | 193 +++-------- tests/mcp/test_project_context.py | 50 +-- tests/mcp/test_server_lifespan_branches.py | 6 +- ...test_initialization_cloud_mode_branches.py | 9 +- tests/services/test_project_service.py | 50 +-- tests/test_config.py | 30 ++ tests/test_project_resolver.py | 164 ++------- tests/test_runtime.py | 26 +- tests/utils/test_timezone_utils.py | 7 +- 37 files changed, 1136 insertions(+), 1190 deletions(-) create mode 100644 tests/cli/test_project_list_and_ls.py diff --git a/README.md b/README.md index 7fa174d3..dc94e030 100644 --- a/README.md +++ b/README.md @@ -344,22 +344,20 @@ basic-memory sync --watch 3. Cloud features (optional, requires subscription): ```bash -# Authenticate with cloud (global cloud mode via OAuth) +# Authenticate with cloud (stores OAuth token locally) basic-memory cloud login -# Bidirectional sync with cloud -basic-memory cloud sync +# (Optional) install/configure rclone for file sync commands +basic-memory cloud setup -# Verify cloud integrity -basic-memory cloud check - -# Mount cloud storage -basic-memory cloud mount +# Check cloud auth + health +basic-memory cloud status ``` **Per-Project Cloud Routing** (API key based): -Individual projects can be routed through the cloud while others stay local. This uses an API key instead of OAuth: +Individual projects can be routed through the cloud while others stay local. This uses an API key for routed +project calls: ```bash # Save an API key (create one in the web app or via CLI) @@ -373,25 +371,32 @@ 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) +# List projects and route metadata basic-memory project list ``` -**Routing Flags** (for users with global cloud mode): +`basic-memory cloud login` / `basic-memory cloud logout` are authentication commands. They do not change default CLI +routing behavior. -When global cloud mode is enabled, CLI commands communicate with the cloud API by default. Use routing flags to override this: +**Routing Flags**: + +Use routing flags to disambiguate command targets: ```bash -# Force local routing (useful for local MCP server while cloud mode is enabled) +# Force local routing for this command basic-memory status --local basic-memory project list --local +basic-memory project ls --name main --local -# Force cloud routing (when cloud mode is disabled but you want cloud access) +# Force cloud routing for this command basic-memory status --cloud basic-memory project info my-project --cloud +basic-memory project ls --name main --cloud ``` -The local MCP server (`basic-memory mcp`) automatically uses local routing, so you can use both local Claude Desktop and cloud-based clients simultaneously. +No-flag behavior defaults to local when no project context is present. + +The local MCP server (`basic-memory mcp`) always uses local routing (including `--transport stdio`). **CLI Note Editing (`tool edit-note`):** @@ -493,7 +498,9 @@ Basic Memory uses [Loguru](https://github.com/Delgan/loguru) for logging. The lo |----------|---------|-------------| | `BASIC_MEMORY_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | | `BASIC_MEMORY_CLOUD_MODE` | `false` | When `true`, API logs to stdout with structured context | -| `BASIC_MEMORY_FORCE_LOCAL` | `false` | When `true`, forces local API routing (ignores cloud mode) | +| `BASIC_MEMORY_FORCE_LOCAL` | `false` | When `true`, forces local API routing | +| `BASIC_MEMORY_FORCE_CLOUD` | `false` | When `true`, forces cloud API routing | +| `BASIC_MEMORY_EXPLICIT_ROUTING` | `false` | When `true`, marks route selection as explicit (`--local`/`--cloud`) | | `BASIC_MEMORY_ENV` | `dev` | Set to `test` for test mode (stderr only) | ### Examples diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6461347c..8c8c2544 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,7 +18,7 @@ Each entrypoint uses a **composition root** pattern to manage configuration and A composition root is the single place in an application where dependencies are wired together. In Basic Memory, each entrypoint has its own composition root that: 1. Reads configuration from `ConfigManager` -2. Resolves runtime mode (cloud/local/test) +2. Resolves runtime mode (local/test) 3. Creates and provides dependencies to downstream code **Key principle**: Only composition roots read global configuration. All other modules receive configuration explicitly. @@ -52,10 +52,7 @@ class Container: def create(cls) -> "Container": """Create container by reading ConfigManager.""" config = ConfigManager().config - mode = resolve_runtime_mode( - cloud_mode_enabled=config.cloud_mode_enabled, - is_test_env=config.is_test_env, - ) + mode = resolve_runtime_mode(is_test_env=config.is_test_env) return cls(config=config, mode=mode) @property @@ -99,18 +96,19 @@ class RuntimeMode(Enum): return self == RuntimeMode.TEST ``` -Resolution follows this precedence: **TEST > CLOUD > LOCAL** +Resolution follows this precedence in local app flows: **TEST > LOCAL** ```python -def resolve_runtime_mode(cloud_mode_enabled: bool, is_test_env: bool) -> RuntimeMode: +def resolve_runtime_mode(is_test_env: bool) -> RuntimeMode: if is_test_env: return RuntimeMode.TEST - if cloud_mode_enabled: - return RuntimeMode.CLOUD 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. +**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`, +which affects client routing in `get_client(project_name=...)` without changing global runtime mode. +`RuntimeMode.CLOUD` may remain for compatibility, but standard local runtime resolution does not select it. ## Dependencies Package diff --git a/docs/SPEC-PER-PROJECT-ROUTING.md b/docs/SPEC-PER-PROJECT-ROUTING.md index 6bf175a5..52b7d4ea 100644 --- a/docs/SPEC-PER-PROJECT-ROUTING.md +++ b/docs/SPEC-PER-PROJECT-ROUTING.md @@ -1,175 +1,135 @@ -# Per-Project Local/Cloud Routing +# Simplified 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. +Basic Memory now uses explicit, project-aware routing without a global cloud-mode toggle. +Routing is determined by command-level flags and project mode, not by a global `cloud_mode` state. -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. +This document is the canonical contract for local/cloud routing behavior in CLI, MCP, and API-adjacent clients. -**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. +## Goals -## UX Flow +1. Remove global `cloud_mode` from runtime/routing semantics. +2. Keep MCP stdio local-only and predictable. +3. Make CLI routing explicit and easy to reason about. +4. Support projects that exist in both local and cloud without ambiguity. -**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 +## Routing Contract -**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 +Routing is resolved in this order: -**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) -``` +1. Injected client factory (for composition/integration contexts) +2. Explicit routing override (`--local` / `--cloud` or env vars below) +3. Project-scoped routing (`project.mode`) when a project is known +4. Default local routing -## Implementation Plan +### Routing Environment Variables -### Step 1: Config model changes +- `BASIC_MEMORY_FORCE_LOCAL=true`: force local transport +- `BASIC_MEMORY_FORCE_CLOUD=true`: force cloud proxy transport +- `BASIC_MEMORY_EXPLICIT_ROUTING=true`: marks routing as explicitly chosen for this command -**File: `src/basic_memory/config.py`** +When explicit routing is active, project mode does not override the selected route. -- 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` +## Config Semantics -### Step 2: Client routing +- `project.mode` is the only config-based routing signal for project-scoped operations. +- Legacy `cloud_mode` values may be encountered during migration/loading but are not used for routing behavior. +- Normalization saves remove stale `cloud_mode` from `~/.basic-memory/config.json`. -**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 ` — saves API key to config.json -- `bm cloud create-key ` — 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 ` — sets project mode to cloud (validates API key exists in config) -- `bm project set-local ` — 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 +### Example Config ```json { "projects": { - "personal": {"path": "/Users/me/notes", "mode": "local"}, - "research": {"path": "/Users/me/research", "mode": "cloud"} + "main": { + "path": "/Users/me/basic-memory", + "mode": "local", + "cloud_sync_path": null, + "bisync_initialized": false, + "last_sync": null + }, + "specs": { + "path": "specs", + "mode": "cloud", + "cloud_sync_path": "/Users/me/dev/specs", + "bisync_initialized": true, + "last_sync": "2026-02-06T17:36:38.544153" + } }, + "default_project": "main", "cloud_api_key": "bmc_abc123...", - "cloud_host": "https://cloud.basicmemory.com", - "default_project": "personal" + "cloud_host": "https://cloud.basicmemory.com" } ``` -## Edge Cases +## Cloud Commands Are Auth-Only -| 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 | +`bm cloud login`, `bm cloud logout`, and `bm cloud status` manage authentication state. -## Verification +- `bm cloud login` + - performs OAuth device flow + - stores/refreshes token material + - may verify cloud health/subscription + - does not change routing defaults +- `bm cloud logout` + - removes stored OAuth session tokens + - does not change routing defaults +- `bm cloud status` + - reports auth state (API key, OAuth token validity) + - runs health checks only when credentials are available -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 +## MCP Stdio Local Guarantee + +`bm mcp --transport stdio` always routes locally. + +The command sets explicit local routing (`BASIC_MEMORY_FORCE_LOCAL=true` and +`BASIC_MEMORY_EXPLICIT_ROUTING=true`) before starting the server. This prevents cloud routing for stdio MCP, +even if the selected project has `mode: cloud`. + +## Project List UX for Dual Presence + +Projects may exist in both local and cloud. `bm project list` should display that clearly in one row per logical +project identity, with explicit source/target signals. + +Recommended display contract: + +1. Keep one row per normalized project name/permalink. +2. Show both local and cloud presence as separate columns/indicators. +3. Show an explicit `MCP (stdio)` target column that always resolves to `local`. +4. Keep CLI route semantics explicit: + - no flags: default local for non-project commands + - `--cloud`: force cloud + - `--local`: force local + +## Project LS Targeting + +`bm project ls` should clearly identify which project instance is being listed. + +Targeting rules: + +1. No routing flags: list local project files. +2. `--cloud`: list cloud project files. +3. `--local`: list local project files (explicit override). +4. Output should label the active target (`LOCAL` or `CLOUD`) in heading or status line. + +## Runtime Mode + +Runtime mode is no longer a cloud/local routing switch for local app flows. + +- `resolve_runtime_mode(is_test_env)` resolves to: + - `TEST` when running in test environment + - `LOCAL` otherwise +- `RuntimeMode.CLOUD` may remain for compatibility with existing tests/call sites but is not selected by normal local + runtime resolution. + +## Verification Checklist + +1. Loading config with legacy `cloud_mode` succeeds. +2. Saving config strips legacy `cloud_mode`. +3. `--local/--cloud` always override per-project mode for that command. +4. No-project + no-flags commands route local by default. +5. `bm cloud login/logout` do not toggle routing behavior. +6. `bm mcp` remains local-only in stdio mode. +7. `bm project list` communicates dual local/cloud presence without ambiguity. +8. `bm project ls` output identifies route target explicitly. diff --git a/docs/cloud-cli.md b/docs/cloud-cli.md index 88840aef..1684eb87 100644 --- a/docs/cloud-cli.md +++ b/docs/cloud-cli.md @@ -5,7 +5,7 @@ The Basic Memory Cloud CLI provides seamless integration between local and cloud ## Overview The cloud CLI enables you to: -- **Toggle cloud mode** - All regular `bm` commands work with cloud when enabled +- **Authenticate cloud access** - OAuth/API key credentials are stored locally for cloud operations - **Project-scoped sync** - Each project independently manages its sync configuration - **Explicit operations** - Sync only what you want, when you want - **Bidirectional sync** - Keep local and cloud in sync with rclone bisync @@ -40,7 +40,7 @@ If you attempt to log in without an active subscription, you'll receive a "Subsc 1. **Cloud-only** - Project exists on cloud, no local copy 2. **Cloud + Local (synced)** - Project has a local working directory that syncs -3. **Local-only** - Project exists locally (when cloud mode is disabled) +3. **Local-only** - Project exists locally and is not routed to cloud **Example:** @@ -50,9 +50,9 @@ If you attempt to log in without an active subscription, you'll receive a "Subsc # - work: wants local sync at ~/work-notes # - temp: cloud-only, no local sync needed -bm project add research --local-path ~/Documents/research -bm project add work --local-path ~/work-notes -bm project add temp # No local sync +bm project add research --cloud --local-path ~/Documents/research +bm project add work --cloud --local-path ~/work-notes +bm project add temp --cloud # No local sync # Now you can sync individually (after initial --resync): bm project bisync --name research @@ -68,9 +68,9 @@ bm project bisync --name work ## Quick Start -### 1. Enable Cloud Mode +### 1. Authenticate Cloud Access -Authenticate and enable cloud mode: +Authenticate with cloud: ```bash bm cloud login @@ -78,11 +78,11 @@ bm cloud login **What this does:** 1. Opens browser to Basic Memory Cloud authentication page -2. Stores authentication token in `~/.basic-memory/auth/token` -3. **Enables cloud mode** - all CLI commands now work against cloud -4. Validates your subscription status +2. Stores authentication tokens in `~/.basic-memory/basic-memory-cloud.json` +3. Validates your subscription status +4. Leaves routing behavior unchanged (auth only) -**Result:** All `bm project`, `bm tools` commands now work with cloud. +**Result:** Cloud credentials are available for cloud-routed commands. Apply OSS discount code `{{OSS_DISCOUNT_CODE}}` during checkout to receive 20% off for 3 months. ### 2. Set Up Sync @@ -107,10 +107,10 @@ Create projects with optional local sync paths: ```bash # Create cloud project without local sync -bm project add research +bm project add research --cloud # Create cloud project WITH local sync -bm project add research --local-path ~/Documents/research +bm project add research --cloud --local-path ~/Documents/research # Or configure sync for existing project bm project sync-setup research ~/Documents/research @@ -120,7 +120,7 @@ bm project sync-setup research ~/Documents/research When you add a project with `--local-path`: 1. Project created on cloud at `/app/data/research` -2. Local path stored in config: `cloud_projects.research.local_path = "~/Documents/research"` +2. Local path stored in config for that project (`cloud_sync_path`) 3. Local directory created if it doesn't exist 4. Bisync state directory created at `~/.basic-memory/bisync-state/research/` @@ -182,7 +182,8 @@ bm cloud status ``` You should see: -- `Mode: Cloud (enabled)` +- `OAuth: token valid` (or missing/expired) +- `API Key: configured` (or not set) - `Cloud instance is healthy` - Instructions for project sync commands @@ -190,16 +191,16 @@ You should see: ### Understanding Project Commands -**Key concept:** When cloud mode is enabled, use regular `bm project` commands (not `bm cloud project`). +**Key concept:** Use regular `bm project` commands (not `bm cloud project`). ```bash -# In cloud mode: -bm project list # Lists cloud projects -bm project add research # Creates cloud project +# Local route +bm project list --local +bm project add research ~/Documents/research -# In local mode: -bm project list # Lists local projects -bm project add research ~/Documents/research # Creates local project +# Cloud route +bm project list --cloud +bm project add research --cloud ``` ### Creating Projects @@ -207,7 +208,7 @@ bm project add research ~/Documents/research # Creates local project **Use case 1: Cloud-only project (no local sync)** ```bash -bm project add temp-notes +bm project add temp-notes --cloud ``` **What this does:** @@ -220,7 +221,7 @@ bm project add temp-notes **Use case 2: Cloud project with local sync** ```bash -bm project add research --local-path ~/Documents/research +bm project add research --cloud --local-path ~/Documents/research ``` **What this does:** @@ -254,11 +255,35 @@ bm project list ``` **What you see:** -- All projects in cloud (when cloud mode enabled) +- Local projects always +- Cloud projects when credentials are available - Default project marked -- Project paths shown +- Route-related metadata (for example, local/cloud presence and sync info) -**Future:** Will show sync status (synced/not synced, last sync time). +Example shape (single row for dual-presence projects): + +```text +Name Path Local Path Cloud Path CLI Default MCP (stdio) +main /basic-memory ~/basic-memory /basic-memory local local +specs /specs ~/dev/specs /specs cloud local +``` + +### When a Project Exists in Both Local and Cloud + +Use routing flags to disambiguate command targets: + +```bash +# Force local target for this command +bm project info main --local +bm project ls --name main --local + +# Force cloud target for this command +bm project info main --cloud +bm project ls --name main --cloud +``` + +Default behavior for no-project, no-flag commands is local. +For MCP stdio, routing is always local. ## File Synchronization @@ -366,24 +391,28 @@ bm project bisync --name research --dry-run **Result:** Safe preview of sync operations. -### Advanced: List Remote Files +### Advanced: List Project Files by Route -**Use case:** See what files exist on cloud without syncing. +**Use case:** Inspect local or cloud project files explicitly. ```bash -# List all files in project +# List local project files (default target when no route flag is given) bm project ls --name research +bm project ls --name research --local + +# List cloud project files +bm project ls --name research --cloud # List files in subdirectory -bm project ls --name research --path subfolder +bm project ls --name research --cloud --path subfolder ``` **What happens:** -1. Connects to cloud via rclone -2. Lists files in remote project path +1. Resolves route from flags (or local default when no route is given) +2. Lists files for the chosen project instance 3. No files transferred -**Result:** See cloud file listing. +**Result:** See file listing for the target route. ## Multiple Projects @@ -393,9 +422,9 @@ bm project ls --name research --path subfolder ```bash # Setup multiple projects -bm project add research --local-path ~/Documents/research -bm project add work --local-path ~/work-notes -bm project add personal --local-path ~/personal +bm project add research --cloud --local-path ~/Documents/research +bm project add work --cloud --local-path ~/work-notes +bm project add personal --cloud --local-path ~/personal # Establish baselines bm project bisync --name research --resync @@ -420,12 +449,12 @@ bm project bisync --all # Coming soon ```bash # Projects with sync -bm project add research --local-path ~/Documents/research -bm project add work --local-path ~/work +bm project add research --cloud --local-path ~/Documents/research +bm project add work --cloud --local-path ~/work # Cloud-only projects -bm project add archive -bm project add temp-notes +bm project add archive --cloud +bm project add temp-notes --cloud # Sync only the configured ones bm project bisync --name research @@ -438,7 +467,7 @@ bm project bisync --name work ## 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. +Route individual projects through cloud using an API key. This lets you keep some projects local while others route through cloud. ### Setting Up API Key Auth @@ -485,10 +514,16 @@ When an MCP tool or CLI command runs for a cloud-mode project: **Routing priority** (highest to lowest): 1. Factory injection (cloud app, tests) -2. `BASIC_MEMORY_FORCE_LOCAL` env var +2. Explicit route override (`--local` / `--cloud`) 3. Per-project cloud mode (API key) -4. Global cloud mode (OAuth — deprecated fallback) -5. Local ASGI transport (default) +4. Local ASGI transport (default) + +Route override environment variables: +- `BASIC_MEMORY_FORCE_LOCAL=true` +- `BASIC_MEMORY_FORCE_CLOUD=true` +- `BASIC_MEMORY_EXPLICIT_ROUTING=true` + +No-project, no-flag CLI commands default to local routing. ### Configuration Example @@ -513,20 +548,18 @@ In this example, `personal` stays local and `research` routes through cloud. Pro 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 (global): +## OAuth Logout ```bash bm cloud logout ``` **What this does:** -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) +1. Removes stored OAuth token(s) +2. Does not change per-project route configuration +3. Does not change command routing defaults -**Result:** All `bm` commands work with local projects again. Per-project cloud routing via API key continues to work independently of global cloud mode. +**Result:** OAuth session is cleared. API-key-based routing still works if `cloud_api_key` is configured. ## Filter Configuration @@ -733,12 +766,12 @@ If instance is down, wait a few minutes and retry. ## Command Reference -### Cloud Mode Management +### Cloud Authentication ```bash -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 login # Authenticate and store OAuth credentials +bm cloud logout # Remove stored OAuth credentials +bm cloud status # Check auth state and instance health bm cloud promo --off # Disable CLI cloud promo notices ``` @@ -757,12 +790,11 @@ bm cloud setup # Install rclone and configure credentials ### Project Management -When cloud mode is enabled: - ```bash -bm project list # List projects with mode column -bm project add # Create cloud project (no sync) -bm project add --local-path # Create with local sync +bm project list --local # Local project list +bm project list --cloud # Cloud project list +bm project add --cloud # Create cloud project (no sync) +bm project add --cloud --local-path # Create with local sync bm project sync-setup # Add sync to existing project bm project rm # Delete project ``` @@ -792,18 +824,20 @@ bm project bisync --name --verbose bm project check --name bm project check --name --one-way -# List remote files -bm project ls --name -bm project ls --name --path +# List project files by route +bm project ls --name # Default target: local +bm project ls --name --local +bm project ls --name --cloud +bm project ls --name --cloud --path ``` ## Summary **Basic Memory Cloud uses project-scoped sync:** -1. **Enable cloud mode** - `bm cloud login` +1. **Authenticate cloud access** - `bm cloud login` 2. **Install rclone** - `bm cloud setup` -3. **Add projects with sync** - `bm project add research --local-path ~/Documents/research` +3. **Add projects with sync** - `bm project add research --cloud --local-path ~/Documents/research` 4. **Preview first sync** - `bm project bisync --name research --resync --dry-run` 5. **Establish baseline** - `bm project bisync --name research --resync` 6. **Daily workflow** - `bm project bisync --name research` diff --git a/src/basic_memory/api/container.py b/src/basic_memory/api/container.py index a333f30f..e048c307 100644 --- a/src/basic_memory/api/container.py +++ b/src/basic_memory/api/container.py @@ -47,7 +47,6 @@ class ApiContainer: """ config = ConfigManager().config mode = resolve_runtime_mode( - cloud_mode_enabled=config.cloud_mode_enabled, is_test_env=config.is_test_env, ) return cls(config=config, mode=mode) diff --git a/src/basic_memory/cli/commands/cloud/core_commands.py b/src/basic_memory/cli/commands/cloud/core_commands.py index 7d86f23b..3abf1783 100644 --- a/src/basic_memory/cli/commands/cloud/core_commands.py +++ b/src/basic_memory/cli/commands/cloud/core_commands.py @@ -30,7 +30,7 @@ console = Console() @cloud_app.command() def login(): - """Authenticate with WorkOS using OAuth Device Authorization flow and enable cloud mode.""" + """Authenticate with WorkOS using OAuth Device Authorization flow.""" async def _login(): client_id, domain, host_url = get_cloud_config() @@ -46,14 +46,8 @@ def login(): console.print("[dim]Verifying subscription access...[/dim]") await make_api_request("GET", f"{host_url.rstrip('/')}/proxy/health") - # Enable cloud mode after successful login and subscription validation - config_manager = ConfigManager() - config = config_manager.load_config() - config.cloud_mode = True - config_manager.save_config(config) - - console.print("[green]Cloud mode enabled[/green]") - console.print(f"[dim]All CLI commands now work against {host_url}[/dim]") + console.print("[green]Cloud authentication successful[/green]") + console.print(f"[dim]Cloud host ready: {host_url}[/dim]") except SubscriptionRequiredError as e: console.print("\n[red]Subscription Required[/red]\n") @@ -72,50 +66,52 @@ def login(): @cloud_app.command() def logout(): - """Disable cloud mode and return to local mode.""" - - # Disable cloud mode - config_manager = ConfigManager() - config = config_manager.load_config() - config.cloud_mode = False - config_manager.save_config(config) - - console.print("[green]Cloud mode disabled[/green]") - console.print("[dim]All CLI commands now work locally[/dim]") + """Remove stored OAuth tokens.""" + config = ConfigManager().config + auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain) + auth.logout() + console.print("[dim]API key (if configured) remains available for cloud project routing.[/dim]") @cloud_app.command("status") def status() -> None: - """Check cloud mode status and cloud instance health.""" - # Check cloud mode + """Check cloud authentication state and cloud instance health.""" config_manager = ConfigManager() config = config_manager.load_config() + auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain) + tokens = auth.load_tokens() - console.print("[bold blue]Cloud Mode Status[/bold blue]") - if config.cloud_mode: - console.print(" Mode: [green]Cloud (enabled)[/green]") - console.print(f" Host: {config.cloud_host}") - console.print(" [dim]All CLI commands work against cloud[/dim]") - else: - console.print(" Mode: [yellow]Local (disabled)[/yellow]") - console.print(" [dim]All CLI commands work locally[/dim]") - console.print("\n[dim]To enable cloud mode, run: bm cloud login[/dim]") - return + console.print("[bold blue]Cloud Authentication Status[/bold blue]") + console.print(f" Host: {config.cloud_host}") + console.print( + f" API Key: {'[green]configured[/green]' if config.cloud_api_key else '[yellow]not set[/yellow]'}" + ) + + oauth_status = "[yellow]not logged in[/yellow]" + if tokens: + oauth_status = ( + "[green]token valid[/green]" + if auth.is_token_valid(tokens) + else "[yellow]token expired[/yellow]" + ) + console.print(f" OAuth: {oauth_status}") # Get cloud configuration _, _, host_url = get_cloud_config() host_url = host_url.rstrip("/") - # Prepare headers - headers = {} + has_credentials = bool(config.cloud_api_key) or tokens is not None + if not has_credentials: + console.print( + "\n[dim]No cloud credentials found. Run: bm cloud login or bm cloud set-key [/dim]" + ) + return try: console.print("\n[blue]Checking cloud instance health...[/blue]") # Make API request to check health - response = run_with_cleanup( - make_api_request(method="GET", url=f"{host_url}/proxy/health", headers=headers) - ) + response = run_with_cleanup(make_api_request(method="GET", url=f"{host_url}/proxy/health")) health_data = response.json() @@ -132,11 +128,12 @@ def status() -> None: console.print("\n[dim]To sync projects, use: bm project bisync --name [/dim]") except CloudAPIError as e: - console.print(f"[red]Error checking cloud health: {e}[/red]") - raise typer.Exit(1) + console.print(f"[yellow]Cloud health check failed: {e}[/yellow]") + console.print( + "[dim]Try re-authenticating with 'bm cloud login' or setting API key with 'bm cloud set-key'.[/dim]" + ) except Exception as e: - console.print(f"[red]Unexpected error: {e}[/red]") - raise typer.Exit(1) + console.print(f"[yellow]Unexpected health check error: {e}[/yellow]") @cloud_app.command("setup") diff --git a/src/basic_memory/cli/commands/mcp.py b/src/basic_memory/cli/commands/mcp.py index 26c26848..bda57f78 100644 --- a/src/basic_memory/cli/commands/mcp.py +++ b/src/basic_memory/cli/commands/mcp.py @@ -45,10 +45,14 @@ def mcp( Users who have cloud mode enabled can still use local MCP for Claude Code and Claude Desktop while using cloud MCP for web and mobile access. """ - # Force local routing for local MCP server - # Why: The local MCP server should always talk to the local API, not the cloud proxy. - # Even when cloud_mode_enabled is True, stdio MCP runs locally and needs local API access. + # Force local routing for local MCP server. + # Trigger: MCP server command invocation (all transports). + # Why: local MCP must never route through cloud; stdio in particular must + # remain local-only to avoid cross-environment ambiguity. + # Outcome: explicit local override disables per-project cloud routing. os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true" + os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None) + os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true" # Import mcp tools/prompts to register them with the server import basic_memory.mcp.tools # noqa: F401 # pragma: no cover diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index e07e8447..36f5a8a3 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -11,12 +11,13 @@ from rich.panel import Panel from rich.table import Table from basic_memory.cli.app import app +from basic_memory.cli.auth import CLIAuth 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, ProjectEntry, 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 +from basic_memory.schemas.project_info import ProjectItem, ProjectList, ProjectStatusResponse from basic_memory.schemas.v2 import ProjectResolveResponse from basic_memory.utils import generate_permalink, normalize_project_path @@ -46,18 +47,31 @@ def format_path(path: str) -> str: return path +def _has_cloud_credentials(config) -> bool: + """Return whether cloud credentials are available (API key or OAuth token).""" + if config.cloud_api_key: + return True + + auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain) + return auth.load_tokens() is not None + + +def _require_cloud_credentials(config) -> None: + """Exit with actionable guidance when cloud credentials are missing.""" + if _has_cloud_credentials(config): + return + + console.print("[red]Error: cloud credentials are required for this command[/red]") + console.print("[dim]Run 'bm cloud login' or 'bm cloud set-key ' first[/dim]") + raise typer.Exit(1) + + @project_app.command("list") def list_projects( - local: bool = typer.Option( - False, "--local", help="Force local API routing (ignore cloud mode)" - ), + local: bool = typer.Option(False, "--local", help="Force local routing for this command"), cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"), ) -> None: - """List all Basic Memory projects. - - Use --local to force local routing when cloud mode is enabled. - Use --cloud to force cloud routing when cloud mode is disabled. - """ + """List Basic Memory projects from local and (when available) cloud.""" try: validate_routing_flags(local, cloud) except ValueError as e: @@ -70,51 +84,112 @@ def list_projects( return ProjectList.model_validate(response.json()) try: - with force_routing(local=local, cloud=cloud): - result = run_with_cleanup(_list_projects()) config = ConfigManager().config + local_result: ProjectList | None = None + cloud_result: ProjectList | None = None + cloud_error: Exception | None = None + + if cloud: + with force_routing(cloud=True): + cloud_result = run_with_cleanup(_list_projects()) + elif local: + with force_routing(local=True): + local_result = run_with_cleanup(_list_projects()) + else: + # Default behavior: always show local projects first. + with force_routing(local=True): + local_result = run_with_cleanup(_list_projects()) + + if _has_cloud_credentials(config): + try: + with force_routing(cloud=True): + cloud_result = run_with_cleanup(_list_projects()) + except Exception as exc: # pragma: no cover + cloud_error = exc 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 cloud-specific columns when in cloud mode - if config.cloud_mode_enabled and not local: - table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold") - table.add_column("Sync", style="green") - + table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold") + table.add_column("Cloud Path", style="green") + table.add_column("CLI Route", style="blue") + table.add_column("MCP (stdio)", style="blue") + table.add_column("Sync", style="green") table.add_column("Default", style="magenta") - for project in result.projects: - is_default = "[X]" if project.is_default else "" - normalized_path = normalize_project_path(project.path) - # Trigger: cloud mode and project not in local config - # Why: cloud-discovered projects default to LOCAL in get_project_mode - # Outcome: show "cloud" for projects only known to the cloud API - entry = config.projects.get(project.name) - if config.cloud_mode_enabled and not local and entry is None: - project_mode = ProjectMode.CLOUD.value + project_names_by_permalink: dict[str, str] = {} + local_projects_by_permalink: dict[str, ProjectItem] = {} + cloud_projects_by_permalink: dict[str, ProjectItem] = {} + + if local_result: + for project in local_result.projects: + permalink = generate_permalink(project.name) + project_names_by_permalink[permalink] = project.name + local_projects_by_permalink[permalink] = project + + if cloud_result: + for project in cloud_result.projects: + permalink = generate_permalink(project.name) + project_names_by_permalink[permalink] = project.name + cloud_projects_by_permalink[permalink] = project + + for permalink in sorted(project_names_by_permalink): + project_name = project_names_by_permalink[permalink] + local_project = local_projects_by_permalink.get(permalink) + cloud_project = cloud_projects_by_permalink.get(permalink) + entry = config.projects.get(project_name) + + local_path = "" + if local_project is not None: + local_path = format_path(normalize_project_path(local_project.path)) + elif entry and entry.cloud_sync_path: + local_path = format_path(entry.cloud_sync_path) + elif entry and entry.mode == ProjectMode.LOCAL and entry.path: + local_path = format_path(normalize_project_path(entry.path)) + + cloud_path = "" + if cloud_project is not None: + cloud_path = normalize_project_path(cloud_project.path) + + if local: + cli_route = "local (flag)" + elif cloud: + cli_route = "cloud (flag)" + elif entry: + cli_route = entry.mode.value + elif cloud_project is not None and local_project is None: + cli_route = ProjectMode.CLOUD.value else: - project_mode = config.get_project_mode(project.name).value + cli_route = ProjectMode.LOCAL.value - # Build row based on mode - row = [project.name, format_path(normalized_path), project_mode] + is_default = "" + if config.default_project == project_name: + is_default = "[X]" + if local_project is not None and local_project.is_default: + is_default = "[X]" + if cloud_project is not None and cloud_project.is_default: + is_default = "[X]" - # Add cloud-specific columns - if config.cloud_mode_enabled and not local: - local_path = "" - if entry: - local_path = format_path(entry.cloud_sync_path or entry.path) - row.append(local_path) - has_sync = "[X]" if entry and entry.cloud_sync_path else "" - row.append(has_sync) + has_sync = "[X]" if entry and entry.cloud_sync_path else "" + mcp_stdio_target = "local" if local_project is not None else "n/a" - row.append(is_default) + row = [ + project_name, + local_path, + cloud_path, + cli_route, + mcp_stdio_target, + has_sync, + is_default, + ] table.add_row(*row) console.print(table) + if cloud_error is not None: + console.print( + "[yellow]Cloud project discovery failed. " + "Showing local projects only. Run 'bm cloud login' or 'bm cloud set-key '.[/yellow]" + ) except Exception as e: console.print(f"[red]Error listing projects: {str(e)}[/red]") raise typer.Exit(1) @@ -155,8 +230,8 @@ def add_project( config = ConfigManager().config - # Determine effective mode: local flag forces local mode behavior - effective_cloud_mode = config.cloud_mode_enabled and not local + # Determine effective mode: default local, cloud only when explicitly requested. + effective_cloud_mode = cloud and not local # Resolve local sync path early (needed for both cloud and local mode) local_sync_path: str | None = None @@ -164,6 +239,7 @@ def add_project( local_sync_path = Path(os.path.abspath(os.path.expanduser(local_path))).as_posix() if effective_cloud_mode: + _require_cloud_credentials(config) # Cloud mode: path auto-generated from name, local sync is optional async def _add_project(): @@ -235,10 +311,7 @@ def setup_project_sync( """ config_manager = ConfigManager() config = config_manager.config - - if not config.cloud_mode_enabled: - console.print("[red]Error: sync-setup only available in cloud mode[/red]") - raise typer.Exit(1) + _require_cloud_credentials(config) async def _verify_project_exists(): """Verify the project exists on cloud by listing all projects.""" @@ -252,7 +325,8 @@ def setup_project_sync( try: # Verify project exists on cloud - run_with_cleanup(_verify_project_exists()) + with force_routing(cloud=True): + run_with_cleanup(_verify_project_exists()) # Resolve and create local path resolved_path = Path(os.path.abspath(os.path.expanduser(local_path))) @@ -326,7 +400,7 @@ def remove_project( has_bisync_state = False entry = config.projects.get(name) - if config.cloud_mode_enabled and not local and entry and entry.cloud_sync_path: + if cloud and entry and entry.cloud_sync_path: local_path_config = entry.cloud_sync_path # Check for bisync state @@ -360,7 +434,7 @@ def remove_project( console.print("[green]Removed bisync state[/green]") # Clean up cloud sync fields on the project entry - if config.cloud_mode_enabled and not local and entry and entry.cloud_sync_path: + if cloud and entry and entry.cloud_sync_path: entry.cloud_sync_path = None entry.bisync_initialized = False entry.last_sync = None @@ -387,17 +461,6 @@ def set_default_project( In cloud mode, use --local to modify the local configuration. """ - config = ConfigManager().config - - # Trigger: cloud mode enabled without --local flag - # Why: default project is a local configuration concept - # Outcome: require explicit --local flag to modify local config in cloud mode - if config.cloud_mode_enabled and not local: - console.print( - "[red]Error: 'default' command requires --local flag in cloud mode[/red]\n" - "[yellow]Hint: Use 'bm project default --local' to set local default[/yellow]" - ) - raise typer.Exit(1) async def _set_default(): async with get_client() as client: @@ -434,17 +497,6 @@ def synchronize_projects( In cloud mode, use --local to sync local configuration. """ - config = ConfigManager().config - - # Trigger: cloud mode enabled without --local flag - # Why: sync-config syncs local config file with local database - # Outcome: require explicit --local flag to clarify intent in cloud mode - if config.cloud_mode_enabled and not local: - console.print( - "[red]Error: 'sync-config' command requires --local flag in cloud mode[/red]\n" - "[yellow]Hint: Use 'bm project sync-config --local' to sync local config[/yellow]" - ) - raise typer.Exit(1) async def _sync_config(): async with get_client() as client: @@ -472,18 +524,6 @@ def move_project( In cloud mode, use --local to modify local project paths. """ - config = ConfigManager().config - - # Trigger: cloud mode enabled without --local flag - # Why: moving a project is a local file system operation - # Outcome: require explicit --local flag to clarify intent in cloud mode - if config.cloud_mode_enabled and not local: - console.print( - "[red]Error: 'move' command requires --local flag in cloud mode[/red]\n" - "[yellow]Hint: Use 'bm project move --local' to move local project[/yellow]" - ) - raise typer.Exit(1) - # Resolve to absolute path resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix() @@ -606,9 +646,7 @@ def sync_project_command( bm project sync --name research --dry-run """ config = ConfigManager().config - if not config.cloud_mode_enabled: - console.print("[red]Error: sync only available in cloud mode[/red]") - raise typer.Exit(1) + _require_cloud_credentials(config) try: # Get tenant info for bucket name @@ -625,7 +663,8 @@ def sync_project_command( return proj return None - project_data = run_with_cleanup(_get_project()) + with force_routing(cloud=True): + project_data = run_with_cleanup(_get_project()) if not project_data: console.print(f"[red]Error: Project '{name}' not found[/red]") raise typer.Exit(1) @@ -666,7 +705,8 @@ def sync_project_command( return response.json() try: - result = run_with_cleanup(_trigger_db_sync()) + with force_routing(cloud=True): + result = run_with_cleanup(_trigger_db_sync()) console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]") except Exception as e: console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]") @@ -697,9 +737,7 @@ def bisync_project_command( bm project bisync --name research --dry-run # Preview changes """ config = ConfigManager().config - if not config.cloud_mode_enabled: - console.print("[red]Error: bisync only available in cloud mode[/red]") - raise typer.Exit(1) + _require_cloud_credentials(config) try: # Get tenant info for bucket name @@ -716,7 +754,8 @@ def bisync_project_command( return proj return None - project_data = run_with_cleanup(_get_project()) + with force_routing(cloud=True): + project_data = run_with_cleanup(_get_project()) if not project_data: console.print(f"[red]Error: Project '{name}' not found[/red]") raise typer.Exit(1) @@ -766,7 +805,8 @@ def bisync_project_command( return response.json() try: - result = run_with_cleanup(_trigger_db_sync()) + with force_routing(cloud=True): + result = run_with_cleanup(_trigger_db_sync()) console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]") except Exception as e: console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]") @@ -793,9 +833,7 @@ def check_project_command( bm project check --name research """ config = ConfigManager().config - if not config.cloud_mode_enabled: - console.print("[red]Error: check only available in cloud mode[/red]") - raise typer.Exit(1) + _require_cloud_credentials(config) try: # Get tenant info for bucket name @@ -812,7 +850,8 @@ def check_project_command( return proj return None - project_data = run_with_cleanup(_get_project()) + with force_routing(cloud=True): + project_data = run_with_cleanup(_get_project()) if not project_data: console.print(f"[red]Error: Project '{name}' not found[/red]") raise typer.Exit(1) @@ -885,23 +924,52 @@ def bisync_reset( def ls_project_command( name: str = typer.Option(..., "--name", help="Project name to list files from"), path: str = typer.Argument(None, help="Path within project (optional)"), + local: bool = typer.Option(False, "--local", help="List files from local project instance"), + cloud: bool = typer.Option(False, "--cloud", help="List files from cloud project instance"), ) -> None: - """List files in remote project. + """List files in a project. Examples: bm project ls --name research + bm project ls --name research --local + bm project ls --name research --cloud bm project ls --name research subfolder """ - config = ConfigManager().config - if not config.cloud_mode_enabled: - console.print("[red]Error: ls only available in cloud mode[/red]") + try: + validate_routing_flags(local, cloud) + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") raise typer.Exit(1) - try: - # Get tenant info for bucket name - tenant_info = run_with_cleanup(get_mount_info()) - bucket_name = tenant_info.bucket_name + use_cloud_route = cloud and not local + def _list_local_files(project_path: str, subpath: str | None = None) -> list[str]: + project_root = Path(normalize_project_path(project_path)).expanduser().resolve() + target_dir = project_root + + if subpath: + requested = Path(subpath) + if requested.is_absolute(): + raise ValueError("Path must be relative to the project root") + target_dir = (project_root / requested).resolve() + if not target_dir.is_relative_to(project_root): + raise ValueError("Path must stay within the project root") + + if not target_dir.exists(): + raise ValueError(f"Path not found: {target_dir}") + if not target_dir.is_dir(): + raise ValueError(f"Path is not a directory: {target_dir}") + + files: list[str] = [] + for file_path in sorted(target_dir.rglob("*")): + if file_path.is_file(): + size = file_path.stat().st_size + relative = file_path.relative_to(project_root).as_posix() + files.append(f"{size:10d} {relative}") + + return files + + try: # Get project info async def _get_project(): async with get_client() as client: @@ -912,29 +980,46 @@ def ls_project_command( return proj return None - project_data = run_with_cleanup(_get_project()) - if not project_data: - console.print(f"[red]Error: Project '{name}' not found[/red]") - raise typer.Exit(1) + if use_cloud_route: + config = ConfigManager().config + _require_cloud_credentials(config) - # Create SyncProject (local_sync_path not needed for ls) - sync_project = SyncProject( - name=project_data.name, - path=normalize_project_path(project_data.path), - ) + tenant_info = run_with_cleanup(get_mount_info()) + bucket_name = tenant_info.bucket_name - # List files - files = project_ls(sync_project, bucket_name, path=path) + with force_routing(cloud=True): + project_data = run_with_cleanup(_get_project()) + if not project_data: + console.print(f"[red]Error: Project '{name}' not found[/red]") + raise typer.Exit(1) + + sync_project = SyncProject( + name=project_data.name, + path=normalize_project_path(project_data.path), + ) + files = project_ls(sync_project, bucket_name, path=path) + target_label = "CLOUD" + else: + with force_routing(local=True): + project_data = run_with_cleanup(_get_project()) + if not project_data: + console.print(f"[red]Error: Project '{name}' not found[/red]") + raise typer.Exit(1) + files = _list_local_files(project_data.path, path) + target_label = "LOCAL" if files: - console.print(f"\n[bold]Files in {name}" + (f"/{path}" if path else "") + ":[/bold]") + heading = f"\n[bold]Files in {name} ({target_label})" + if path: + heading += f"/{path}" + heading += ":[/bold]" + console.print(heading) for file in files: console.print(f" {file}") console.print(f"\n[dim]Total: {len(files)} files[/dim]") else: - console.print( - f"[yellow]No files found in {name}" + (f"/{path}" if path else "") + "[/yellow]" - ) + prefix = f"[yellow]No files found in {name} ({target_label})" + console.print(prefix + (f"/{path}" if path else "") + "[/yellow]") except Exception as e: console.print(f"[red]Error: {e}[/red]") diff --git a/src/basic_memory/cli/commands/routing.py b/src/basic_memory/cli/commands/routing.py index b3a89056..1224b92f 100644 --- a/src/basic_memory/cli/commands/routing.py +++ b/src/basic_memory/cli/commands/routing.py @@ -1,14 +1,11 @@ """CLI routing utilities for --local/--cloud flag handling. -This module provides utilities for CLI commands to override the default routing -behavior (determined by cloud_mode_enabled in config). This allows users to: - -1. Use local MCP server even when cloud mode is enabled -2. Force local routing for specific CLI commands with --local flag -3. Force cloud routing with --cloud flag (requires authentication) +This module provides utilities for CLI commands to override default routing. +This allows users to force local or cloud routing per-command. The routing is controlled via environment variables: - BASIC_MEMORY_FORCE_LOCAL: When "true", forces local ASGI transport +- BASIC_MEMORY_FORCE_CLOUD: When "true", forces cloud proxy transport - BASIC_MEMORY_EXPLICIT_ROUTING: When "true", signals that --local/--cloud was explicitly passed, overriding per-project routing in get_client() - These are checked in basic_memory.mcp.async_client.get_client() @@ -32,8 +29,8 @@ def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, N (without EXPLICIT_ROUTING), so per-project routing still works for MCP tools. Args: - local: If True, force local ASGI transport (ignores cloud_mode_enabled) - cloud: If True, clear force_local to allow cloud routing + local: If True, force local ASGI transport + cloud: If True, force cloud proxy transport Usage: with force_routing(local=True): @@ -48,15 +45,17 @@ def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, N # Save original values original_force_local = os.environ.get("BASIC_MEMORY_FORCE_LOCAL") + original_force_cloud = os.environ.get("BASIC_MEMORY_FORCE_CLOUD") original_explicit = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING") try: if local: os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true" + os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None) os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true" elif cloud: - # Ensure force_local is NOT set, let cloud_mode_enabled take effect os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None) + os.environ["BASIC_MEMORY_FORCE_CLOUD"] = "true" os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "true" # If neither is set, don't change anything (use default behavior) yield @@ -67,6 +66,11 @@ def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, N else: os.environ["BASIC_MEMORY_FORCE_LOCAL"] = original_force_local + if original_force_cloud is None: + os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None) + else: + os.environ["BASIC_MEMORY_FORCE_CLOUD"] = original_force_cloud + if original_explicit is None: os.environ.pop("BASIC_MEMORY_EXPLICIT_ROUTING", None) else: diff --git a/src/basic_memory/cli/container.py b/src/basic_memory/cli/container.py index e375923c..33c7e4cb 100644 --- a/src/basic_memory/cli/container.py +++ b/src/basic_memory/cli/container.py @@ -35,7 +35,6 @@ class CliContainer: """ config = ConfigManager().config mode = resolve_runtime_mode( - cloud_mode_enabled=config.cloud_mode_enabled, is_test_env=config.is_test_env, ) return cls(config=config, mode=mode) diff --git a/src/basic_memory/cli/promo.py b/src/basic_memory/cli/promo.py index 9807f49c..db52d941 100644 --- a/src/basic_memory/cli/promo.py +++ b/src/basic_memory/cli/promo.py @@ -74,6 +74,10 @@ def maybe_show_cloud_promo( """Show cloud promo copy when discovery gates are satisfied.""" manager = config_manager or ConfigManager() config = manager.load_config() + from basic_memory.cli.auth import CLIAuth + + auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain) + has_cloud_access = bool(config.cloud_api_key) or auth.load_tokens() is not None interactive = _is_interactive_session() if is_interactive is None else is_interactive @@ -89,7 +93,7 @@ def maybe_show_cloud_promo( if invoked_subcommand in {None, "mcp"}: return - if config.cloud_mode_enabled or config.cloud_promo_opt_out: + if has_cloud_access or config.cloud_promo_opt_out: return show_first_run = not config.cloud_promo_first_run_shown diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 4a9f1505..97ac5237 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -287,11 +287,6 @@ class BasicMemoryConfig(BaseSettings): description="Basic Memory Cloud host URL", ) - cloud_mode: bool = Field( - default=False, - description="Enable cloud mode - all requests go to cloud instead of local (config file value)", - ) - cloud_promo_opt_out: bool = Field( default=False, description="Disable CLI cloud promo messages when true.", @@ -333,6 +328,7 @@ class BasicMemoryConfig(BaseSettings): # --- Remove stale keys from old config versions --- data.pop("default_project_mode", None) + data.pop("cloud_mode", None) projects = data.get("projects", {}) if not projects: @@ -401,22 +397,6 @@ class BasicMemoryConfig(BaseSettings): or os.getenv("PYTEST_CURRENT_TEST") is not None ) - @property - def cloud_mode_enabled(self) -> bool: - """Check if cloud mode is enabled. - - Priority: - 1. BASIC_MEMORY_CLOUD_MODE environment variable - 2. Config file value (cloud_mode) - """ - env_value = os.environ.get("BASIC_MEMORY_CLOUD_MODE", "").lower() - if env_value in ("true", "1", "yes"): - return True - elif env_value in ("false", "0", "no"): - return False - # 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. @@ -463,7 +443,6 @@ class BasicMemoryConfig(BaseSettings): database_backend=DatabaseBackend.POSTGRES, database_url=database_url, projects=projects or {}, - cloud_mode=True, skip_initialization_sync=True, ) @@ -617,7 +596,12 @@ class ConfigManager: file_data = json.loads(self.config_file.read_text(encoding="utf-8")) # Detect legacy format before model validators strip stale keys - _STALE_KEYS = {"default_project_mode", "project_modes", "cloud_projects"} + _STALE_KEYS = { + "default_project_mode", + "project_modes", + "cloud_projects", + "cloud_mode", + } needs_resave = bool(_STALE_KEYS & file_data.keys()) # Check if projects dict uses old string-value format diff --git a/src/basic_memory/mcp/async_client.py b/src/basic_memory/mcp/async_client.py index 6f791a1c..a5040e86 100644 --- a/src/basic_memory/mcp/async_client.py +++ b/src/basic_memory/mcp/async_client.py @@ -1,5 +1,5 @@ import os -from contextlib import asynccontextmanager, AbstractAsyncContextManager +from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import AsyncIterator, Callable, Optional from httpx import ASGITransport, AsyncClient, Timeout @@ -10,49 +10,75 @@ from basic_memory.config import ConfigManager, ProjectMode def _force_local_mode() -> bool: - """Check if local mode is forced via environment variable. - - This allows commands like `bm mcp` to force local routing even when - cloud_mode_enabled is True in config. The local MCP server should - always talk to the local API, not the cloud proxy. - - Returns: - True if BASIC_MEMORY_FORCE_LOCAL is set to a truthy value - """ + """Check if local mode is forced via environment variable.""" return os.environ.get("BASIC_MEMORY_FORCE_LOCAL", "").lower() in ("true", "1", "yes") +def _force_cloud_mode() -> bool: + """Check if cloud mode is forced via environment variable.""" + return os.environ.get("BASIC_MEMORY_FORCE_CLOUD", "").lower() in ("true", "1", "yes") + + def _explicit_routing() -> bool: - """Check if CLI --local/--cloud flag was explicitly passed. - - Set by force_routing() in CLI commands. When active, --local/--cloud - flags override per-project routing. The MCP server sets FORCE_LOCAL - directly (without this flag), so per-project routing still works there. - - Returns: - True if BASIC_MEMORY_EXPLICIT_ROUTING is set to a truthy value - """ + """Check if CLI --local/--cloud flag was explicitly passed.""" return os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING", "").lower() in ("true", "1", "yes") +def _build_timeout() -> Timeout: + """Create a standard timeout config used across all clients.""" + return Timeout( + connect=10.0, + read=30.0, + write=30.0, + pool=30.0, + ) + + +def _asgi_client(timeout: Timeout) -> AsyncClient: + """Create a local ASGI client.""" + return AsyncClient( + transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout + ) + + +async def _resolve_cloud_token(config) -> str: + """Resolve cloud token with API key preferred, OAuth fallback.""" + token = config.cloud_api_key + if token: + return 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 token: + return token + + raise RuntimeError( + "Cloud routing requested but no credentials found. " + "Run 'bm cloud set-key ' or 'bm cloud login' first." + ) + + +async def _cloud_client(config, timeout: Timeout) -> AsyncIterator[AsyncClient]: + """Create a cloud proxy client with resolved credentials.""" + token = await _resolve_cloud_token(config) + proxy_base_url = f"{config.cloud_host}/proxy" + logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}") + async with AsyncClient( + base_url=proxy_base_url, + headers={"Authorization": f"Bearer {token}"}, + timeout=timeout, + ) as client: + yield client + + # Optional factory override for dependency injection _client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None def set_client_factory(factory: Callable[[], AbstractAsyncContextManager[AsyncClient]]) -> None: - """Override the default client factory (for cloud app, testing, etc). - - Args: - factory: An async context manager that yields an AsyncClient - - Example: - @asynccontextmanager - async def custom_client_factory(): - async with AsyncClient(...) as client: - yield client - - set_client_factory(custom_client_factory) - """ + """Override the default client factory (for cloud app, testing, etc).""" global _client_factory _client_factory = factory @@ -63,204 +89,78 @@ async def get_client( ) -> AsyncIterator[AsyncClient]: """Get an AsyncClient as a context manager. - This function provides proper resource management for HTTP clients, - 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 explicit override** (BASIC_MEMORY_EXPLICIT_ROUTING env var): - When --local or --cloud is explicitly passed via CLI, skip per-project - routing and fall through to force-local / global cloud mode handling. - This allows users to override per-project mode for commands like - `bm status --project specs --local` (check local copy of a cloud project). - - 3. **Per-project cloud mode** (project_name provided, no explicit override): - If the project's mode is CLOUD, routes to cloud using API key or - OAuth token. Honored even when FORCE_LOCAL is set (e.g. MCP server), - because the user explicitly declared this project as cloud. - - 4. **Per-project local mode** (project_name provided, no explicit override): - 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. - - 5. **Force-local** (BASIC_MEMORY_FORCE_LOCAL env var): - Routes to local ASGI transport, ignoring global cloud settings. - - 6. **Global cloud mode**: - When cloud_mode_enabled is True, uses OAuth JWT token or API key. - - 7. **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 routing needed but no API key / not authenticated + Routing priority: + 1. Factory injection. + 2. Explicit routing flags (--local/--cloud). + 3. Per-project mode routing when project_name is provided. + 4. Local ASGI transport by default. """ if _client_factory: - # Use injected factory (cloud app, tests) async with _client_factory() as client: yield client - else: - # Default: create based on config - config = ConfigManager().config - timeout = Timeout( - connect=10.0, # 10 seconds for connection - read=30.0, # 30 seconds for reading response - write=30.0, # 30 seconds for writing request - pool=30.0, # 30 seconds for connection pool - ) + return - # --- Per-project routing (when project_name given and no CLI override) --- - # Trigger: CLI --local/--cloud flag was NOT explicitly passed - # Why: per-project routing is an explicit user declaration that should be - # honored even from the MCP server (which sets FORCE_LOCAL) - # Outcome: route based on project's configured mode (CLOUD or LOCAL) - if project_name is not None and not _explicit_routing(): - project_mode = config.get_project_mode(project_name) + config = ConfigManager().config + timeout = _build_timeout() - if project_mode == 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 ' 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 - return - - # Trigger: project is LOCAL (the default, no CLI override) - # 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 - else: - 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 - return - - # --- Fallback routing (no per-project routing applies) --- - - # 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 + # --- Explicit routing override --- + # Trigger: user passed --local/--cloud. + # Why: command-level override should be deterministic and bypass project mode. + # Outcome: route strictly based on explicit flag. + if _explicit_routing(): if _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: + logger.info("Explicit local routing enabled - using ASGI client") + async with _asgi_client(timeout) as client: yield client + return - elif config.cloud_mode_enabled: - # Global cloud mode (deprecated fallback): inject OAuth auth when creating client - from basic_memory.cli.auth import CLIAuth + if _force_cloud_mode(): + logger.info("Explicit cloud routing enabled - using cloud proxy client") + async for client in _cloud_client(config, timeout): + yield client + return - auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain) - token = await auth.get_valid_token() - - if not token: + # --- Per-project routing --- + # Trigger: project_name provided without explicit routing override. + # Why: project mode is the source of truth for project-scoped commands. + # Outcome: route via project.mode (CLOUD/LOCAL). + if project_name is not None and not _explicit_routing(): + project_mode = config.get_project_mode(project_name) + if project_mode == ProjectMode.CLOUD: + logger.info(f"Project '{project_name}' is cloud mode - using cloud proxy client") + try: + async for client in _cloud_client(config, timeout): + yield client + except RuntimeError as exc: raise RuntimeError( - "Cloud mode enabled but not authenticated. " - "Run 'basic-memory cloud login' first." - ) + f"Project '{project_name}' is set to cloud mode but no credentials found. " + "Run 'bm cloud set-key ' or 'bm cloud login' first." + ) from exc + return - # Auth header set ONCE at client creation - proxy_base_url = f"{config.cloud_host}/proxy" - logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}") - async with AsyncClient( - base_url=proxy_base_url, - headers={"Authorization": f"Bearer {token}"}, - timeout=timeout, - ) as client: - yield client - else: - # Local mode: ASGI transport for in-process calls - # Note: ASGI transport does NOT trigger FastAPI lifespan, so no special handling needed - logger.info("Creating 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 + logger.info(f"Project '{project_name}' is local mode - using ASGI client") + async with _asgi_client(timeout) as client: + yield client + return + + # --- Default fallback --- + logger.info("Default routing - using ASGI client for local Basic Memory API") + async with _asgi_client(timeout) as client: + yield client def create_client() -> AsyncClient: - """Create an HTTP client based on configuration. + """Create an HTTP client based on explicit routing flags. DEPRECATED: Use get_client() context manager instead for proper resource management. - - This function is kept for backward compatibility but will be removed in a future version. - The returned client should be closed manually by calling await client.aclose(). - - Returns: - AsyncClient configured for either local ASGI or remote proxy """ - config_manager = ConfigManager() - config = config_manager.config + timeout = _build_timeout() - # Configure timeout for longer operations like write_note - # Default httpx timeout is 5 seconds which is too short for file operations - timeout = Timeout( - connect=10.0, # 10 seconds for connection - read=30.0, # 30 seconds for reading response - write=30.0, # 30 seconds for writing request - pool=30.0, # 30 seconds for connection pool - ) - - # Check force local first (for local MCP server and CLI --local flag) - if _force_local_mode(): - logger.info("Force local mode enabled - using ASGI client for local Basic Memory API") - return AsyncClient( - transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout - ) - elif config.cloud_mode_enabled: - # Use HTTP transport to proxy endpoint - proxy_base_url = f"{config.cloud_host}/proxy" - logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}") - return AsyncClient(base_url=proxy_base_url, timeout=timeout) - else: - # Default: use ASGI transport for local API (development mode) + if _force_local_mode() or not _force_cloud_mode(): logger.info("Creating ASGI client for local Basic Memory API") - return AsyncClient( - transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout - ) + return _asgi_client(timeout) + + logger.info("Creating HTTP client for cloud proxy (legacy create_client path)") + config = ConfigManager().config + proxy_base_url = f"{config.cloud_host}/proxy" + return AsyncClient(base_url=proxy_base_url, timeout=timeout) diff --git a/src/basic_memory/mcp/container.py b/src/basic_memory/mcp/container.py index 44d78000..603d8a4a 100644 --- a/src/basic_memory/mcp/container.py +++ b/src/basic_memory/mcp/container.py @@ -39,7 +39,6 @@ class McpContainer: """ config = ConfigManager().config mode = resolve_runtime_mode( - cloud_mode_enabled=config.cloud_mode_enabled, is_test_env=config.is_test_env, ) return cls(config=config, mode=mode) diff --git a/src/basic_memory/mcp/project_context.py b/src/basic_memory/mcp/project_context.py index 90bddbe5..6ec181c5 100644 --- a/src/basic_memory/mcp/project_context.py +++ b/src/basic_memory/mcp/project_context.py @@ -30,7 +30,6 @@ from basic_memory.utils import generate_permalink, normalize_project_reference async def resolve_project_parameter( project: Optional[str] = None, allow_discovery: bool = False, - cloud_mode: Optional[bool] = None, default_project: Optional[str] = None, ) -> Optional[str]: """Resolve project parameter using unified linear priority chain. @@ -39,33 +38,29 @@ async def resolve_project_parameter( New code should consider using ProjectResolver directly for more detailed resolution information. - Resolution order (same for local and cloud modes): + Resolution order: 1. ENV_CONSTRAINT: BASIC_MEMORY_MCP_PROJECT env var (highest priority) 2. EXPLICIT: project parameter passed directly 3. DEFAULT: default_project from config (if set) - 4. Fallback: cloud → CLOUD_DISCOVERY or ValueError; local → NONE + 4. Fallback: discovery (if allowed) → NONE Args: project: Optional explicit project parameter - allow_discovery: If True, allows returning None in cloud mode for discovery mode + allow_discovery: If True, allows returning None for discovery mode (used by tools like recent_activity that can operate across all projects) - cloud_mode: Optional explicit cloud mode. If not provided, reads from ConfigManager. default_project: Optional explicit default project. If not provided, reads from ConfigManager. Returns: Resolved project name or None if no resolution possible """ # Load config for any values not explicitly provided - if cloud_mode is None or default_project is None: + if default_project is None: config = ConfigManager().config - if cloud_mode is None: - cloud_mode = config.cloud_mode if default_project is None: default_project = config.default_project # Create resolver with configuration and resolve resolver = ProjectResolver.from_env( - cloud_mode=cloud_mode, default_project=default_project, ) result = resolver.resolve(project=project, allow_discovery=allow_discovery) diff --git a/src/basic_memory/mcp/server.py b/src/basic_memory/mcp/server.py index 41e72a1f..c8a1f7be 100644 --- a/src/basic_memory/mcp/server.py +++ b/src/basic_memory/mcp/server.py @@ -48,23 +48,19 @@ async def lifespan(app: FastMCP): default = " (default)" if name == config.default_project else "" logger.info(f"Project: {name} -> {entry.path} [mode={entry.mode.value}]{default}") - # Check cloud login status (local file check, no network call) - if config.cloud_mode: - auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain) - tokens = auth.load_tokens() - if tokens is None: - logger.warning("Cloud mode enabled but not authenticated - run 'bm cloud login'") - elif not auth.is_token_valid(tokens): + # Check cloud auth status (local file check, no network call) + auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain) + tokens = auth.load_tokens() + if tokens is not None: + if not auth.is_token_valid(tokens): expires_at = tokens.get("expires_at", 0) expired_ago = int(time.time() - expires_at) logger.warning(f"Cloud token expired {expired_ago}s ago - may need 'bm cloud login'") else: - logger.info("Cloud: authenticated (token valid)") + logger.info("Cloud: authenticated (OAuth token valid)") - if config.cloud_api_key: - logger.info("Cloud: API key configured (preferred for per-project routing)") - else: - logger.info("Cloud: no API key set (will use OAuth token for cloud projects)") + if config.cloud_api_key: + logger.info("Cloud: API key configured") # Track if we created the engine (vs test fixtures providing it) # This prevents disposing an engine provided by test fixtures when diff --git a/src/basic_memory/project_resolver.py b/src/basic_memory/project_resolver.py index ac6a5642..4374c59a 100644 --- a/src/basic_memory/project_resolver.py +++ b/src/basic_memory/project_resolver.py @@ -1,16 +1,4 @@ -"""Unified project resolution across MCP, API, and CLI. - -This module provides a single canonical implementation of project resolution -logic, eliminating duplicated decision trees across the codebase. - -The resolution follows a unified linear priority chain that works -identically in both local and cloud modes: - -1. ENV_CONSTRAINT: BASIC_MEMORY_MCP_PROJECT env var (highest priority) -2. EXPLICIT: Project passed directly to operation -3. DEFAULT: default_project from config (if set) -4. Fallback: cloud → CLOUD_DISCOVERY or ValueError; local → NONE -""" +"""Unified project resolution across MCP, API, and CLI.""" import os from dataclasses import dataclass @@ -23,23 +11,16 @@ from loguru import logger class ResolutionMode(Enum): """How the project was resolved.""" - CLOUD_EXPLICIT = auto() # Explicit project in cloud mode - CLOUD_DISCOVERY = auto() # Discovery mode allowed in cloud (no project) ENV_CONSTRAINT = auto() # BASIC_MEMORY_MCP_PROJECT env var EXPLICIT = auto() # Explicit project parameter DEFAULT = auto() # default_project from config + DISCOVERY = auto() # Discovery mode allowed (no project) NONE = auto() # No resolution possible @dataclass(frozen=True) class ResolvedProject: - """Result of project resolution. - - Attributes: - project: The resolved project name, or None if not resolved - mode: How the project was resolved - reason: Human-readable explanation of resolution - """ + """Result of project resolution.""" project: Optional[str] mode: ResolutionMode @@ -47,58 +28,29 @@ class ResolvedProject: @property def is_resolved(self) -> bool: - """Whether a project was successfully resolved.""" return self.project is not None @property def is_discovery_mode(self) -> bool: - """Whether we're in discovery mode (no specific project).""" - return self.mode == ResolutionMode.CLOUD_DISCOVERY or ( - self.mode == ResolutionMode.NONE and self.project is None - ) + return self.mode in {ResolutionMode.DISCOVERY, ResolutionMode.NONE} and self.project is None @dataclass class ProjectResolver: - """Unified project resolution logic. + """Unified project resolution logic.""" - Resolves the effective project given requested project, environment - constraints, and configuration settings. - - This is the single canonical implementation of project resolution, - used by MCP tools, API routes, and CLI commands. - - Args: - cloud_mode: Whether running in cloud mode - default_project: The default project name (used as fallback when set) - constrained_project: Optional env-constrained project override - (typically from BASIC_MEMORY_MCP_PROJECT) - """ - - cloud_mode: bool = False default_project: Optional[str] = None constrained_project: Optional[str] = None @classmethod def from_env( cls, - cloud_mode: bool = False, default_project: Optional[str] = None, ) -> "ProjectResolver": - """Create resolver with constrained_project from environment. - - Args: - cloud_mode: Whether running in cloud mode - default_project: The default project name - - Returns: - ProjectResolver configured with current environment - """ - constrained = os.environ.get("BASIC_MEMORY_MCP_PROJECT") + """Create resolver with constrained_project from environment.""" return cls( - cloud_mode=cloud_mode, default_project=default_project, - constrained_project=constrained, + constrained_project=os.environ.get("BASIC_MEMORY_MCP_PROJECT"), ) def resolve( @@ -106,46 +58,23 @@ class ProjectResolver: project: Optional[str] = None, allow_discovery: bool = False, ) -> ResolvedProject: - """Resolve project using a unified linear priority chain. - - The same resolution order applies in both local and cloud modes: - 1. ENV_CONSTRAINT — BASIC_MEMORY_MCP_PROJECT env var (highest priority) - 2. EXPLICIT — project parameter passed directly - 3. DEFAULT — default_project from config (if set) - 4. Fallback — cloud: CLOUD_DISCOVERY or ValueError; local: NONE - - Args: - project: Optional explicit project parameter - allow_discovery: If True, allows returning None in cloud mode - for discovery operations (e.g., recent_activity across projects) - - Returns: - ResolvedProject with project name, resolution mode, and reason - - Raises: - ValueError: If in cloud mode and no project could be resolved - (unless allow_discovery=True) - """ - # --- Priority 1: ENV constraint overrides everything --- + """Resolve project using a unified linear priority chain.""" if self.constrained_project: - logger.debug(f"Using CLI constrained project: {self.constrained_project}") + logger.debug(f"Using constrained project from env: {self.constrained_project}") return ResolvedProject( project=self.constrained_project, mode=ResolutionMode.ENV_CONSTRAINT, reason=f"Environment constraint: BASIC_MEMORY_MCP_PROJECT={self.constrained_project}", ) - # --- Priority 2: Explicit project parameter --- if project: - mode = ResolutionMode.CLOUD_EXPLICIT if self.cloud_mode else ResolutionMode.EXPLICIT logger.debug(f"Using explicit project parameter: {project}") return ResolvedProject( project=project, - mode=mode, + mode=ResolutionMode.EXPLICIT, reason=f"Explicit parameter: {project}", ) - # --- Priority 3: Default project from config --- if self.default_project: logger.debug(f"Using default project from config: {self.default_project}") return ResolvedProject( @@ -154,18 +83,14 @@ class ProjectResolver: reason=f"Default project: {self.default_project}", ) - # --- Fallback: mode-dependent behavior --- - if self.cloud_mode: - if allow_discovery: - logger.debug("Cloud mode: discovery mode allowed, no project required") - return ResolvedProject( - project=None, - mode=ResolutionMode.CLOUD_DISCOVERY, - reason="Discovery mode enabled in cloud", - ) - raise ValueError("No project specified. Project is required.") + if allow_discovery: + logger.debug("No project resolved, using discovery mode") + return ResolvedProject( + project=None, + mode=ResolutionMode.DISCOVERY, + reason="Discovery mode enabled", + ) - # Local mode: no resolution possible logger.debug("No project resolution possible") return ResolvedProject( project=None, @@ -178,20 +103,7 @@ class ProjectResolver: project: Optional[str] = None, error_message: Optional[str] = None, ) -> ResolvedProject: - """Resolve project, raising an error if not resolved. - - Convenience method for operations that require a project. - - Args: - project: Optional explicit project parameter - error_message: Custom error message if project not resolved - - Returns: - ResolvedProject (always with a non-None project) - - Raises: - ValueError: If project could not be resolved - """ + """Resolve project, raising an error if not resolved.""" result = self.resolve(project, allow_discovery=False) if not result.is_resolved: msg = error_message or ( diff --git a/src/basic_memory/runtime.py b/src/basic_memory/runtime.py index a9d6fef9..4868e98e 100644 --- a/src/basic_memory/runtime.py +++ b/src/basic_memory/runtime.py @@ -31,7 +31,6 @@ class RuntimeMode(Enum): def resolve_runtime_mode( - cloud_mode_enabled: bool, is_test_env: bool, ) -> RuntimeMode: """Resolve the runtime mode from configuration flags. @@ -40,7 +39,6 @@ def resolve_runtime_mode( Composition roots call this with config values they've read. Args: - cloud_mode_enabled: Whether cloud mode is enabled in config is_test_env: Whether running in test environment Returns: @@ -52,10 +50,4 @@ def resolve_runtime_mode( if is_test_env: return RuntimeMode.TEST - # Trigger: cloud mode is enabled in config - # Why: cloud mode changes auth, sync, and API behavior - # Outcome: returns CLOUD mode for remote-first behavior - if cloud_mode_enabled: - return RuntimeMode.CLOUD - return RuntimeMode.LOCAL diff --git a/src/basic_memory/services/initialization.py b/src/basic_memory/services/initialization.py index e911876f..059eaa6f 100644 --- a/src/basic_memory/services/initialization.py +++ b/src/basic_memory/services/initialization.py @@ -177,7 +177,7 @@ async def initialize_app( # Trigger: database backend is Postgres (cloud deployment) # Why: cloud deployments manage their own projects and migrations via the cloud platform. # The local MCP server always uses SQLite and needs initialization even when - # cloud_mode is enabled (for per-project cloud routing). + # projects are configured for cloud routing. # Outcome: skip initialization only for actual cloud Postgres deployments. if app_config.database_backend == DatabaseBackend.POSTGRES: logger.info("Skipping local initialization - Postgres backend manages its own schema") diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index c9927703..23ed3711 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -21,6 +21,7 @@ from basic_memory.schemas import ( SystemStatus, ) from basic_memory.config import ( + DatabaseBackend, WATCH_STATUS_JSON, ConfigManager, ProjectEntry, @@ -204,7 +205,7 @@ class ProjectService: f"Projects cannot share directory trees." ) - if not self.config_manager.config.cloud_mode: + if self.config_manager.config.database_backend != DatabaseBackend.POSTGRES: # First add to config file (this will validate the project doesn't exist) self.config_manager.add_project(name, resolved_path) @@ -251,7 +252,7 @@ class ProjectService: # In cloud mode: database is source of truth # In local mode: also check config file is_default = project.is_default - if not self.config_manager.config.cloud_mode: + if self.config_manager.config.database_backend != DatabaseBackend.POSTGRES: is_default = is_default or name == self.config_manager.config.default_project if is_default: raise ValueError(f"Cannot remove the default project '{name}'") # pragma: no cover @@ -307,7 +308,7 @@ class ProjectService: await self.repository.set_as_default(project.id) # Update config file only in local mode (cloud mode uses database only) - if not self.config_manager.config.cloud_mode: + if self.config_manager.config.database_backend != DatabaseBackend.POSTGRES: self.config_manager.set_default_project(name) logger.info(f"Project '{name}' set as default in configuration and database") diff --git a/src/basic_memory/utils.py b/src/basic_memory/utils.py index c6260017..54de742e 100644 --- a/src/basic_memory/utils.py +++ b/src/basic_memory/utils.py @@ -488,17 +488,18 @@ def ensure_timezone_aware(dt: datetime, cloud_mode: bool | None = None) -> datet Args: dt: The datetime to ensure is timezone-aware - cloud_mode: Optional explicit cloud_mode setting. If None, loads from config. + cloud_mode: Optional explicit cloud_mode setting. If None, inferred from + configured database backend (Postgres => UTC semantics). Returns: A timezone-aware datetime """ if dt.tzinfo is None: - # Determine cloud_mode: use explicit parameter if provided, otherwise load from config + # Determine cloud_mode: use explicit parameter if provided, otherwise infer from config. if cloud_mode is None: - from basic_memory.config import ConfigManager + from basic_memory.config import ConfigManager, DatabaseBackend - cloud_mode = ConfigManager().config.cloud_mode_enabled + cloud_mode = ConfigManager().config.database_backend == DatabaseBackend.POSTGRES if cloud_mode: # Cloud/PostgreSQL mode: naive datetimes from asyncpg are already UTC diff --git a/test-int/cli/test_routing_integration.py b/test-int/cli/test_routing_integration.py index 678b509f..e949667b 100644 --- a/test-int/cli/test_routing_integration.py +++ b/test-int/cli/test_routing_integration.py @@ -40,6 +40,15 @@ class TestRoutingFlagsValidation: assert result.exit_code != 0 assert "Cannot specify both --local and --cloud" in result.output + def test_project_ls_both_flags_error(self): + """Using both --local and --cloud on project ls should produce an error.""" + result = runner.invoke( + cli_app, + ["project", "ls", "--name", "test", "--local", "--cloud"], + ) + assert result.exit_code != 0 + assert "Cannot specify both --local and --cloud" in result.output + def test_tool_search_both_flags_error(self): """Using both --local and --cloud should produce an error.""" result = runner.invoke(cli_app, ["tool", "search-notes", "test", "--local", "--cloud"]) @@ -187,6 +196,16 @@ class TestProjectCommandsAcceptFlags: result = runner.invoke(cli_app, ["project", "move", "test", "/tmp/dest", "--local"]) assert "No such option: --local" not in result.output + def test_project_ls_accepts_local_flag(self, app_config): + """project ls should accept --local flag.""" + result = runner.invoke(cli_app, ["project", "ls", "--name", "test", "--local"]) + assert "No such option: --local" not in result.output + + def test_project_ls_accepts_cloud_flag(self, app_config): + """project ls should accept --cloud flag.""" + result = runner.invoke(cli_app, ["project", "ls", "--name", "test", "--cloud"]) + assert "No such option: --cloud" not in result.output + class TestStatusCommandAcceptsFlags: """Tests that status command accepts routing flags.""" diff --git a/test-int/conftest.py b/test-int/conftest.py index c92ceefc..8a98c94e 100644 --- a/test-int/conftest.py +++ b/test-int/conftest.py @@ -245,7 +245,6 @@ def app_config( projects=projects, default_project="test-project", update_permalinks_on_move=True, - cloud_mode=False, # Explicitly disable cloud mode sync_changes=False, # Disable file sync in tests - prevents lifespan from starting blocking task database_backend=database_backend, database_url=database_url, diff --git a/tests/cli/test_cloud_authentication.py b/tests/cli/test_cloud_authentication.py index ca5b9915..0fe73205 100644 --- a/tests/cli/test_cloud_authentication.py +++ b/tests/cli/test_cloud_authentication.py @@ -174,37 +174,10 @@ class TestLoginCommand: fake_make_api_request, ) - instances: list[object] = [] - - class _StubConfig: - cloud_mode = False - - class _StubConfigManager: - def __init__(self): - self._config = _StubConfig() - self.config = self._config - self.saved_config = None - instances.append(self) - - def load_config(self): - return self._config - - def save_config(self, config): - self.saved_config = config - - monkeypatch.setattr( - "basic_memory.cli.commands.cloud.core_commands.ConfigManager", - _StubConfigManager, - ) - result = runner.invoke(app, ["cloud", "login"]) assert result.exit_code == 0 - assert "Cloud mode enabled" in result.stdout - - assert len(instances) == 1 - mgr = instances[0] - assert mgr.saved_config is not None - assert mgr.saved_config.cloud_mode is True + assert "Cloud authentication successful" in result.stdout + assert "Cloud host ready: https://cloud.example.com" in result.stdout def test_login_authentication_failure(self, monkeypatch): runner = CliRunner() diff --git a/tests/cli/test_cloud_promo.py b/tests/cli/test_cloud_promo.py index dd904dbe..5451dc19 100644 --- a/tests/cli/test_cloud_promo.py +++ b/tests/cli/test_cloud_promo.py @@ -162,10 +162,10 @@ def test_no_message_when_already_shown_for_current_version(): assert buf.getvalue() == "" -def test_no_message_when_cloud_mode_enabled(): +def test_no_message_when_cloud_access_is_configured(): config_manager = ConfigManager() config = config_manager.load_config() - config.cloud_mode = True + config.cloud_api_key = "bmc_test_key_123" config_manager.save_config(config) console, buf = _capture_console() diff --git a/tests/cli/test_project_add_with_local_path.py b/tests/cli/test_project_add_with_local_path.py index 64069ab8..384c943c 100644 --- a/tests/cli/test_project_add_with_local_path.py +++ b/tests/cli/test_project_add_with_local_path.py @@ -17,7 +17,7 @@ def runner(): @pytest.fixture def mock_config(tmp_path, monkeypatch): - """Create a mock config in cloud mode using environment variables.""" + """Create a mock config with cloud credentials using environment variables.""" # Invalidate config cache to ensure clean state for each test from basic_memory import config as config_module @@ -31,7 +31,7 @@ def mock_config(tmp_path, monkeypatch): "env": "dev", "projects": {}, "default_project": "main", - "cloud_mode": True, + "cloud_api_key": "bmc_test_key_123", } config_file.write_text(json.dumps(config_data, indent=2)) @@ -91,6 +91,7 @@ def test_project_add_with_local_path_saves_to_config( "project", "add", "test-project", + "--cloud", "--local-path", str(local_sync_dir), ], @@ -121,7 +122,7 @@ def test_project_add_without_local_path_no_config_entry(runner, mock_config, moc """Test that bm project add without --local-path doesn't save to config.""" result = runner.invoke( app, - ["project", "add", "test-project"], + ["project", "add", "test-project", "--cloud"], ) assert result.exit_code == 0 @@ -140,7 +141,7 @@ def test_project_add_local_path_expands_tilde(runner, mock_config, mock_api_clie """Test that --local-path ~/path expands to absolute path.""" result = runner.invoke( app, - ["project", "add", "test-project", "--local-path", "~/test-sync"], + ["project", "add", "test-project", "--cloud", "--local-path", "~/test-sync"], ) assert result.exit_code == 0 @@ -162,7 +163,7 @@ def test_project_add_local_path_creates_nested_directories( result = runner.invoke( app, - ["project", "add", "test-project", "--local-path", str(nested_path)], + ["project", "add", "test-project", "--cloud", "--local-path", str(nested_path)], ) assert result.exit_code == 0 diff --git a/tests/cli/test_project_list_and_ls.py b/tests/cli/test_project_list_and_ls.py new file mode 100644 index 00000000..692e283b --- /dev/null +++ b/tests/cli/test_project_list_and_ls.py @@ -0,0 +1,245 @@ +"""Tests for project list display and project ls routing behavior.""" + +import json +import os +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from basic_memory.cli.app import app + +# Importing registers project subcommands on the shared app instance. +import basic_memory.cli.commands.project as project_cmd # noqa: F401 + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def write_config(tmp_path, monkeypatch): + """Write config.json under a temporary HOME and return the file path.""" + + def _write(config_data: dict) -> Path: + 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_file.write_text(json.dumps(config_data, indent=2)) + monkeypatch.setenv("HOME", str(tmp_path)) + return config_file + + return _write + + +@pytest.fixture +def mock_client(monkeypatch): + """Mock get_client with a no-op async context manager.""" + + @asynccontextmanager + async def fake_get_client(): + yield object() + + monkeypatch.setattr(project_cmd, "get_client", fake_get_client) + + +def test_project_list_shows_local_cloud_presence_and_routes( + runner: CliRunner, write_config, mock_client, tmp_path, monkeypatch +): + """project list should show local/cloud paths plus CLI and MCP route targets.""" + alpha_local = (tmp_path / "alpha-local").as_posix() + beta_local_sync = (tmp_path / "beta-sync").as_posix() + + write_config( + { + "env": "dev", + "projects": { + "alpha": {"path": alpha_local, "mode": "local"}, + "beta": { + "path": beta_local_sync, + "mode": "cloud", + "cloud_sync_path": beta_local_sync, + }, + }, + "default_project": "alpha", + "cloud_api_key": "bmc_test_key_123", + } + ) + + local_payload = { + "projects": [ + { + "id": 1, + "external_id": "11111111-1111-1111-1111-111111111111", + "name": "alpha", + "path": alpha_local, + "is_default": True, + } + ], + "default_project": "alpha", + } + + cloud_payload = { + "projects": [ + { + "id": 2, + "external_id": "22222222-2222-2222-2222-222222222222", + "name": "alpha", + "path": "/alpha", + "is_default": True, + }, + { + "id": 3, + "external_id": "33333333-3333-3333-3333-333333333333", + "name": "beta", + "path": "/beta", + "is_default": False, + }, + ], + "default_project": "alpha", + } + + class _Resp: + def __init__(self, payload: dict): + self._payload = payload + + def json(self): + return self._payload + + async def fake_call_get(client, path: str, **kwargs): + assert path == "/v2/projects/" + if os.getenv("BASIC_MEMORY_FORCE_CLOUD", "").lower() in ("true", "1", "yes"): + return _Resp(cloud_payload) + return _Resp(local_payload) + + monkeypatch.setattr(project_cmd, "call_get", fake_call_get) + + result = runner.invoke(app, ["project", "list"], env={"COLUMNS": "240"}) + + assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}" + assert "Local Path" in result.stdout + assert "Cloud Path" in result.stdout + assert "CLI Route" in result.stdout + assert "MCP (stdio)" in result.stdout + + lines = result.stdout.splitlines() + alpha_line = next(line for line in lines if "│ alpha" in line) + beta_line = next(line for line in lines if "│ beta" in line) + + assert "local" in alpha_line # CLI route for alpha + assert "cloud" in beta_line # CLI route for beta + assert "n/a" in beta_line # MCP stdio route is unavailable for cloud-only projects + assert "alpha-local" in result.stdout + assert "/alpha" in result.stdout + assert "/beta" in result.stdout + + +def test_project_ls_defaults_to_local_route( + runner: CliRunner, write_config, mock_client, tmp_path, monkeypatch +): + """project ls without flags should list local files and not require cloud credentials.""" + project_dir = tmp_path / "alpha-files" + (project_dir / "docs").mkdir(parents=True, exist_ok=True) + (project_dir / "notes.md").write_text("# local note") + (project_dir / "docs" / "spec.md").write_text("# spec") + + write_config( + { + "env": "dev", + "projects": {"alpha": {"path": project_dir.as_posix(), "mode": "cloud"}}, + "default_project": "alpha", + } + ) + + payload = { + "projects": [ + { + "id": 1, + "external_id": "11111111-1111-1111-1111-111111111111", + "name": "alpha", + "path": project_dir.as_posix(), + "is_default": True, + } + ], + "default_project": "alpha", + } + + class _Resp: + def json(self): + return payload + + async def fake_call_get(client, path: str, **kwargs): + assert path == "/v2/projects/" + assert os.getenv("BASIC_MEMORY_FORCE_CLOUD", "").lower() not in ("true", "1", "yes") + return _Resp() + + def fail_if_called(*args, **kwargs): + raise AssertionError("project_ls should not be used for default local route") + + monkeypatch.setattr(project_cmd, "call_get", fake_call_get) + monkeypatch.setattr(project_cmd, "project_ls", fail_if_called) + + result = runner.invoke(app, ["project", "ls", "--name", "alpha"], env={"COLUMNS": "200"}) + + assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}" + assert "Files in alpha (LOCAL)" in result.stdout + assert "notes.md" in result.stdout + assert "docs/spec.md" in result.stdout + + +def test_project_ls_cloud_route_uses_cloud_listing( + runner: CliRunner, write_config, mock_client, tmp_path, monkeypatch +): + """project ls --cloud should fetch cloud project listing and print cloud-target heading.""" + write_config( + { + "env": "dev", + "projects": {"alpha": {"path": str(tmp_path / "alpha"), "mode": "local"}}, + "default_project": "alpha", + "cloud_api_key": "bmc_test_key_123", + } + ) + + cloud_payload = { + "projects": [ + { + "id": 1, + "external_id": "11111111-1111-1111-1111-111111111111", + "name": "alpha", + "path": "/alpha", + "is_default": True, + } + ], + "default_project": "alpha", + } + + class _Resp: + def json(self): + return cloud_payload + + class _TenantInfo: + bucket_name = "tenant-bucket" + + async def fake_call_get(client, path: str, **kwargs): + assert path == "/v2/projects/" + assert os.getenv("BASIC_MEMORY_FORCE_CLOUD", "").lower() in ("true", "1", "yes") + return _Resp() + + async def fake_get_mount_info(): + return _TenantInfo() + + monkeypatch.setattr(project_cmd, "call_get", fake_call_get) + monkeypatch.setattr(project_cmd, "get_mount_info", fake_get_mount_info) + monkeypatch.setattr(project_cmd, "project_ls", lambda *args, **kwargs: [" 42 cloud.md"]) + + result = runner.invoke(app, ["project", "ls", "--name", "alpha", "--cloud"]) + + assert result.exit_code == 0, f"Exit code: {result.exit_code}, output: {result.stdout}" + assert "Files in alpha (CLOUD)" in result.stdout + assert "cloud.md" in result.stdout diff --git a/tests/cli/test_routing.py b/tests/cli/test_routing.py index dd183e1f..fec3568b 100644 --- a/tests/cli/test_routing.py +++ b/tests/cli/test_routing.py @@ -34,59 +34,73 @@ class TestForceRouting: def test_local_sets_env_vars(self): """Local flag should set BASIC_MEMORY_FORCE_LOCAL and EXPLICIT_ROUTING.""" os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None) + os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None) os.environ.pop("BASIC_MEMORY_EXPLICIT_ROUTING", None) with force_routing(local=True): assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true" + assert os.environ.get("BASIC_MEMORY_FORCE_CLOUD") is None assert os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING") == "true" # Should be cleaned up after context exits assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None + assert os.environ.get("BASIC_MEMORY_FORCE_CLOUD") is None assert os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING") is None def test_cloud_sets_explicit_routing(self): - """Cloud flag should set EXPLICIT_ROUTING and clear FORCE_LOCAL.""" + """Cloud flag should set FORCE_CLOUD + EXPLICIT_ROUTING and clear FORCE_LOCAL.""" os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true" + os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None) os.environ.pop("BASIC_MEMORY_EXPLICIT_ROUTING", None) with force_routing(cloud=True): assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None + assert os.environ.get("BASIC_MEMORY_FORCE_CLOUD") == "true" assert os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING") == "true" # Should restore original values after context exits assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true" + assert os.environ.get("BASIC_MEMORY_FORCE_CLOUD") is None assert os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING") is None # Cleanup os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None) + os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None) def test_neither_flag_no_change(self): """Neither flag should not change env vars.""" os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None) + os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None) os.environ.pop("BASIC_MEMORY_EXPLICIT_ROUTING", None) with force_routing(): assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None + assert os.environ.get("BASIC_MEMORY_FORCE_CLOUD") is None assert os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING") is None assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None + assert os.environ.get("BASIC_MEMORY_FORCE_CLOUD") is None assert os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING") is None def test_preserves_original_env_vars(self): """Should restore original env var values after context exits.""" os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "original" + os.environ["BASIC_MEMORY_FORCE_CLOUD"] = "original" os.environ["BASIC_MEMORY_EXPLICIT_ROUTING"] = "original" with force_routing(local=True): assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true" + assert os.environ.get("BASIC_MEMORY_FORCE_CLOUD") is None assert os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING") == "true" # Should restore original values assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "original" + assert os.environ.get("BASIC_MEMORY_FORCE_CLOUD") == "original" assert os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING") == "original" # Cleanup os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None) + os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None) os.environ.pop("BASIC_MEMORY_EXPLICIT_ROUTING", None) def test_both_flags_raises(self): @@ -98,11 +112,13 @@ class TestForceRouting: def test_restores_on_exception(self): """Should restore env vars even when exception is raised.""" os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None) + os.environ.pop("BASIC_MEMORY_FORCE_CLOUD", None) os.environ.pop("BASIC_MEMORY_EXPLICIT_ROUTING", None) try: with force_routing(local=True): assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true" + assert os.environ.get("BASIC_MEMORY_FORCE_CLOUD") is None assert os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING") == "true" raise RuntimeError("Test exception") except RuntimeError: @@ -110,4 +126,5 @@ class TestForceRouting: # Should be cleaned up even after exception assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None + assert os.environ.get("BASIC_MEMORY_FORCE_CLOUD") is None assert os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING") is None diff --git a/tests/mcp/test_async_client_modes.py b/tests/mcp/test_async_client_modes.py index ac3756f5..57f0ad44 100644 --- a/tests/mcp/test_async_client_modes.py +++ b/tests/mcp/test_async_client_modes.py @@ -10,8 +10,11 @@ from basic_memory.mcp.async_client import get_client, set_client_factory @pytest.fixture(autouse=True) -def _reset_async_client_factory(): +def _reset_async_client_state(monkeypatch): async_client_module._client_factory = None + monkeypatch.delenv("BASIC_MEMORY_FORCE_LOCAL", raising=False) + monkeypatch.delenv("BASIC_MEMORY_FORCE_CLOUD", raising=False) + monkeypatch.delenv("BASIC_MEMORY_EXPLICIT_ROUTING", raising=False) yield async_client_module._client_factory = None @@ -34,61 +37,52 @@ async def test_get_client_uses_injected_factory(monkeypatch): @pytest.mark.asyncio -async def test_get_client_cloud_mode_injects_auth_header(config_manager, config_home): +async def test_get_client_default_uses_local_asgi_transport(config_manager): cfg = config_manager.load_config() - cfg.cloud_mode = True cfg.cloud_host = "https://cloud.example.test" - cfg.cloud_client_id = "cid" - cfg.cloud_domain = "https://auth.example.test" + cfg.cloud_api_key = "bmc_test_key_123" config_manager.save_config(cfg) - # Write token for CLIAuth so get_client() can authenticate without network - 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":"token-123","refresh_token":null,"expires_at":9999999999,"token_type":"Bearer"}', - encoding="utf-8", - ) - async with get_client() as client: - assert str(client.base_url).rstrip("/") == "https://cloud.example.test/proxy" - assert client.headers.get("Authorization") == "Bearer token-123" + assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage] @pytest.mark.asyncio -async def test_get_client_cloud_mode_raises_when_not_authenticated(config_manager): +async def test_get_client_explicit_cloud_uses_api_key(config_manager, monkeypatch): cfg = config_manager.load_config() - cfg.cloud_mode = True cfg.cloud_host = "https://cloud.example.test" + cfg.cloud_api_key = "bmc_test_key_123" + config_manager.save_config(cfg) + + monkeypatch.setenv("BASIC_MEMORY_FORCE_CLOUD", "true") + monkeypatch.setenv("BASIC_MEMORY_EXPLICIT_ROUTING", "true") + + async with get_client() 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_explicit_cloud_raises_without_credentials(config_manager, monkeypatch): + cfg = config_manager.load_config() + cfg.cloud_host = "https://cloud.example.test" + cfg.cloud_api_key = None cfg.cloud_client_id = "cid" cfg.cloud_domain = "https://auth.example.test" config_manager.save_config(cfg) - # No token file written -> should raise - with pytest.raises(RuntimeError, match="Cloud mode enabled but not authenticated"): + monkeypatch.setenv("BASIC_MEMORY_FORCE_CLOUD", "true") + monkeypatch.setenv("BASIC_MEMORY_EXPLICIT_ROUTING", "true") + + with pytest.raises(RuntimeError, match="Cloud routing requested but no credentials found"): async with get_client(): pass @pytest.mark.asyncio -async def test_get_client_local_mode_uses_asgi_transport(config_manager): +async def test_get_client_per_project_cloud_uses_api_key(config_manager): + """Cloud-mode project routes through cloud with API key auth.""" cfg = config_manager.load_config() - cfg.cloud_mode = False - config_manager.save_config(cfg) - - 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) @@ -100,32 +94,23 @@ async def test_get_client_per_project_cloud_mode_uses_api_key(config_manager, co @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.""" +async def test_get_client_per_project_cloud_raises_without_credentials(config_manager): + """Cloud-mode project raises with actionable auth guidance when no credentials exist.""" cfg = config_manager.load_config() - cfg.cloud_mode = False - cfg.cloud_api_key = None # No API key + cfg.cloud_api_key = None 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", - ): + with pytest.raises(RuntimeError, match="Project 'research' is set to cloud mode"): 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.""" +async def test_get_client_local_project_uses_asgi_transport(config_manager): + """Local-mode project uses ASGI transport even if 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: @@ -133,39 +118,20 @@ async def test_get_client_local_project_uses_asgi_transport(config_manager, conf @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.""" +async def test_get_client_no_project_name_defaults_local(config_manager): + """No project_name defaults to local ASGI routing.""" 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.""" +async def test_get_client_factory_overrides_per_project_routing(config_manager): + """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) @@ -178,21 +144,16 @@ async def test_get_client_factory_overrides_per_project_routing(config_manager, 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 +async def test_get_client_force_local_without_explicit_does_not_override_project_mode( + config_manager, monkeypatch ): - """CLOUD project routes to cloud even when BASIC_MEMORY_FORCE_LOCAL is set.""" + """FORCE_LOCAL alone should not bypass per-project cloud routing.""" 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) @@ -202,33 +163,30 @@ async def test_get_client_per_project_cloud_bypasses_force_local( 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.""" +async def test_get_client_explicit_local_overrides_cloud_project(config_manager, monkeypatch): + """EXPLICIT_ROUTING + FORCE_LOCAL should override a cloud project to local ASGI.""" cfg = config_manager.load_config() - cfg.cloud_mode = False + cfg.cloud_host = "https://cloud.example.test" cfg.cloud_api_key = "bmc_test_key_123" - # "main" defaults to LOCAL + cfg.set_project_mode("research", ProjectMode.CLOUD) config_manager.save_config(cfg) monkeypatch.setenv("BASIC_MEMORY_FORCE_LOCAL", "true") + monkeypatch.setenv("BASIC_MEMORY_EXPLICIT_ROUTING", "true") - async with get_client(project_name="main") as client: + async with get_client(project_name="research") 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.""" +async def test_get_client_per_project_cloud_oauth_fallback(config_manager): + """Cloud-mode 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_api_key = None cfg.cloud_client_id = "cid" cfg.cloud_domain = "https://auth.example.test" cfg.set_project_mode("research", ProjectMode.CLOUD) @@ -247,56 +205,17 @@ async def test_get_client_per_project_cloud_oauth_fallback(config_manager, confi assert client.headers.get("Authorization") == "Bearer oauth-token-456" -# --- Explicit routing override tests --- - - @pytest.mark.asyncio -async def test_get_client_explicit_routing_overrides_cloud_project( - config_manager, config_home, monkeypatch -): - """EXPLICIT_ROUTING + FORCE_LOCAL should override a CLOUD project to use local ASGI.""" +async def test_get_client_explicit_cloud_overrides_local_project(config_manager, monkeypatch): + """EXPLICIT_ROUTING + FORCE_CLOUD should override a local project to cloud.""" 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) - # Simulate CLI --local flag: sets both FORCE_LOCAL and EXPLICIT_ROUTING - monkeypatch.setenv("BASIC_MEMORY_FORCE_LOCAL", "true") - monkeypatch.setenv("BASIC_MEMORY_EXPLICIT_ROUTING", "true") - - async with get_client(project_name="research") as client: - # Should use local ASGI transport, NOT cloud proxy - assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage] - - -@pytest.mark.asyncio -async def test_get_client_explicit_routing_cloud_flag_overrides_local_project( - config_manager, config_home, monkeypatch -): - """EXPLICIT_ROUTING + cloud mode should override a LOCAL project to use cloud.""" - cfg = config_manager.load_config() - cfg.cloud_mode = True - cfg.cloud_host = "https://cloud.example.test" - cfg.cloud_client_id = "cid" - cfg.cloud_domain = "https://auth.example.test" - # "main" defaults to LOCAL - config_manager.save_config(cfg) - - # Write OAuth token for cloud auth - 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":"token-cloud","refresh_token":null,"expires_at":9999999999,"token_type":"Bearer"}', - encoding="utf-8", - ) - - # Simulate CLI --cloud flag: sets EXPLICIT_ROUTING, no FORCE_LOCAL - monkeypatch.delenv("BASIC_MEMORY_FORCE_LOCAL", raising=False) + monkeypatch.setenv("BASIC_MEMORY_FORCE_CLOUD", "true") monkeypatch.setenv("BASIC_MEMORY_EXPLICIT_ROUTING", "true") async with get_client(project_name="main") as client: - # Should use cloud proxy, NOT local ASGI assert str(client.base_url).rstrip("/") == "https://cloud.example.test/proxy" - assert client.headers.get("Authorization") == "Bearer token-cloud" + assert client.headers.get("Authorization") == "Bearer bmc_test_key_123" diff --git a/tests/mcp/test_project_context.py b/tests/mcp/test_project_context.py index 7e8885e9..6e569c8e 100644 --- a/tests/mcp/test_project_context.py +++ b/tests/mcp/test_project_context.py @@ -10,29 +10,22 @@ import pytest @pytest.mark.asyncio -async def test_cloud_mode_requires_project_when_no_default(config_manager, monkeypatch): +async def test_returns_none_when_no_default_and_no_project(config_manager, monkeypatch): from basic_memory.mcp.project_context import resolve_project_parameter cfg = config_manager.load_config() - cfg.cloud_mode = True - # Clear default_project to test the "no default available" path cfg.default_project = None config_manager.save_config(cfg) - with pytest.raises(ValueError) as exc_info: - await resolve_project_parameter(project=None, allow_discovery=False) - - assert "No project specified" in str(exc_info.value) - assert "Project is required" in str(exc_info.value) + monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False) + assert await resolve_project_parameter(project=None, allow_discovery=False) is None @pytest.mark.asyncio -async def test_cloud_mode_allows_discovery_when_enabled(config_manager): +async def test_allows_discovery_when_enabled(config_manager): from basic_memory.mcp.project_context import resolve_project_parameter cfg = config_manager.load_config() - cfg.cloud_mode = True - # Clear default_project so discovery fallback is reached cfg.default_project = None config_manager.save_config(cfg) @@ -40,22 +33,20 @@ async def test_cloud_mode_allows_discovery_when_enabled(config_manager): @pytest.mark.asyncio -async def test_cloud_mode_returns_project_when_specified(config_manager): +async def test_returns_project_when_specified(config_manager): from basic_memory.mcp.project_context import resolve_project_parameter cfg = config_manager.load_config() - cfg.cloud_mode = True config_manager.save_config(cfg) assert await resolve_project_parameter(project="my-project") == "my-project" @pytest.mark.asyncio -async def test_local_mode_uses_env_var_priority(config_manager, monkeypatch): +async def test_uses_env_var_priority(config_manager, monkeypatch): from basic_memory.mcp.project_context import resolve_project_parameter cfg = config_manager.load_config() - cfg.cloud_mode = False config_manager.save_config(cfg) monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "env-project") @@ -63,11 +54,10 @@ async def test_local_mode_uses_env_var_priority(config_manager, monkeypatch): @pytest.mark.asyncio -async def test_local_mode_uses_explicit_project(config_manager, monkeypatch): +async def test_uses_explicit_project_when_no_env(config_manager, monkeypatch): from basic_memory.mcp.project_context import resolve_project_parameter cfg = config_manager.load_config() - cfg.cloud_mode = False config_manager.save_config(cfg) monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False) @@ -75,13 +65,11 @@ async def test_local_mode_uses_explicit_project(config_manager, monkeypatch): @pytest.mark.asyncio -async def test_local_mode_uses_default_project(config_manager, config_home, monkeypatch): +async def test_uses_default_project(config_manager, config_home, monkeypatch): from basic_memory.mcp.project_context import resolve_project_parameter - - cfg = config_manager.load_config() - cfg.cloud_mode = False from basic_memory.config import ProjectEntry + cfg = config_manager.load_config() (config_home / "default-project").mkdir(parents=True, exist_ok=True) cfg.projects["default-project"] = ProjectEntry(path=str(config_home / "default-project")) cfg.default_project = "default-project" @@ -92,11 +80,10 @@ async def test_local_mode_uses_default_project(config_manager, config_home, monk @pytest.mark.asyncio -async def test_local_mode_returns_none_when_no_default(config_manager, monkeypatch): +async def test_returns_none_when_no_default(config_manager, monkeypatch): from basic_memory.mcp.project_context import resolve_project_parameter cfg = config_manager.load_config() - cfg.cloud_mode = False cfg.default_project = None config_manager.save_config(cfg) @@ -105,18 +92,15 @@ async def test_local_mode_returns_none_when_no_default(config_manager, monkeypat @pytest.mark.asyncio -async def test_cloud_mode_uses_default_project(config_manager, config_home, monkeypatch): - """In cloud mode with default_project set, default project is resolved.""" +async def test_env_constraint_overrides_default(config_manager, config_home, monkeypatch): from basic_memory.mcp.project_context import resolve_project_parameter - - cfg = config_manager.load_config() - cfg.cloud_mode = True from basic_memory.config import ProjectEntry - (config_home / "cloud-default").mkdir(parents=True, exist_ok=True) - cfg.projects["cloud-default"] = ProjectEntry(path=str(config_home / "cloud-default")) - cfg.default_project = "cloud-default" + cfg = config_manager.load_config() + (config_home / "default-project").mkdir(parents=True, exist_ok=True) + cfg.projects["default-project"] = ProjectEntry(path=str(config_home / "default-project")) + cfg.default_project = "default-project" config_manager.save_config(cfg) - monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False) - assert await resolve_project_parameter(project=None) == "cloud-default" + monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "env-project") + assert await resolve_project_parameter(project=None) == "env-project" diff --git a/tests/mcp/test_server_lifespan_branches.py b/tests/mcp/test_server_lifespan_branches.py index 8db01508..49b42513 100644 --- a/tests/mcp/test_server_lifespan_branches.py +++ b/tests/mcp/test_server_lifespan_branches.py @@ -5,10 +5,9 @@ from basic_memory.mcp.server import lifespan, mcp @pytest.mark.asyncio -async def test_mcp_lifespan_sync_disabled_branch(config_manager, monkeypatch): +async def test_mcp_lifespan_sync_disabled_branch(config_manager): cfg = config_manager.load_config() cfg.sync_changes = False - cfg.cloud_mode = False config_manager.save_config(cfg) async with lifespan(mcp): @@ -16,10 +15,9 @@ async def test_mcp_lifespan_sync_disabled_branch(config_manager, monkeypatch): @pytest.mark.asyncio -async def test_mcp_lifespan_cloud_mode_branch(config_manager): +async def test_mcp_lifespan_sync_enabled_branch(config_manager): cfg = config_manager.load_config() cfg.sync_changes = True - cfg.cloud_mode = True config_manager.save_config(cfg) async with lifespan(mcp): diff --git a/tests/services/test_initialization_cloud_mode_branches.py b/tests/services/test_initialization_cloud_mode_branches.py index 24e7d60e..99a3aa5c 100644 --- a/tests/services/test_initialization_cloud_mode_branches.py +++ b/tests/services/test_initialization_cloud_mode_branches.py @@ -1,5 +1,6 @@ import pytest +from basic_memory.config import DatabaseBackend from basic_memory.services.initialization import ( ensure_initialization, initialize_app, @@ -8,13 +9,13 @@ from basic_memory.services.initialization import ( @pytest.mark.asyncio -async def test_initialize_app_noop_in_cloud_mode(app_config): - app_config.cloud_mode = True +async def test_initialize_app_noop_in_postgres_backend(app_config): + app_config.database_backend = DatabaseBackend.POSTGRES await initialize_app(app_config) -def test_ensure_initialization_noop_in_cloud_mode(app_config): - app_config.cloud_mode = True +def test_ensure_initialization_noop_in_postgres_backend(app_config): + app_config.database_backend = DatabaseBackend.POSTGRES ensure_initialization(app_config) diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index b20eb444..f90551fc 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -13,7 +13,7 @@ from basic_memory.schemas import ( SystemStatus, ) from basic_memory.services.project_service import ProjectService -from basic_memory.config import ConfigManager +from basic_memory.config import ConfigManager, DatabaseBackend def test_projects_property(project_service: ProjectService): @@ -1355,18 +1355,20 @@ async def test_remove_project_delete_notes_missing_directory(project_service: Pr @pytest.mark.asyncio -async def test_remove_project_cloud_mode_uses_database_not_config(project_service: ProjectService): - """Test that in cloud mode, remove_project only checks database for default status. +async def test_remove_project_postgres_backend_uses_database_not_config( + project_service: ProjectService, +): + """Test that in Postgres backend, remove_project only checks database for default status. - Regression test for bug where cloud mode checked config file (stale) instead of + Regression test for bug where cloud backend checked config file (stale) instead of database (source of truth) when determining if a project is the default. """ test_project_name = f"test-cloud-default-{os.urandom(4).hex()}" test_project_path = f"/tmp/test-cloud-{os.urandom(8).hex()}" - # Save original cloud_mode setting + # Save original backend/default settings config = project_service.config_manager.config - original_cloud_mode = config.cloud_mode + original_backend = config.database_backend original_default = config.default_project try: @@ -1382,11 +1384,11 @@ async def test_remove_project_cloud_mode_uses_database_not_config(project_servic # (This simulates what happens when config isn't updated after API calls) config.default_project = test_project_name - # Enable cloud mode - config.cloud_mode = True + # Use Postgres backend semantics (database is source of truth) + config.database_backend = DatabaseBackend.POSTGRES project_service.config_manager.save_config(config) - # In cloud mode, should be able to remove the project because database says it's not default + # In Postgres backend, should be able to remove because database says it's not default # (even though stale config says it is) - this should NOT raise ValueError await project_service.remove_project(test_project_name, delete_notes=False) @@ -1397,7 +1399,7 @@ async def test_remove_project_cloud_mode_uses_database_not_config(project_servic finally: # Restore original settings config = project_service.config_manager.config - config.cloud_mode = original_cloud_mode + config.database_backend = original_backend config.default_project = original_default project_service.config_manager.save_config(config) @@ -1412,9 +1414,9 @@ async def test_remove_project_cloud_mode_uses_database_not_config(project_servic async def test_remove_project_local_mode_checks_both_config_and_database( project_service: ProjectService, ): - """Test that in local mode, remove_project checks both config AND database for default status. + """Test that in SQLite backend, remove_project checks both config AND database for default status. - In local mode, we check both sources to be safe - if either says the project is default, + In SQLite backend, we check both sources to be safe - if either says the project is default, we prevent deletion. """ test_project_name = f"test-local-default-{os.urandom(4).hex()}" @@ -1422,15 +1424,15 @@ async def test_remove_project_local_mode_checks_both_config_and_database( # Save original settings config = project_service.config_manager.config - original_cloud_mode = config.cloud_mode + original_backend = config.database_backend original_default = config.default_project try: - # Ensure we're in local mode before adding project - config.cloud_mode = False + # Ensure we're in SQLite backend before adding project + config.database_backend = DatabaseBackend.SQLITE project_service.config_manager.save_config(config) - # Add a test project (not default) - this will add to both DB and config in local mode + # Add a test project (not default) - this will add to both DB and config in SQLite mode await project_service.add_project(test_project_name, test_project_path, set_default=False) # Verify project exists and is NOT default in database @@ -1445,7 +1447,7 @@ async def test_remove_project_local_mode_checks_both_config_and_database( config.default_project = test_project_name project_service.config_manager.save_config(config) - # In local mode, should NOT be able to remove because config says it's default + # In SQLite backend, should NOT be able to remove because config says it's default with pytest.raises(ValueError, match="Cannot remove the default project"): await project_service.remove_project(test_project_name, delete_notes=False) @@ -1456,7 +1458,7 @@ async def test_remove_project_local_mode_checks_both_config_and_database( finally: # Restore original settings config = project_service.config_manager.config - config.cloud_mode = original_cloud_mode + config.database_backend = original_backend config.default_project = original_default project_service.config_manager.save_config(config) @@ -1479,7 +1481,7 @@ async def test_remove_project_rejects_database_default_in_both_modes( test_project_path = f"/tmp/test-db-default-{os.urandom(8).hex()}" # Save original settings - original_cloud_mode = project_service.config_manager.config.cloud_mode + original_backend = project_service.config_manager.config.database_backend original_default = project_service.config_manager.config.default_project try: @@ -1491,16 +1493,16 @@ async def test_remove_project_rejects_database_default_in_both_modes( assert db_project is not None assert db_project.is_default is True - # Test in cloud mode - should reject + # Test in Postgres backend - should reject config = project_service.config_manager.config - config.cloud_mode = True + config.database_backend = DatabaseBackend.POSTGRES project_service.config_manager.save_config(config) with pytest.raises(ValueError, match="Cannot remove the default project"): await project_service.remove_project(test_project_name, delete_notes=False) - # Test in local mode - should also reject - config.cloud_mode = False + # Test in SQLite backend - should also reject + config.database_backend = DatabaseBackend.SQLITE project_service.config_manager.save_config(config) with pytest.raises(ValueError, match="Cannot remove the default project"): @@ -1512,7 +1514,7 @@ async def test_remove_project_rejects_database_default_in_both_modes( finally: # Restore original settings config = project_service.config_manager.config - config.cloud_mode = original_cloud_mode + config.database_backend = original_backend config.default_project = original_default project_service.config_manager.save_config(config) diff --git a/tests/test_config.py b/tests/test_config.py index 143215a0..011d0328 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -463,6 +463,36 @@ class TestConfigManager: assert config.projects["research"].bisync_initialized is True assert config.projects["main"].mode == ProjectMode.LOCAL + def test_legacy_cloud_mode_key_is_stripped_on_normalization_save(self): + """Legacy cloud_mode should be removed from config.json after load/save normalization.""" + 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) + + import json + + legacy_config = { + "env": "dev", + "projects": {"main": str(temp_path / "main")}, + "default_project": "main", + "cloud_mode": True, + } + config_manager.config_file.write_text(json.dumps(legacy_config, indent=2)) + + import basic_memory.config + + basic_memory.config._CONFIG_CACHE = None + + loaded = config_manager.load_config() + assert isinstance(loaded, BasicMemoryConfig) + + raw = json.loads(config_manager.config_file.read_text(encoding="utf-8")) + assert "cloud_mode" not in raw + class TestPlatformNativePathSeparators: """Test that config uses platform-native path separators.""" diff --git a/tests/test_project_resolver.py b/tests/test_project_resolver.py index e7b7a03e..f6fadf61 100644 --- a/tests/test_project_resolver.py +++ b/tests/test_project_resolver.py @@ -1,6 +1,7 @@ """Tests for ProjectResolver - unified project resolution logic.""" import pytest + from basic_memory.project_resolver import ( ProjectResolver, ResolvedProject, @@ -11,77 +12,38 @@ from basic_memory.project_resolver import ( class TestProjectResolver: """Test ProjectResolver class.""" - def test_cloud_mode_requires_project(self): - """In cloud mode, project is required.""" - resolver = ProjectResolver(cloud_mode=True) - with pytest.raises(ValueError, match="Project is required"): - resolver.resolve(project=None) - - def test_cloud_mode_with_explicit_project(self): - """In cloud mode, explicit project is accepted.""" - resolver = ProjectResolver(cloud_mode=True) - result = resolver.resolve(project="my-project") - - assert result.project == "my-project" - assert result.mode == ResolutionMode.CLOUD_EXPLICIT - assert result.is_resolved is True - assert result.is_discovery_mode is False - - def test_cloud_mode_discovery_allowed(self): - """In cloud mode with allow_discovery, None is acceptable.""" - resolver = ProjectResolver(cloud_mode=True) - result = resolver.resolve(project=None, allow_discovery=True) - - assert result.project is None - assert result.mode == ResolutionMode.CLOUD_DISCOVERY - assert result.is_resolved is False - assert result.is_discovery_mode is True - - def test_local_mode_env_constraint_priority(self, monkeypatch): - """Env constraint has highest priority in local mode.""" + def test_env_constraint_has_highest_priority(self, monkeypatch): + """Environment constraint should win over explicit/default.""" monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "constrained-project") - resolver = ProjectResolver.from_env( - cloud_mode=False, - default_project="default-project", - ) + resolver = ProjectResolver.from_env(default_project="default-project") - # Even with explicit project and default, env constraint wins result = resolver.resolve(project="explicit-project") assert result.project == "constrained-project" assert result.mode == ResolutionMode.ENV_CONSTRAINT assert result.is_resolved is True - def test_local_mode_explicit_project(self): - """Explicit project parameter has second priority.""" - resolver = ProjectResolver( - cloud_mode=False, - default_project="default-project", - ) + def test_explicit_project_has_second_priority(self): + """Explicit project parameter should override default.""" + resolver = ProjectResolver(default_project="default-project") result = resolver.resolve(project="explicit-project") assert result.project == "explicit-project" assert result.mode == ResolutionMode.EXPLICIT - def test_local_mode_default_project(self): - """Default project is used as fallback when set.""" - resolver = ProjectResolver( - cloud_mode=False, - default_project="my-default", - ) + def test_default_project_is_used_as_fallback(self): + """Default project should be used when explicit is missing.""" + resolver = ProjectResolver(default_project="my-default") result = resolver.resolve(project=None) assert result.project == "my-default" assert result.mode == ResolutionMode.DEFAULT - def test_local_mode_no_default_project(self): - """No default_project configured → ResolutionMode.NONE.""" - resolver = ProjectResolver( - cloud_mode=False, - default_project=None, - ) + def test_no_resolution_when_no_default_and_no_discovery(self): + """Without explicit/default/discovery, resolution should return NONE.""" + resolver = ProjectResolver(default_project=None) result = resolver.resolve(project=None) @@ -89,21 +51,19 @@ class TestProjectResolver: assert result.mode == ResolutionMode.NONE assert result.is_resolved is False - def test_local_mode_no_resolution_possible(self): - """When nothing is configured, resolution returns None.""" - resolver = ProjectResolver(cloud_mode=False) - result = resolver.resolve(project=None) + def test_discovery_resolution_when_allowed(self): + """Discovery mode should return DISCOVERY when allowed.""" + resolver = ProjectResolver(default_project=None) + + result = resolver.resolve(project=None, allow_discovery=True) assert result.project is None - assert result.mode == ResolutionMode.NONE - assert "no default project configured" in result.reason + assert result.mode == ResolutionMode.DISCOVERY + assert result.is_discovery_mode is True def test_require_project_success(self): - """require_project returns result when project resolved.""" - resolver = ProjectResolver( - cloud_mode=False, - default_project="required-project", - ) + """require_project returns result when project resolves.""" + resolver = ProjectResolver(default_project="required-project") result = resolver.require_project() @@ -111,15 +71,15 @@ class TestProjectResolver: assert result.is_resolved is True def test_require_project_raises_on_failure(self): - """require_project raises ValueError when not resolved.""" - resolver = ProjectResolver(cloud_mode=False) + """require_project raises ValueError when project cannot resolve.""" + resolver = ProjectResolver(default_project=None) with pytest.raises(ValueError, match="No project specified"): resolver.require_project() def test_require_project_custom_error_message(self): """require_project uses custom error message.""" - resolver = ProjectResolver(cloud_mode=False) + resolver = ProjectResolver(default_project=None) with pytest.raises(ValueError, match="Custom error message"): resolver.require_project(error_message="Custom error message") @@ -127,10 +87,7 @@ class TestProjectResolver: def test_from_env_without_env_var(self, monkeypatch): """from_env without BASIC_MEMORY_MCP_PROJECT set.""" monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False) - resolver = ProjectResolver.from_env( - cloud_mode=False, - default_project="test", - ) + resolver = ProjectResolver.from_env(default_project="test") assert resolver.constrained_project is None result = resolver.resolve(project="explicit") @@ -143,69 +100,6 @@ class TestProjectResolver: assert resolver.constrained_project == "env-project" - def test_cloud_mode_uses_default_project(self): - """In cloud mode, default project is used when configured.""" - resolver = ProjectResolver( - cloud_mode=True, - default_project="my-default", - ) - result = resolver.resolve(project=None) - - assert result.project == "my-default" - assert result.mode == ResolutionMode.DEFAULT - assert result.is_resolved is True - - def test_cloud_mode_no_default_still_requires_project(self): - """In cloud mode with no default_project, project is still required.""" - resolver = ProjectResolver( - cloud_mode=True, - ) - with pytest.raises(ValueError, match="Project is required"): - resolver.resolve(project=None) - - def test_cloud_mode_explicit_overrides_default(self): - """In cloud mode, explicit project wins over default project.""" - resolver = ProjectResolver( - cloud_mode=True, - default_project="my-default", - ) - result = resolver.resolve(project="explicit-project") - - assert result.project == "explicit-project" - assert result.mode == ResolutionMode.CLOUD_EXPLICIT - - def test_cloud_mode_no_default_project_raises(self): - """In cloud mode with no default_project set, still raises.""" - resolver = ProjectResolver( - cloud_mode=True, - default_project=None, - ) - with pytest.raises(ValueError, match="Project is required"): - resolver.resolve(project=None) - - def test_cloud_mode_discovery_fallback_after_no_default(self): - """In cloud mode, discovery still works as last resort when no default is configured.""" - resolver = ProjectResolver( - cloud_mode=True, - ) - result = resolver.resolve(project=None, allow_discovery=True) - - assert result.project is None - assert result.mode == ResolutionMode.CLOUD_DISCOVERY - assert result.is_discovery_mode is True - - def test_cloud_mode_env_constraint_overrides_everything(self, monkeypatch): - """In cloud mode, env constraint has highest priority.""" - monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "constrained-project") - resolver = ProjectResolver.from_env( - cloud_mode=True, - default_project="default-project", - ) - result = resolver.resolve(project="explicit-project") - - assert result.project == "constrained-project" - assert result.mode == ResolutionMode.ENV_CONSTRAINT - class TestResolvedProject: """Test ResolvedProject dataclass.""" @@ -228,11 +122,11 @@ class TestResolvedProject: ) assert result.is_resolved is False - def test_is_discovery_mode_cloud(self): - """is_discovery_mode is True for CLOUD_DISCOVERY.""" + def test_is_discovery_mode_discovery(self): + """is_discovery_mode is True for DISCOVERY.""" result = ResolvedProject( project=None, - mode=ResolutionMode.CLOUD_DISCOVERY, + mode=ResolutionMode.DISCOVERY, reason="test", ) assert result.is_discovery_mode is True diff --git a/tests/test_runtime.py b/tests/test_runtime.py index ba7b5ef8..729d228d 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -29,24 +29,16 @@ class TestResolveRuntimeMode: """Tests for resolve_runtime_mode function.""" def test_resolves_to_test_when_test_env(self): - """Test environment takes precedence over cloud mode.""" - mode = resolve_runtime_mode(cloud_mode_enabled=True, is_test_env=True) + """Test environment resolves to TEST mode.""" + mode = resolve_runtime_mode(is_test_env=True) assert mode == RuntimeMode.TEST - def test_resolves_to_cloud_when_enabled(self): - """Cloud mode is used when enabled and not in test env.""" - mode = resolve_runtime_mode(cloud_mode_enabled=True, is_test_env=False) - assert mode == RuntimeMode.CLOUD - - def test_resolves_to_local_by_default(self): - """Local mode is the default when no other modes apply.""" - mode = resolve_runtime_mode(cloud_mode_enabled=False, is_test_env=False) + def test_resolves_to_local_when_not_test_env(self): + """Non-test environments resolve to LOCAL mode.""" + mode = resolve_runtime_mode(is_test_env=False) assert mode == RuntimeMode.LOCAL - def test_test_env_overrides_cloud_mode(self): - """Test environment should override cloud mode.""" - # When both are enabled, test takes precedence - mode = resolve_runtime_mode(cloud_mode_enabled=True, is_test_env=True) - assert mode == RuntimeMode.TEST - assert mode.is_test is True - assert mode.is_cloud is False + def test_never_resolves_to_cloud_in_local_app_context(self): + """Resolver no longer returns CLOUD for local app composition roots.""" + mode = resolve_runtime_mode(is_test_env=False) + assert mode is not RuntimeMode.CLOUD diff --git a/tests/utils/test_timezone_utils.py b/tests/utils/test_timezone_utils.py index 1d1d9909..d9e02ea7 100644 --- a/tests/utils/test_timezone_utils.py +++ b/tests/utils/test_timezone_utils.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone +from basic_memory.config import DatabaseBackend from basic_memory.utils import ensure_timezone_aware @@ -62,12 +63,12 @@ class TestEnsureTimezoneAware: result_local = ensure_timezone_aware(naive_dt, cloud_mode=False) assert result_local.tzinfo is not None - def test_none_cloud_mode_falls_back_to_config(self, config_manager): - """When cloud_mode is None, should load from config.""" + def test_none_cloud_mode_falls_back_to_database_backend(self, config_manager): + """When cloud_mode is None, should infer from database backend.""" naive_dt = datetime(2024, 1, 15, 12, 30, 0) # Use the real config file (via test fixtures) rather than mocking. cfg = config_manager.config - cfg.cloud_mode = True + cfg.database_backend = DatabaseBackend.POSTGRES config_manager.save_config(cfg) result = ensure_timezone_aware(naive_dt, cloud_mode=None)