mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: Add cloud discovery touchpoints to CLI and MCP (#546)
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user