feat: Add cloud discovery touchpoints to CLI and MCP (#546)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-02-12 21:01:45 -06:00
committed by GitHub
parent ed9487708e
commit 312662f382
14 changed files with 420 additions and 3 deletions
+9 -3
View File
@@ -9,9 +9,9 @@
## 🚀 Basic Memory Cloud is Live!
- **Cross-device and multi-platform support is here.** Your knowledge graph now works on desktop, web, and mobile - seamlessly synced across all your AI tools (Claude, ChatGPT, Gemini, Claude Code, and Codex)
- **Early Supporter Pricing:** Early users get 25% off forever.
The open source project continues as always. Cloud just makes it work everywhere.
- **Cross-device and multi-platform support is here.** Your knowledge graph now works on desktop, web, and mobile.
- **Cloud is optional.** The local-first open-source workflow continues as always.
- **OSS discount:** use code `{{OSS_DISCOUNT_CODE}}` for 20% off for 3 months.
[Sign up now →](https://basicmemory.com)
@@ -408,6 +408,12 @@ get_current_project() - Show current project stats
sync_status() - Check synchronization status
```
**Cloud Discovery (opt-in):**
```
cloud_info() - Show optional Cloud overview and setup guidance
release_notes() - Show latest release notes
```
**Visualization:**
```
canvas(nodes, edges, title, folder) - Generate knowledge visualizations
+4
View File
@@ -17,6 +17,8 @@ Before using Basic Memory Cloud, you need:
- **Active Subscription**: An active Basic Memory Cloud subscription is required to access cloud features
- **Subscribe**: Visit [https://basicmemory.com/subscribe](https://basicmemory.com/subscribe) to sign up
- **Optional**: Cloud is optional. Local-first open-source usage continues without cloud.
- **OSS Discount**: Use code `{{OSS_DISCOUNT_CODE}}` for 20% off for 3 months.
If you attempt to log in without an active subscription, you'll receive a "Subscription Required" error with a link to subscribe.
@@ -81,6 +83,7 @@ bm cloud login
4. Validates your subscription status
**Result:** All `bm project`, `bm tools` commands now work with cloud.
Apply OSS discount code `{{OSS_DISCOUNT_CODE}}` during checkout to receive 20% off for 3 months.
### 2. Set Up Sync
@@ -659,6 +662,7 @@ If instance is down, wait a few minutes and retry.
bm cloud login # Authenticate and enable cloud mode
bm cloud logout # Disable cloud mode
bm cloud status # Check cloud mode and instance health
bm cloud promo --off # Disable CLI cloud promo notices
```
### Setup
+3
View File
@@ -9,6 +9,7 @@ from typing import Optional # noqa: E402
import typer # noqa: E402
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
from basic_memory.cli.promo import maybe_show_cloud_promo # noqa: E402
from basic_memory.config import init_cli_logging # noqa: E402
@@ -46,6 +47,8 @@ def app_callback(
container = CliContainer.create()
set_container(container)
maybe_show_cloud_promo(ctx.invoked_subcommand)
# Run initialization for commands that don't use the API
# Skip for 'mcp' command - it has its own lifespan that handles initialization
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
@@ -6,6 +6,7 @@ from rich.console import Console
from basic_memory.cli.app import cloud_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.auth import CLIAuth
from basic_memory.cli.promo import OSS_DISCOUNT_CODE
from basic_memory.config import ConfigManager
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
@@ -57,6 +58,10 @@ def login():
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(
f"OSS discount code: [bold]{OSS_DISCOUNT_CODE}[/bold] "
"(20% off for 3 months)\n"
)
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
console.print(
"[dim]Once you have an active subscription, run [bold]bm cloud login[/bold] again.[/dim]"
@@ -191,3 +196,17 @@ def setup() -> None:
except Exception as e:
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("promo")
def promo(enabled: bool = typer.Option(True, "--on/--off", help="Enable or disable CLI promos.")):
"""Enable or disable CLI cloud promo messages."""
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_promo_opt_out = not enabled
config_manager.save_config(config)
if enabled:
console.print("[green]Cloud promo messages enabled[/green]")
else:
console.print("[yellow]Cloud promo messages disabled[/yellow]")
+84
View File
@@ -0,0 +1,84 @@
"""Cloud promo messaging for CLI entrypoint."""
import os
import sys
from collections.abc import Callable
import typer
from basic_memory.config import ConfigManager
CLOUD_PROMO_VERSION = "2026-02-06"
OSS_DISCOUNT_CODE = "{{OSS_DISCOUNT_CODE}}"
def _promos_disabled_by_env() -> bool:
"""Check environment-level kill switch for promo output."""
value = os.getenv("BASIC_MEMORY_NO_PROMOS", "").strip().lower()
return value in {"1", "true", "yes"}
def _is_interactive_session() -> bool:
"""Return whether stdin/stdout are interactive terminals."""
return sys.stdin.isatty() and sys.stdout.isatty()
def _build_first_run_message() -> str:
"""Build first-run cloud promo copy."""
return (
"Basic Memory initialized (local mode).\n"
"Cloud is optional and keeps your workflow local-first.\n"
"Cloud adds cross-device sync + mobile/web access.\n"
f"OSS discount: {OSS_DISCOUNT_CODE} (20% off for 3 months).\n"
"Run `bm cloud login` to enable."
)
def _build_version_message() -> str:
"""Build cloud promo copy shown after promo-version bumps."""
return (
"New in Basic Memory Cloud: cross-device sync + mobile/web access.\n"
f"OSS discount: {OSS_DISCOUNT_CODE} (20% off for 3 months).\n"
"Run `bm cloud login` to enable."
)
def maybe_show_cloud_promo(
invoked_subcommand: str | None,
*,
config_manager: ConfigManager | None = None,
is_interactive: bool | None = None,
echo: Callable[[str], None] = typer.echo,
) -> None:
"""Show cloud promo copy when discovery gates are satisfied."""
manager = config_manager or ConfigManager()
config = manager.load_config()
interactive = _is_interactive_session() if is_interactive is None else is_interactive
# Trigger: environment-level promo suppression or non-interactive execution.
# Why: avoid polluting scripts/CI output and support a hard opt-out.
# Outcome: skip all promo copy for this invocation.
if _promos_disabled_by_env() or not interactive:
return
# Trigger: command context where cloud promo is not actionable.
# Why: mcp/stdin protocol and root help flows should stay noise-free.
# Outcome: command continues without promo messaging.
if invoked_subcommand in {None, "mcp"}:
return
if config.cloud_mode_enabled or config.cloud_promo_opt_out:
return
show_first_run = not config.cloud_promo_first_run_shown
show_version_notice = config.cloud_promo_last_version_shown != CLOUD_PROMO_VERSION
if not show_first_run and not show_version_notice:
return
message = _build_first_run_message() if show_first_run else _build_version_message()
echo(message)
config.cloud_promo_first_run_shown = True
config.cloud_promo_last_version_shown = CLOUD_PROMO_VERSION
manager.save_config(config)
+15
View File
@@ -249,6 +249,21 @@ class BasicMemoryConfig(BaseSettings):
description="Cloud project sync configuration mapping project names to their local paths and sync state",
)
cloud_promo_opt_out: bool = Field(
default=False,
description="Disable CLI cloud promo messages when true.",
)
cloud_promo_first_run_shown: bool = Field(
default=False,
description="Tracks whether the first-run cloud promo message has been shown.",
)
cloud_promo_last_version_shown: Optional[str] = Field(
default=None,
description="Most recent cloud promo version shown in CLI.",
)
@property
def is_test_env(self) -> bool:
"""Check if running in a test environment.
@@ -0,0 +1,16 @@
# Basic Memory Cloud (optional)
Basic Memory Cloud is an optional add-on for users who want hosted access and sync.
- Hosted access to your knowledge
- Cross-device sync
- Mobile and web access
- Multi-client workflows (Claude, ChatGPT, Gemini, and others)
OSS discount: `{{OSS_DISCOUNT_CODE}}` (20% off for 3 months)
Get started:
```bash
bm cloud login
```
@@ -0,0 +1,16 @@
# Release Notes
## 2026-02-06
- Added optional cloud discovery copy to CLI first-run and promo-version notices.
- Added MCP tools for opt-in cloud discovery: `cloud_info` and `release_notes`.
- Updated docs and README copy to keep cloud messaging explicit and optional.
Cloud remains optional for open-source users.
OSS discount: `{{OSS_DISCOUNT_CODE}}` (20% off for 3 months)
Get started:
```bash
bm cloud login
```
+4
View File
@@ -13,6 +13,8 @@ from basic_memory.mcp.tools.recent_activity import recent_activity
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.view_note import view_note
from basic_memory.mcp.tools.write_note import write_note
from basic_memory.mcp.tools.cloud_info import cloud_info
from basic_memory.mcp.tools.release_notes import release_notes
from basic_memory.mcp.tools.search import search_notes, search_by_metadata
from basic_memory.mcp.tools.canvas import canvas
from basic_memory.mcp.tools.list_directory import list_directory
@@ -33,6 +35,7 @@ from basic_memory.mcp.tools.schema import schema_validate, schema_infer, schema_
__all__ = [
"build_context",
"canvas",
"cloud_info",
"create_memory_project",
"delete_note",
"delete_project",
@@ -43,6 +46,7 @@ __all__ = [
"move_note",
"read_content",
"read_note",
"release_notes",
"recent_activity",
"schema_diff",
"schema_infer",
+12
View File
@@ -0,0 +1,12 @@
"""Cloud information MCP tool."""
from pathlib import Path
from basic_memory.mcp.server import mcp
@mcp.tool("cloud_info")
def cloud_info() -> str:
"""Return optional Basic Memory Cloud information and setup guidance."""
content_path = Path(__file__).parent.parent / "resources" / "cloud_info.md"
return content_path.read_text(encoding="utf-8")
@@ -0,0 +1,12 @@
"""Release notes MCP tool."""
from pathlib import Path
from basic_memory.mcp.server import mcp
@mcp.tool("release_notes")
def release_notes() -> str:
"""Return the latest product release notes for optional user review."""
content_path = Path(__file__).parent.parent / "resources" / "release_notes.md"
return content_path.read_text(encoding="utf-8")
+202
View File
@@ -0,0 +1,202 @@
"""Tests for CLI cloud promo messaging."""
from typer.testing import CliRunner
from basic_memory.cli.app import app
from basic_memory.cli.promo import CLOUD_PROMO_VERSION, maybe_show_cloud_promo
from basic_memory.config import ConfigManager
def test_first_run_shows_intro_message_and_persists_flags():
messages: list[str] = []
maybe_show_cloud_promo(
"status",
config_manager=ConfigManager(),
is_interactive=True,
echo=messages.append,
)
assert len(messages) == 1
assert "Basic Memory initialized (local mode)." in messages[0]
assert "{{OSS_DISCOUNT_CODE}}" in messages[0]
config = ConfigManager().load_config()
assert config.cloud_promo_first_run_shown is True
assert config.cloud_promo_last_version_shown == CLOUD_PROMO_VERSION
def test_version_notice_shows_when_promo_version_changes():
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_promo_first_run_shown = True
config.cloud_promo_last_version_shown = "2025-01-01"
config_manager.save_config(config)
messages: list[str] = []
maybe_show_cloud_promo(
"status",
config_manager=config_manager,
is_interactive=True,
echo=messages.append,
)
assert len(messages) == 1
assert messages[0].startswith("New in Basic Memory Cloud")
def test_no_message_when_already_shown_for_current_version():
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_promo_first_run_shown = True
config.cloud_promo_last_version_shown = CLOUD_PROMO_VERSION
config_manager.save_config(config)
messages: list[str] = []
maybe_show_cloud_promo(
"status",
config_manager=config_manager,
is_interactive=True,
echo=messages.append,
)
assert messages == []
def test_no_message_when_cloud_mode_enabled():
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_mode = True
config_manager.save_config(config)
messages: list[str] = []
maybe_show_cloud_promo(
"status",
config_manager=config_manager,
is_interactive=True,
echo=messages.append,
)
assert messages == []
def test_no_message_when_user_opted_out():
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_promo_opt_out = True
config_manager.save_config(config)
messages: list[str] = []
maybe_show_cloud_promo(
"status",
config_manager=config_manager,
is_interactive=True,
echo=messages.append,
)
assert messages == []
def test_no_message_for_mcp_subcommand():
messages: list[str] = []
maybe_show_cloud_promo(
"mcp",
config_manager=ConfigManager(),
is_interactive=True,
echo=messages.append,
)
assert messages == []
def test_no_message_when_env_disables_promos(monkeypatch):
monkeypatch.setenv("BASIC_MEMORY_NO_PROMOS", "1")
messages: list[str] = []
maybe_show_cloud_promo(
"status",
config_manager=ConfigManager(),
is_interactive=True,
echo=messages.append,
)
assert messages == []
def test_no_message_when_not_interactive():
messages: list[str] = []
maybe_show_cloud_promo(
"status",
config_manager=ConfigManager(),
is_interactive=False,
echo=messages.append,
)
assert messages == []
def test_cloud_promo_command_off_sets_opt_out(monkeypatch):
runner = CliRunner()
instances: list[object] = []
class _StubConfig:
cloud_promo_opt_out = False
class _StubConfigManager:
def __init__(self):
self._config = _StubConfig()
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", "promo", "--off"])
assert result.exit_code == 0
assert "Cloud promo messages disabled" in result.stdout
assert len(instances) == 1
assert instances[0].saved_config.cloud_promo_opt_out is True
def test_cloud_promo_command_on_clears_opt_out(monkeypatch):
runner = CliRunner()
instances: list[object] = []
class _StubConfig:
cloud_promo_opt_out = True
class _StubConfigManager:
def __init__(self):
self._config = _StubConfig()
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", "promo", "--on"])
assert result.exit_code == 0
assert "Cloud promo messages enabled" in result.stdout
assert len(instances) == 1
assert instances[0].saved_config.cloud_promo_opt_out is False
+20
View File
@@ -0,0 +1,20 @@
"""Tests for cloud discovery MCP tools."""
from basic_memory.mcp.tools import cloud_info, release_notes
def test_cloud_info_tool_returns_expected_copy():
result = cloud_info.fn()
assert "# Basic Memory Cloud (optional)" in result
assert "{{OSS_DISCOUNT_CODE}}" in result
assert "bm cloud login" in result
def test_release_notes_tool_returns_expected_copy():
result = release_notes.fn()
assert "# Release Notes" in result
assert "2026-02-06" in result
assert "{{OSS_DISCOUNT_CODE}}" in result
assert "bm cloud login" in result
+4
View File
@@ -10,6 +10,7 @@ from basic_memory.mcp import tools
EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
"build_context": ["url", "project", "depth", "timeframe", "page", "page_size", "max_related"],
"canvas": ["nodes", "edges", "title", "directory", "project"],
"cloud_info": [],
"create_memory_project": ["project_name", "project_path", "set_default"],
"delete_note": ["identifier", "is_directory", "project"],
"delete_project": ["project_name"],
@@ -28,6 +29,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
"move_note": ["identifier", "destination_path", "is_directory", "project"],
"read_content": ["path", "project"],
"read_note": ["identifier", "project", "page", "page_size"],
"release_notes": [],
"recent_activity": ["type", "depth", "timeframe", "project"],
"search": ["query"],
"search_by_metadata": ["filters", "project", "limit", "offset"],
@@ -52,6 +54,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
TOOL_FUNCTIONS: dict[str, object] = {
"build_context": tools.build_context,
"canvas": tools.canvas,
"cloud_info": tools.cloud_info,
"create_memory_project": tools.create_memory_project,
"delete_note": tools.delete_note,
"delete_project": tools.delete_project,
@@ -62,6 +65,7 @@ TOOL_FUNCTIONS: dict[str, object] = {
"move_note": tools.move_note,
"read_content": tools.read_content,
"read_note": tools.read_note,
"release_notes": tools.release_notes,
"recent_activity": tools.recent_activity,
"search": tools.search,
"search_by_metadata": tools.search_by_metadata,