chore: import upstream snapshot with attribution
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:03:45 +08:00
commit 555e282cc4
1802 changed files with 338080 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
VENV := .venv
PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
.PHONY: install dev lint format test build clean publish publish-test shell
$(VENV)/bin/activate:
python3 -m venv $(VENV)
$(PIP) install -U pip
install: $(VENV)/bin/activate
$(PIP) install -e .
dev: $(VENV)/bin/activate
$(PIP) install -e ".[dev]"
lint: dev
$(VENV)/bin/ruff check .
$(VENV)/bin/ruff format --check .
format: dev
$(VENV)/bin/ruff check --fix .
$(VENV)/bin/ruff format .
test: dev
$(VENV)/bin/pytest
build: clean $(VENV)/bin/activate
$(PIP) install hatch
$(VENV)/bin/hatch build
clean:
rm -rf dist/
publish: build
$(VENV)/bin/hatch publish
publish-test: build
$(VENV)/bin/hatch publish --repo test
shell: $(VENV)/bin/activate
@echo "Spawning a new shell with the virtual environment activated..."
@VIRTUAL_ENV=$(CURDIR)/$(VENV) PATH=$(CURDIR)/$(VENV)/bin:$$PATH exec $(SHELL)
+349
View File
@@ -0,0 +1,349 @@
# mem0 CLI (Python)
The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents. Python implementation.
> **Built for AI agents.** Pass `--agent` (or `--json`) as a global flag on any command to get structured JSON output optimized for programmatic consumption — sanitized fields, no colors or spinners, and errors as JSON too.
## Prerequisites
- Python **3.10+**
## Installation
### Using pipx (recommended)
```bash
pipx install mem0-cli
```
### Using pip
```bash
pip install mem0-cli
```
> **Note:** On macOS with Homebrew Python, `pip install` outside a virtual environment will fail with an `externally-managed-environment` error ([PEP 668](https://peps.python.org/pep-0668/)). Use `pipx` instead, or install inside a virtual environment.
## Quick start
```bash
# Interactive setup wizard
mem0 init
# Or login via email
mem0 init --email alice@company.com
# Or authenticate with an existing API key
mem0 init --api-key m0-xxx
# Add a memory
mem0 add "I prefer dark mode and use vim keybindings" --user-id alice
# Search memories
mem0 search "What are Alice's preferences?" --user-id alice
# List all memories for a user
mem0 list --user-id alice
# Get a specific memory
mem0 get <memory-id>
# Update a memory
mem0 update <memory-id> "I switched to light mode"
# Delete a memory
mem0 delete <memory-id>
```
## Commands
### `mem0 init`
Interactive setup wizard. Prompts for your API key and default user ID.
```bash
mem0 init
mem0 init --api-key m0-xxx --user-id alice
mem0 init --email alice@company.com
```
If an existing configuration is detected, the CLI asks for confirmation before overwriting. Use `--force` to skip the prompt (useful in CI/CD).
```bash
mem0 init --api-key m0-xxx --user-id alice --force
```
| Flag | Description |
|------|-------------|
| `--api-key` | API key (skip prompt) |
| `-u, --user-id` | Default user ID (skip prompt) |
| `--email` | Login via email verification code |
| `--code` | Verification code (use with `--email` for non-interactive login) |
| `--force` | Overwrite existing config without confirmation |
### `mem0 add`
Add a memory from text, a JSON messages array, a file, or stdin.
```bash
mem0 add "I prefer dark mode" --user-id alice
mem0 add --file conversation.json --user-id alice
echo "Loves hiking on weekends" | mem0 add --user-id alice
```
| Flag | Description |
|------|-------------|
| `-u, --user-id` | Scope to a user |
| `--agent-id` | Scope to an agent |
| `--messages` | Conversation messages as JSON |
| `-f, --file` | Read messages from a JSON file |
| `-m, --metadata` | Custom metadata as JSON |
| `--categories` | Categories (JSON array or comma-separated) |
| `--graph / --no-graph` | Enable or disable graph memory extraction |
| `-o, --output` | Output format: `text`, `json`, `quiet` |
### `mem0 search`
Search memories using natural language.
```bash
mem0 search "dietary restrictions" --user-id alice
mem0 search "preferred tools" --user-id alice --output json --top-k 5
```
| Flag | Description |
|------|-------------|
| `-u, --user-id` | Filter by user |
| `-k, --top-k` | Number of results (default: 10) |
| `--threshold` | Minimum similarity score (default: 0.3) |
| `--rerank` | Enable reranking |
| `--keyword` | Use keyword search instead of semantic |
| `--filter` | Advanced filter expression (JSON) |
| `--graph / --no-graph` | Enable or disable graph in search |
| `-o, --output` | Output format: `text`, `json`, `table` |
### `mem0 list`
List memories with optional filters and pagination.
```bash
mem0 list --user-id alice
mem0 list --user-id alice --category preferences --output json
mem0 list --user-id alice --after 2024-01-01 --page-size 50
```
| Flag | Description |
|------|-------------|
| `-u, --user-id` | Filter by user |
| `--page` | Page number (default: 1) |
| `--page-size` | Results per page (default: 100) |
| `--category` | Filter by category |
| `--after` | Created after date (YYYY-MM-DD) |
| `--before` | Created before date (YYYY-MM-DD) |
| `-o, --output` | Output format: `text`, `json`, `table` |
### `mem0 get`
Retrieve a specific memory by ID.
```bash
mem0 get 7b3c1a2e-4d5f-6789-abcd-ef0123456789
mem0 get 7b3c1a2e-4d5f-6789-abcd-ef0123456789 --output json
```
### `mem0 update`
Update the text or metadata of an existing memory.
```bash
mem0 update <memory-id> "Updated preference text"
mem0 update <memory-id> --metadata '{"priority": "high"}'
echo "new text" | mem0 update <memory-id>
```
### `mem0 delete`
Delete a single memory, all memories for a scope, or an entire entity.
```bash
# Delete a single memory
mem0 delete <memory-id>
# Delete all memories for a user
mem0 delete --all --user-id alice --force
# Delete all memories project-wide
mem0 delete --all --project --force
# Preview what would be deleted
mem0 delete --all --user-id alice --dry-run
```
| Flag | Description |
|------|-------------|
| `--all` | Delete all memories matching scope filters |
| `--entity` | Delete the entity and all its memories |
| `--project` | With `--all`: delete all memories project-wide |
| `--dry-run` | Preview without deleting |
| `--force` | Skip confirmation prompt |
### `mem0 import`
Bulk import memories from a JSON file.
```bash
mem0 import data.json --user-id alice
```
The file should be a JSON array where each item has a `memory` (or `text` or `content`) field and optional `user_id`, `agent_id`, and `metadata` fields.
### `mem0 config`
View or modify the local CLI configuration.
```bash
mem0 config show # Display current config (secrets redacted)
mem0 config get api_key # Get a specific value
mem0 config set user_id bob # Set a value
```
### `mem0 entity`
List or delete entities (users, agents, apps, runs).
```bash
mem0 entity list users
mem0 entity list agents --output json
mem0 entity delete --user-id alice --force
```
### `mem0 event`
Inspect background processing events created by async operations (e.g. bulk deletes, large add jobs).
```bash
# List recent events
mem0 event list
# Check the status of a specific event
mem0 event status <event-id>
```
| Flag | Description |
|------|-------------|
| `-o, --output` | Output format: `text`, `json` |
### `mem0 status`
Verify your API connection and display the current project.
```bash
mem0 status
```
### `mem0 version`
Print the CLI version.
```bash
mem0 version
```
## Agent mode
Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops:
```bash
mem0 --agent search "user preferences" --user-id alice
mem0 --agent add "User prefers dark mode" --user-id alice
mem0 --agent list --user-id alice
mem0 --agent delete --all --user-id alice --force
```
Every command returns the same envelope shape:
```json
{
"status": "success",
"command": "search",
"duration_ms": 134,
"scope": { "user_id": "alice" },
"count": 2,
"data": [
{ "id": "abc-123", "memory": "User prefers dark mode", "score": 0.97, "created_at": "2026-01-15", "categories": ["preferences"] }
]
}
```
What agent mode does differently from `--output json`:
- **Sanitized `data`**: only the fields an agent needs (id, memory, score, etc.) — no internal API noise
- **No human output**: spinners, colors, and banners are suppressed entirely
- **Errors as JSON**: errors go to stdout as `{"status": "error", "command": "...", "error": "..."}` with a non-zero exit code
Use `mem0 help --json` to get the full command tree as JSON — useful for agents that need to self-discover available commands.
## Output formats
Control how results are displayed with `--output`:
| Format | Description |
|--------|-------------|
| `text` | Human-readable with colors and formatting (default) |
| `json` | Structured JSON for piping to `jq` (raw API response) |
| `table` | Tabular format (default for `list`) |
| `quiet` | Minimal — just IDs or status codes |
| `agent` | Structured JSON envelope with sanitized fields (set by `--agent`/`--json`) |
## Global flags
These flags are available on all commands:
| Flag | Description |
|------|-------------|
| `--json` | Enable agent mode: structured JSON envelope output, no colors or spinners |
| `--agent` | Alias for `--json` |
| `--api-key` | Override the configured API key for this request |
| `--base-url` | Override the configured API base URL for this request |
| `-o, --output` | Set the output format |
## Environment variables
| Variable | Description |
|----------|-------------|
| `MEM0_API_KEY` | API key (overrides config file) |
| `MEM0_BASE_URL` | API base URL |
| `MEM0_USER_ID` | Default user ID |
| `MEM0_AGENT_ID` | Default agent ID |
| `MEM0_APP_ID` | Default app ID |
| `MEM0_RUN_ID` | Default run ID |
| `MEM0_ENABLE_GRAPH` | Enable graph memory (`true` / `false`) |
Environment variables take precedence over values in the config file, which take precedence over defaults.
## Development
```bash
cd cli/python
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
# Run during development
python -m mem0_cli --help
mem0 add "test memory" --user-id alice
```
## Releasing
1. Update `version` in `pyproject.toml`
2. Create a GitHub Release with tag `cli-v<version>` (e.g. `cli-v0.2.1`)
For a pre-release, use a beta version like `0.2.1b1` and check the **pre-release** checkbox.
## Documentation
Full documentation is available at [docs.mem0.ai/platform/cli](https://docs.mem0.ai/platform/cli).
## License
Apache-2.0
+107
View File
@@ -0,0 +1,107 @@
# Development
## Prerequisites
- Python **3.10+**
- `make` (optional — you can use plain Python commands instead)
All commands below should be run from the `python/` directory:
```bash
cd python
```
## Setup
### Using Make (recommended)
All `make` targets automatically create a virtual environment (`.venv/`) and install the required dependencies — no manual setup needed.
```bash
# Install the CLI in editable mode
make install
# Install with dev tools (tests + linting)
make dev
```
### Using Python directly
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -U pip
# Install in editable mode
pip install -e .
# With dev tools
pip install -e ".[dev]"
```
## Make targets
| Target | Description |
| ------------------- | ------------------------------------------------ |
| `make install` | Create venv and install the CLI (editable mode) |
| `make dev` | Create venv and install CLI + dev dependencies |
| `make test` | Run all tests (installs dev deps if needed) |
| `make lint` | Run linter and format check |
| `make format` | Auto-fix lint issues and format code |
| `make build` | Build distribution packages |
| `make clean` | Remove `dist/` |
| `make publish` | Build and publish to PyPI |
| `make publish-test` | Build and publish to Test PyPI |
| `make shell` | Open a new shell with the venv activated |
## Run tests
```bash
# Using Make
make test
# Using Python directly
pytest
# Run a specific test file
pytest tests/test_cli_integration.py
# Run a single test
pytest -k test_help
```
## Run the CLI
```bash
# Using Make — drop into an activated shell
make shell
mem0 --help
# Using Python directly (with venv activated)
source .venv/bin/activate
mem0 --help
mem0 version
# Or run without activating
.venv/bin/mem0 --help
```
## Lint
```bash
# Using Make
make lint # check only
make format # auto-fix
# Using Python directly (with venv activated)
ruff check .
ruff format .
```
## Optional extras
### OSS integration
```bash
pip install -e ".[oss]"
```
+77
View File
@@ -0,0 +1,77 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mem0-cli"
version = "0.2.9"
description = "The official CLI for mem0 — the memory layer for AI agents"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.10"
authors = [
{ name = "mem0.ai", email = "founders@mem0.ai" },
]
keywords = ["mem0", "memory", "ai", "agents", "cli"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries",
]
dependencies = [
"typer>=0.9.0",
"rich>=13.0.0",
"httpx>=0.24.0",
]
[project.optional-dependencies]
oss = ["mem0ai>=0.1.0"]
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.21",
"ruff>=0.1.0",
]
[project.scripts]
mem0 = "mem0_cli.app:main"
[tool.hatch.build.targets.wheel]
packages = ["src/mem0_cli"]
[tool.hatch.build.targets.sdist]
include = ["src/mem0_cli"]
[tool.ruff]
target-version = "py310"
line-length = 100
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort (import sorting)
"W", # pycodestyle warnings
"UP", # pyupgrade (modern Python syntax)
"B", # flake8-bugbear (common bugs)
"SIM", # flake8-simplify
"RUF", # ruff-specific rules
]
ignore = [
"E501", # line too long — handled by formatter
"B008", # function call in default arg — required by Typer's Option/Argument pattern
"SIM108", # ternary operator — sometimes less readable
]
[tool.ruff.lint.isort]
known-first-party = ["mem0_cli"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
docstring-code-format = true
+3
View File
@@ -0,0 +1,3 @@
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
__version__ = "0.2.9"
+5
View File
@@ -0,0 +1,5 @@
"""Allow running with `python -m mem0_cli`."""
from mem0_cli.app import main
main()
+36
View File
@@ -0,0 +1,36 @@
"""Detect whether the CLI is being invoked from inside an AI-agent context.
Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an
agent runtime env var is present. The return value is a context **trigger
only** — the canonical agent identity is self-declared by the agent via
``--agent-caller <name>`` (Proof Editor-style) and never sniffed from env
vars to fill the ``agent_caller`` field on the APIKey row.
Returns a short name or None. The list is curated, not exhaustive — env
vars we don't recognise fall through to None (caller treated as
non-agent). Honest reporting depends on ``--agent-caller``; this list is
just enough to enable the zero-friction auto-bootstrap UX.
"""
from __future__ import annotations
import os
_AGENT_CALLER_ENV: tuple[tuple[str, tuple[str, ...]], ...] = (
("claude-code", ("CLAUDECODE", "CLAUDE_CODE")),
("cursor", ("CURSOR_AGENT", "CURSOR_SESSION_ID")),
("codex", ("CODEX_CLI", "OPENAI_CODEX")),
("cline", ("CLINE_AGENT", "CLINE")),
("continue", ("CONTINUE_AGENT", "CONTINUE_SESSION")),
("aider", ("AIDER_SESSION",)),
("goose", ("GOOSE_AGENT",)),
("windsurf", ("WINDSURF_AGENT",)),
)
def detect_agent_caller() -> str | None:
"""Return a canonical agent name if any agent env var is set, else None."""
for name, env_vars in _AGENT_CALLER_ENV:
if any(os.environ.get(v) for v in env_vars):
return name
return None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
"""Backend abstraction layer for mem0 CLI."""
from mem0_cli.backend.base import Backend, get_backend
__all__ = ["Backend", "get_backend"]
+115
View File
@@ -0,0 +1,115 @@
"""Abstract backend interface and factory."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
from mem0_cli.config import Mem0Config
class Backend(ABC):
"""Abstract interface for mem0 backends."""
@abstractmethod
def add(
self,
content: str | None = None,
messages: list[dict] | None = None,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
metadata: dict | None = None,
immutable: bool = False,
infer: bool = True,
expires: str | None = None,
categories: list[str] | None = None,
) -> dict: ...
@abstractmethod
def search(
self,
query: str,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
top_k: int = 10,
threshold: float = 0.3,
rerank: bool = False,
keyword: bool = False,
filters: dict | None = None,
fields: list[str] | None = None,
) -> list[dict]: ...
@abstractmethod
def get(self, memory_id: str) -> dict: ...
@abstractmethod
def list_memories(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
page: int = 1,
page_size: int = 100,
category: str | None = None,
after: str | None = None,
before: str | None = None,
) -> list[dict]: ...
@abstractmethod
def update(
self, memory_id: str, content: str | None = None, metadata: dict | None = None
) -> dict: ...
@abstractmethod
def delete(
self,
memory_id: str | None = None,
*,
all: bool = False,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
) -> dict: ...
@abstractmethod
def delete_entities(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
) -> dict: ...
@abstractmethod
def status(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
) -> dict[str, Any]: ...
@abstractmethod
def entities(self, entity_type: str) -> list[dict]: ...
@abstractmethod
def list_events(self) -> list[dict]: ...
@abstractmethod
def get_event(self, event_id: str) -> dict: ...
def get_backend(config: Mem0Config) -> Backend:
"""Return the Platform backend."""
from mem0_cli.backend.platform import PlatformBackend
return PlatformBackend(config.platform)
+382
View File
@@ -0,0 +1,382 @@
"""Platform (SaaS) backend — communicates with api.mem0.ai."""
from __future__ import annotations
from typing import Any
from urllib.parse import quote
import httpx
from mem0_cli import __version__
from mem0_cli.backend.base import Backend
from mem0_cli.config import PlatformConfig
def _encode_path_segment(value: Any) -> str:
return quote(str(value), safe="")
class PlatformBackend(Backend):
"""Backend that talks to the mem0 Platform API."""
def __init__(self, config: PlatformConfig) -> None:
self.config = config
self.base_url = config.base_url.rstrip("/")
self._client = httpx.Client(
base_url=self.base_url,
headers={
"Authorization": f"Token {config.api_key}",
"Content-Type": "application/json",
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "python",
"X-Mem0-Client-Version": __version__,
},
timeout=30.0,
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
from mem0_cli.state import capture_notice, is_agent_mode
self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user"
resp = self._client.request(method, path, **kwargs)
if resp.status_code == 401:
raise AuthError("Authentication failed. Your API key may be invalid or expired.")
if resp.status_code == 404:
raise NotFoundError(f"Resource not found: {path}")
if resp.status_code == 400:
# Extract API error detail when available
try:
detail = resp.json().get("detail", resp.text)
except Exception:
detail = resp.text
raise APIError(f"Bad request to {path}: {detail}")
resp.raise_for_status()
if resp.status_code == 204:
return {}
data = resp.json()
# Pull the unclaimed-Agent-Mode notice out of the body (or the header
# fallback for endpoints that return non-dict / non-dict-leading
# payloads) and stash it for end-of-command surfacing.
notice = None
if isinstance(data, dict) and "mem0_notice" in data:
notice = data.pop("mem0_notice")
elif (
isinstance(data, list)
and data
and isinstance(data[0], dict)
and "mem0_notice" in data[0]
):
notice = data[0].pop("mem0_notice")
if notice is None:
notice = resp.headers.get("X-Mem0-Notice-Message") or None
capture_notice(notice)
return data
def add(
self,
content: str | None = None,
messages: list[dict] | None = None,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
metadata: dict | None = None,
immutable: bool = False,
infer: bool = True,
expires: str | None = None,
categories: list[str] | None = None,
) -> dict:
payload: dict[str, Any] = {}
if messages:
payload["messages"] = messages
elif content:
payload["messages"] = [{"role": "user", "content": content}]
if user_id:
payload["user_id"] = user_id
if agent_id:
payload["agent_id"] = agent_id
if app_id:
payload["app_id"] = app_id
if run_id:
payload["run_id"] = run_id
if metadata:
payload["metadata"] = metadata
if immutable:
payload["immutable"] = True
if not infer:
payload["infer"] = False
if expires:
payload["expiration_date"] = expires
if categories:
payload["categories"] = categories
payload["source"] = "CLI"
return self._request("POST", "/v3/memories/add/", json=payload)
def _build_filters(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
extra_filters: dict | None = None,
) -> dict | None:
"""Build a filters dict for v3 API endpoints.
Entity IDs are ANDed (all provided IDs must match).
Extra filters (date ranges, categories) are also ANDed.
"""
# If caller passed a pre-built filter structure (e.g. --filter from CLI), use it directly
if extra_filters and ("AND" in extra_filters or "OR" in extra_filters):
return extra_filters
# Build AND conditions for entity IDs
and_conditions: list[dict[str, Any]] = []
if user_id:
and_conditions.append({"user_id": user_id})
if agent_id:
and_conditions.append({"agent_id": agent_id})
if app_id:
and_conditions.append({"app_id": app_id})
if run_id:
and_conditions.append({"run_id": run_id})
# Append any extra filters (dates, categories)
if extra_filters:
for k, v in extra_filters.items():
and_conditions.append({k: v})
if len(and_conditions) == 1:
return and_conditions[0]
elif and_conditions:
return {"AND": and_conditions}
else:
return None
def search(
self,
query: str,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
top_k: int = 10,
threshold: float = 0.3,
rerank: bool = False,
keyword: bool = False,
filters: dict | None = None,
fields: list[str] | None = None,
) -> list[dict]:
payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold}
api_filters = self._build_filters(
user_id=user_id,
agent_id=agent_id,
app_id=app_id,
run_id=run_id,
extra_filters=filters,
)
if api_filters:
payload["filters"] = api_filters
if rerank:
payload["rerank"] = True
if keyword:
payload["keyword_search"] = True
if fields:
payload["fields"] = fields
payload["source"] = "CLI"
result = self._request("POST", "/v3/memories/search/", json=payload)
return (
result
if isinstance(result, list)
else result.get("results", result.get("memories", []))
)
def get(self, memory_id: str) -> dict:
return self._request(
"GET",
f"/v1/memories/{_encode_path_segment(memory_id)}/",
params={"source": "CLI"},
)
def list_memories(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
page: int = 1,
page_size: int = 100,
category: str | None = None,
after: str | None = None,
before: str | None = None,
) -> list[dict]:
payload: dict[str, Any] = {}
params = {"page": str(page), "page_size": str(page_size)}
# Build filters — entity IDs and date filters go inside "filters"
extra: dict[str, Any] = {}
if category:
extra["categories"] = {"contains": category}
if after:
extra["created_at"] = {**(extra.get("created_at", {})), "gte": after}
if before:
extra["created_at"] = {**(extra.get("created_at", {})), "lte": before}
api_filters = self._build_filters(
user_id=user_id,
agent_id=agent_id,
app_id=app_id,
run_id=run_id,
extra_filters=extra if extra else None,
)
if api_filters:
payload["filters"] = api_filters
payload["source"] = "CLI"
result = self._request("POST", "/v3/memories/", json=payload, params=params)
return (
result
if isinstance(result, list)
else result.get("results", result.get("memories", []))
)
def update(
self, memory_id: str, content: str | None = None, metadata: dict | None = None
) -> dict:
payload: dict[str, Any] = {}
if content:
payload["text"] = content
if metadata:
payload["metadata"] = metadata
payload["source"] = "CLI"
return self._request(
"PUT",
f"/v1/memories/{_encode_path_segment(memory_id)}/",
json=payload,
)
def delete(
self,
memory_id: str | None = None,
*,
all: bool = False,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
) -> dict:
if all:
params: dict[str, str] = {"source": "CLI"}
if user_id:
params["user_id"] = user_id
if agent_id:
params["agent_id"] = agent_id
if app_id:
params["app_id"] = app_id
if run_id:
params["run_id"] = run_id
return self._request("DELETE", "/v1/memories/", params=params)
elif memory_id:
return self._request(
"DELETE",
f"/v1/memories/{_encode_path_segment(memory_id)}/",
params={"source": "CLI"},
)
else:
raise ValueError("Either memory_id or --all is required")
def delete_entities(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
) -> dict:
# v2 endpoint: DELETE /v2/entities/{entity_type}/{entity_id}/
type_map = {
"user": user_id,
"agent": agent_id,
"app": app_id,
"run": run_id,
}
entities = {t: v for t, v in type_map.items() if v}
if not entities:
raise ValueError("At least one entity ID is required for delete_entities.")
# Delete each provided entity via the v2 path-based endpoint. Key each
# response by entity type so a multi-entity delete (e.g. --user-id and
# --agent-id together) doesn't discard everything but the last result.
results: dict = {}
for entity_type, entity_id in entities.items():
results[entity_type] = self._request(
"DELETE",
f"/v2/entities/{_encode_path_segment(entity_type)}/{_encode_path_segment(entity_id)}/",
params={"source": "CLI"},
)
return results
def ping(self, timeout: float | None = None) -> dict:
"""Call the ping endpoint and return the raw response.
When *timeout* is given it overrides the client-level timeout so that
validation pings can fail fast without blocking the user.
"""
if timeout is not None:
resp = self._client.get("/v1/ping/", timeout=timeout)
if resp.status_code == 401:
raise AuthError("Authentication failed. Your API key may be invalid or expired.")
resp.raise_for_status()
return resp.json()
return self._request("GET", "/v1/ping/")
def status(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
) -> dict[str, Any]:
"""Check connectivity using the ping endpoint."""
try:
self.ping()
return {"connected": True, "backend": "platform", "base_url": self.base_url}
except Exception as e:
return {"connected": False, "backend": "platform", "error": str(e)}
def entities(self, entity_type: str) -> list[dict]:
result = self._request("GET", "/v1/entities/")
items = result if isinstance(result, list) else result.get("results", [])
# Filter by entity type client-side (API returns all types)
type_map = {"users": "user", "agents": "agent", "apps": "app", "runs": "run"}
target_type = type_map.get(entity_type)
if target_type:
items = [e for e in items if e.get("type", "").lower() == target_type]
return items
def list_events(self) -> list[dict]:
result = self._request("GET", "/v1/events/")
return result if isinstance(result, list) else result.get("results", [])
def get_event(self, event_id: str) -> dict:
return self._request("GET", f"/v1/event/{_encode_path_segment(event_id)}/")
class AuthError(Exception):
pass
class NotFoundError(Exception):
pass
class APIError(Exception):
pass
+178
View File
@@ -0,0 +1,178 @@
"""Branding and ASCII art for mem0 CLI."""
import os
import sys
import time
from contextlib import contextmanager
from rich.console import Console
from rich.panel import Panel
from rich.status import Status
from rich.text import Text
# stderr console for spinners, errors, and timing messages
_err = Console(stderr=True)
LOGO = r"""
███╗ ███╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ ██╗
████╗ ████║██╔════╝████╗ ████║██╔═████╗ ██╔════╝██║ ██║
██╔████╔██║█████╗ ██╔████╔██║██║██╔██║ ██║ ██║ ██║
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║████╔╝██║ ██║ ██║ ██║
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝ ╚██████╗███████╗██║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝
"""
LOGO_MINI = "◆ mem0"
TAGLINE = "The Memory Layer for AI Agents"
BRAND_COLOR = "#8b5cf6" # Purple
ACCENT_COLOR = "#a78bfa"
SUCCESS_COLOR = "#22c55e"
ERROR_COLOR = "#ef4444"
WARNING_COLOR = "#f59e0b"
DIM_COLOR = "#6b7280"
def _sym(fancy: str, plain: str) -> str:
"""Return *fancy* when stdout is a TTY with colour, else *plain*."""
if not sys.stdout.isatty() or os.environ.get("NO_COLOR") is not None:
return plain
return fancy
def print_banner(console: Console) -> None:
"""Print the mem0 welcome banner."""
from mem0_cli.state import is_agent_mode
if is_agent_mode():
return
logo_text = Text(LOGO, style=f"bold {BRAND_COLOR}")
tagline = Text(f" {TAGLINE}\n", style=f"{ACCENT_COLOR}")
content = Text()
content.append_text(logo_text)
content.append_text(tagline)
panel = Panel(
content,
border_style=BRAND_COLOR,
padding=(0, 2),
subtitle=f"[{DIM_COLOR}]Python SDK · v{_get_version()}[/]",
subtitle_align="right",
)
console.print(panel)
def print_success(console: Console, message: str) -> None:
from mem0_cli.state import is_agent_mode
if is_agent_mode():
return
sym = _sym("", "[ok]")
console.print(f"[{SUCCESS_COLOR}]{sym}[/] {message}")
def print_error(console: Console, message: str, hint: str | None = None) -> None:
from mem0_cli.state import get_current_command, is_agent_mode
if is_agent_mode():
import json as _json
envelope = {
"status": "error",
"command": get_current_command(),
"error": message,
"data": None,
}
print(_json.dumps(envelope))
return
from rich.markup import escape
sym = _sym("", "[error]")
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {escape(str(message))}")
if hint:
console.print(f" [{DIM_COLOR}]{escape(str(hint))}[/]")
def print_warning(console: Console, message: str) -> None:
from mem0_cli.state import is_agent_mode
if is_agent_mode():
return
sym = _sym("", "[warn]")
console.print(f"[{WARNING_COLOR}]{sym}[/] {message}")
def print_info(console: Console, message: str) -> None:
from mem0_cli.state import is_agent_mode
if is_agent_mode():
return
sym = _sym("", "*")
console.print(f"[{BRAND_COLOR}]{sym}[/] {message}")
@contextmanager
def timed_status(console: Console, message: str):
"""Spinner with automatic timing. Yields a context object for setting the final message.
The spinner and timing output are sent to stderr (via ``_err``) so they
never contaminate machine-readable stdout. The *console* parameter is
kept for backward compatibility but is not used for spinner output.
In agent mode the spinner is suppressed entirely.
"""
from mem0_cli.state import is_agent_mode
class _Ctx:
def __init__(self):
self.success_msg = ""
self.error_msg = ""
ctx = _Ctx()
if is_agent_mode():
try:
yield ctx
except Exception:
raise
return
start = time.perf_counter()
try:
with Status(f"[{DIM_COLOR}]{message}[/]", console=_err):
yield ctx
except Exception:
elapsed = time.perf_counter() - start
if ctx.error_msg:
print_error(_err, f"{ctx.error_msg} ({elapsed:.2f}s)")
if "Authentication failed" in ctx.error_msg:
_err.print(
f" [{DIM_COLOR}]Run [bold]mem0 init[/bold] to reconfigure your API key"
f" · [bold]https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python[/bold][/]"
)
raise
else:
elapsed = time.perf_counter() - start
if ctx.success_msg:
print_success(_err, f"{ctx.success_msg} ({elapsed:.2f}s)")
def print_scope(console: Console, **ids: str | None) -> None:
"""Show active entity scope if any IDs are set."""
from mem0_cli.state import is_agent_mode
if is_agent_mode():
return
parts = []
for key, val in ids.items():
if val:
parts.append(f"{key}={val}")
if parts:
scope_str = ", ".join(parts)
console.print(f" [{DIM_COLOR}]Scope: {scope_str}[/]")
def _get_version() -> str:
from mem0_cli import __version__
return __version__
@@ -0,0 +1 @@
"""CLI command modules."""
@@ -0,0 +1,239 @@
"""Agent Mode commands — bootstrap (unattended signup) and claim (OTP-based human upgrade)."""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
from typing import Any
import httpx
import typer
from rich.console import Console
from rich.prompt import Prompt
from mem0_cli.branding import (
BRAND_COLOR,
DIM_COLOR,
print_error,
print_success,
)
from mem0_cli.config import Mem0Config, save_config
console = Console()
err_console = Console(stderr=True)
_SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "python",
}
def _validate_envelope(envelope: Any) -> None:
"""Defend against partial/malformed backend responses.
A backend regression that returns ``{"api_key": null}`` would otherwise be
silently persisted, producing confusing downstream errors far from the
source. Fail fast with a clear message if the required fields are missing.
"""
if not isinstance(envelope, dict):
print_error(err_console, "Bootstrap response was not a JSON object.")
raise typer.Exit(1)
for field in ("api_key", "default_user_id"):
value = envelope.get(field)
if not isinstance(value, str) or not value:
print_error(
err_console,
f"Bootstrap response missing required field {field!r} — please update the CLI.",
)
raise typer.Exit(1)
def bootstrap_via_backend(
config: Mem0Config,
*,
source: str | None = None,
agent_caller: str | None = None,
) -> None:
"""POST /api/v1/auth/agent_mode/ and mutate config in place.
Args:
config: Mem0Config mutated in place with the new platform values.
source: ``--source`` flag passthrough (analytics tag, free-form).
agent_caller: Self-declared agent identity passed via ``--agent-caller``
(e.g. ``claude-code``, ``cursor``). May be None when the caller
omitted the flag; the agent can backfill later via
``mem0 identify <name>``. Sent to the backend in the request body
and saved into ``platform.agent_caller`` for local introspection.
Raises typer.Exit(1) on failure.
"""
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
body: dict[str, Any] = {}
if source:
body["source"] = source
if agent_caller:
body["agent_caller"] = agent_caller
try:
with httpx.Client(timeout=30.0) as client:
resp = client.post(
f"{base_url}/api/v1/auth/agent_mode/",
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
json=body,
)
except httpx.HTTPError as exc:
print_error(err_console, f"Network error contacting Mem0: {exc}")
raise typer.Exit(1) from exc
if resp.status_code == 429:
print_error(err_console, "Rate-limited. Try again in a few minutes.")
raise typer.Exit(1)
if resp.status_code == 503:
print_error(err_console, "Agent Mode is temporarily disabled. Try again later.")
raise typer.Exit(1)
if resp.status_code != 200:
detail = resp.text
try:
err_body = resp.json()
detail = err_body.get("error") or err_body.get("detail") or resp.text
except (json.JSONDecodeError, ValueError, AttributeError):
pass
# Backend's @ratelimit decorator raises PermissionDenied, which DRF
# translates to a generic 403 "You do not have permission to perform
# this action." That's opaque — surface as the rate-limit it actually is.
if resp.status_code == 403 and "permission" in str(detail).lower():
print_error(
err_console,
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
)
raise typer.Exit(1)
print_error(err_console, f"Bootstrap failed: {detail}")
raise typer.Exit(1)
envelope = resp.json()
_validate_envelope(envelope)
config.platform.api_key = envelope["api_key"]
config.platform.base_url = base_url
config.platform.agent_mode = True
config.platform.created_via = "agent_mode"
config.platform.agent_caller = agent_caller or ""
config.platform.claimed_at = ""
config.platform.default_user_id = envelope["default_user_id"]
# Adopt the slug-derived user_id as the default scope for memory ops.
config.defaults.user_id = envelope["default_user_id"]
save_config(config)
print_success(console, f"Agent Mode active. Default user_id: {envelope['default_user_id']}")
notice = envelope.get("mem0_notice")
if notice:
console.print(f"\n[yellow]🔔 {notice}[/yellow]\n")
else:
# Fallback if the backend hasn't deployed the unified notice yet.
claim_cmd = envelope.get("claim_command", "mem0 init --email <your-email>")
console.print(f" [{DIM_COLOR}]To claim this account later: {claim_cmd}[/]")
if not agent_caller:
console.print(
f" [{DIM_COLOR}]If you are an AI agent, identify yourself: "
f"`mem0 identify <your-name>` (e.g. claude-code, cursor).[/]"
)
def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None:
"""Claim an existing Agent Mode account via OTP — no browser, no polling.
Reuses the standard email-code flow (`/api/v1/auth/email_code/` then
`/.../verify/`) and adds the local agent-mode API key in the verify body
as `agent_mode_api_key`. Backend's `verify_email_code` runs the
upgrade-in-place transaction inline and returns claim result.
On success: flips `platform.agent_mode=false`, sets `claimed_at`, stamps
`user_email`. The api_key value itself never changes.
"""
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
if not config.platform.api_key or not config.platform.agent_mode:
print_error(
err_console,
"This command requires an active Agent Mode config. Run `mem0 init` first.",
)
raise typer.Exit(1)
raw_key = config.platform.api_key
with httpx.Client(timeout=30.0) as client:
# Step 1: request OTP (unless --code provided)
if not code:
send = client.post(
f"{base_url}/api/v1/auth/email_code/",
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
json={"email": email},
)
if send.status_code == 429:
print_error(err_console, "Too many attempts. Try again in a few minutes.")
raise typer.Exit(1)
if send.status_code != 200:
try:
detail = send.json().get("error", send.text)
except Exception:
detail = send.text
print_error(err_console, f"Failed to send code: {detail}")
raise typer.Exit(1)
print_success(console, f"Verification code sent to {email}. Check your inbox.")
if not sys.stdin.isatty():
print_error(
err_console,
"No --code provided and terminal is non-interactive.",
hint=f"Re-run: mem0 init --email {email} --code <code>",
)
raise typer.Exit(1)
console.print()
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
if not code:
print_error(err_console, "Code is required.")
raise typer.Exit(1)
# Step 2: verify + claim in one shot
verify = client.post(
f"{base_url}/api/v1/auth/email_code/verify/",
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
json={
"email": email,
"code": code.strip(),
"agent_mode_api_key": raw_key,
},
)
if verify.status_code != 200:
try:
err_body = verify.json()
detail = err_body.get("error", verify.text)
code_str = err_body.get("code", "")
except (json.JSONDecodeError, ValueError, AttributeError):
detail = verify.text
code_str = ""
print_error(err_console, f"Claim failed: {detail}")
if code_str == "email_already_claimed":
console.print(
f" [{DIM_COLOR}]Tip: this email already has a Mem0 account. Sign in at app.mem0.ai with your existing credentials.[/]"
)
raise typer.Exit(1)
claim_body = verify.json()
if not claim_body.get("claimed"):
print_error(err_console, f"Unexpected verify response: {claim_body}")
raise typer.Exit(1)
config.platform.agent_mode = False
config.platform.claimed_at = claim_body.get("claimed_at") or _utcnow_iso()
config.platform.user_email = email
config.platform.created_via = "email"
save_config(config)
print_success(console, f"Agent claimed to {email}. Your API key is unchanged.")
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@@ -0,0 +1,132 @@
"""mem0 agent-rush — AGENTRUSH game commands.
Wraps the platform's /v1/agent-rush/{memories/, memories/search/} endpoints.
Hardcoded routing; no flags needed.
"""
from __future__ import annotations
import sys
from datetime import datetime, timezone
import httpx
import typer
from rich.console import Console
from mem0_cli.branding import print_error, print_success
from mem0_cli.config import load_config, save_config
console = Console()
err_console = Console(stderr=True)
_PII_WARNING_LINES = (
"",
"[yellow]⚠️ AGENTRUSH memories are PUBLIC — visible to any other player.[/yellow]",
"[yellow] Do not include real names, emails, secrets, work content, or PII.[/yellow]",
"",
)
_SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "python",
"X-Mem0-Mode": "agent-rush",
}
_ERROR_HINTS = {
"agentrush_search_first": "Run 3 'mem0 agent-rush search' commands before adding.",
"agentrush_search_quota": "You've used your 3 lifetime searches.",
"agentrush_add_quota": "You've used your 3 lifetime adds.",
"agentrush_not_agent_mode": "Re-run 'mem0 init --agent' to bootstrap an agent-mode key.",
"agentrush_length": "Memory text must be 50-1000 characters.",
"agentrush_no_urls": "URLs are not allowed.",
"agentrush_blocklist": "Content contains a blocked term.",
"agentrush_global_quota": "Event-wide cap reached. Try again later.",
"agentrush_not_provisioned": "AGENTRUSH is not provisioned in this environment.",
}
def _call(path: str, body: dict) -> dict:
config = load_config()
if not config.platform.api_key:
print_error(err_console, "Not initialized. Run `mem0 init --agent` first.")
raise typer.Exit(1)
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
try:
with httpx.Client(timeout=30.0) as client:
resp = client.post(
f"{base_url}{path}",
headers={
**_SOURCE_HEADERS,
"Authorization": f"Token {config.platform.api_key}",
"Content-Type": "application/json",
},
json=body,
)
except httpx.HTTPError as exc:
print_error(err_console, f"Network error: {exc}")
raise typer.Exit(1) from exc
try:
data = resp.json()
except Exception:
data = {}
if resp.status_code >= 400:
code = (
(data.get("error") or {}).get("code", "unknown")
if isinstance(data, dict)
else "unknown"
)
print_error(err_console, f"AGENTRUSH error: {code}")
hint = _ERROR_HINTS.get(code)
if hint:
console.print(f" [dim]{hint}[/dim]")
raise typer.Exit(1)
return data
def _ensure_warning_acknowledged() -> None:
"""Block the first interactive add on the PII warning; pass-through for agents.
Interactive (TTY): show prompt, require explicit 'y', persist
`agent_rush.acknowledged_at` so we never ask the same machine twice.
Non-interactive (no TTY — typical when an agent runs the CLI): surface
the warning to stderr for the human reading the agent transcript and
proceed without prompting (agents can't answer y/N).
"""
config = load_config()
if config.agent_rush.acknowledged_at:
return
is_tty = sys.stdin.isatty() and sys.stdout.isatty()
if not is_tty:
for line in _PII_WARNING_LINES:
err_console.print(line)
return
for line in _PII_WARNING_LINES:
console.print(line)
answer = typer.prompt(" Continue? [y/N]", default="N", show_default=False).strip().lower()
if answer not in ("y", "yes"):
print_error(err_console, "Aborted.")
raise typer.Exit(1)
config.agent_rush.acknowledged_at = datetime.now(timezone.utc).isoformat()
save_config(config)
def run_agent_rush_add(content: str) -> None:
_ensure_warning_acknowledged()
result = _call("/v1/agent-rush/memories/", {"content": content})
event_id = result.get("event_id", "?")
print_success(console, f"Memory submitted (event_id: {event_id})")
def run_agent_rush_search(query: str) -> None:
result = _call("/v1/agent-rush/memories/search/", {"query": query})
memories = result.get("results") or result.get("memories") or []
if not memories:
console.print("[dim](no results)[/dim]")
return
for i, m in enumerate(memories[:5], start=1):
text = m.get("memory") if isinstance(m, dict) else str(m)
console.print(f" {i}. {text}")
@@ -0,0 +1,127 @@
"""Config management commands: show, set, get."""
from __future__ import annotations
from rich.console import Console
from rich.table import Table
from mem0_cli.branding import ACCENT_COLOR, BRAND_COLOR, DIM_COLOR, print_error, print_success
from mem0_cli.config import (
get_nested_value,
load_config,
redact_key,
save_config,
set_nested_value,
)
console = Console()
err_console = Console(stderr=True)
def cmd_config_show(*, output: str = "text") -> None:
"""Display current configuration (secrets redacted)."""
from mem0_cli.output import format_agent_envelope
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("config show")
if is_agent_mode():
output = "agent"
config = load_config()
if output in ("json", "agent"):
format_agent_envelope(
console,
command="config show",
data={
"defaults": {
"user_id": config.defaults.user_id or None,
"agent_id": config.defaults.agent_id or None,
"app_id": config.defaults.app_id or None,
"run_id": config.defaults.run_id or None,
},
"platform": {
"api_key": redact_key(config.platform.api_key),
"base_url": config.platform.base_url,
},
},
)
return
console.print()
console.print(f" [{BRAND_COLOR}]◆ mem0 Configuration[/]\n")
table = Table(border_style=BRAND_COLOR, header_style=f"bold {ACCENT_COLOR}", padding=(0, 2))
table.add_column("Key", style="bold")
table.add_column("Value")
# Defaults
table.add_row(
"defaults.user_id",
config.defaults.user_id or f"[{DIM_COLOR}](not set)[/]",
)
table.add_row(
"defaults.agent_id",
config.defaults.agent_id or f"[{DIM_COLOR}](not set)[/]",
)
table.add_row(
"defaults.app_id",
config.defaults.app_id or f"[{DIM_COLOR}](not set)[/]",
)
table.add_row(
"defaults.run_id",
config.defaults.run_id or f"[{DIM_COLOR}](not set)[/]",
)
table.add_row("", "")
# Platform
table.add_row("[bold]platform.api_key[/]", redact_key(config.platform.api_key))
table.add_row("platform.base_url", config.platform.base_url)
console.print(table)
console.print()
def cmd_config_get(key: str) -> None:
"""Get a config value."""
from mem0_cli.output import format_agent_envelope
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("config get")
config = load_config()
value = get_nested_value(config, key)
if value is None:
print_error(err_console, f"Unknown config key: {key}")
return
display_value = (
redact_key(str(value)) if ("api_key" in key or "key" in key.split(".")[-1:]) else str(value)
)
if is_agent_mode():
format_agent_envelope(
console, command="config get", data={"key": key, "value": display_value}
)
else:
console.print(display_value)
def cmd_config_set(key: str, value: str) -> None:
"""Set a config value."""
from mem0_cli.output import format_agent_envelope
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("config set")
config = load_config()
if set_nested_value(config, key, value):
save_config(config)
display = redact_key(value) if "key" in key else value
if is_agent_mode():
format_agent_envelope(
console, command="config set", data={"key": key, "value": display}
)
else:
print_success(console, f"{key} = {display}")
else:
print_error(err_console, f"Unknown config key: {key}")
@@ -0,0 +1,168 @@
"""Entity management commands."""
from __future__ import annotations
import time as _time
import typer
from rich.console import Console
from rich.table import Table
from mem0_cli.backend.base import Backend
from mem0_cli.branding import (
ACCENT_COLOR,
BRAND_COLOR,
DIM_COLOR,
print_error,
print_info,
print_success,
timed_status,
)
from mem0_cli.output import format_agent_envelope, format_json
console = Console()
err_console = Console(stderr=True)
def cmd_entities_list(backend: Backend, entity_type: str, *, output: str) -> None:
"""List entities of a given type."""
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("entity list")
if is_agent_mode():
output = "agent"
valid_types = {"users", "agents", "apps", "runs"}
if entity_type not in valid_types:
print_error(
err_console, f"Invalid entity type: {entity_type}. Use: {', '.join(valid_types)}"
)
raise typer.Exit(1)
_start = _time.perf_counter()
with timed_status(err_console, f"Fetching {entity_type}...") as _ts:
try:
results = backend.entities(entity_type)
except Exception as e:
print_error(err_console, str(e), hint="This feature may require the mem0 Platform.")
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "agent":
format_agent_envelope(
console,
command="entity list",
data=results,
count=len(results),
duration_ms=int(_elapsed * 1000),
)
return
if output == "json":
format_json(console, results)
return
if not results:
print_info(console, f"No {entity_type} found.")
return
table = Table(border_style=BRAND_COLOR, header_style=f"bold {ACCENT_COLOR}", padding=(0, 1))
table.add_column("Name / ID", style="bold")
table.add_column("Created", max_width=12)
for entity in results:
name = entity.get("name", entity.get("id", ""))
created = str(entity.get("created_at", ""))[:10]
table.add_row(str(name), created)
console.print()
console.print(table)
console.print(f" [{DIM_COLOR}]{len(results)} {entity_type} ({_elapsed:.2f}s)[/]")
console.print()
def cmd_entities_delete(
backend: Backend,
*,
user_id: str | None,
agent_id: str | None,
app_id: str | None,
run_id: str | None,
force: bool,
dry_run: bool = False,
output: str,
) -> None:
"""Delete an entity and all its memories (cascade delete)."""
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("entity delete")
if is_agent_mode():
output = "agent"
if not force:
print_error(err_console, "Destructive operation requires --force in agent mode.")
raise typer.Exit(1)
if not any([user_id, agent_id, app_id, run_id]):
print_error(
err_console, "Provide at least one of --user-id, --agent-id, --app-id, --run-id."
)
raise typer.Exit(1)
scope_parts = []
if user_id:
scope_parts.append(f"user={user_id}")
if agent_id:
scope_parts.append(f"agent={agent_id}")
if app_id:
scope_parts.append(f"app={app_id}")
if run_id:
scope_parts.append(f"run={run_id}")
scope_str = ", ".join(scope_parts)
if dry_run:
print_info(console, f"Would delete entity {scope_str} and all its memories.")
print_info(console, "No changes made (dry run).")
return
if not force:
confirm = typer.confirm(
f"\n \u26a0 Delete entity {scope_str} AND all its memories? This cannot be undone."
)
if not confirm:
print_info(console, "Cancelled.")
raise typer.Exit(0)
_start = _time.perf_counter()
with timed_status(err_console, "Deleting entity...") as _ts:
try:
result = backend.delete_entities(
user_id=user_id,
agent_id=agent_id,
app_id=app_id,
run_id=run_id,
)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
scope = {
k: v
for k, v in {
"user_id": user_id,
"agent_id": agent_id,
"app_id": app_id,
"run_id": run_id,
}.items()
if v
}
if output == "agent":
format_agent_envelope(
console,
command="entity delete",
data={"deleted": True},
scope=scope or None,
duration_ms=int(_elapsed * 1000),
)
elif output == "json":
format_json(console, result)
elif output != "quiet":
print_success(console, f"Entity deleted with all memories ({_elapsed:.2f}s)")
@@ -0,0 +1,176 @@
"""Event commands: list and status."""
from __future__ import annotations
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from mem0_cli.backend.base import Backend
from mem0_cli.branding import (
ACCENT_COLOR,
BRAND_COLOR,
DIM_COLOR,
ERROR_COLOR,
SUCCESS_COLOR,
WARNING_COLOR,
print_info,
timed_status,
)
from mem0_cli.output import format_agent_envelope, format_json
console = Console()
err_console = Console(stderr=True)
_STATUS_STYLE = {
"SUCCEEDED": f"[{SUCCESS_COLOR}]SUCCEEDED[/]",
"PENDING": f"[{ACCENT_COLOR}]PENDING[/]",
"FAILED": f"[{ERROR_COLOR}]FAILED[/]",
"PROCESSING": f"[{WARNING_COLOR}]PROCESSING[/]",
}
def _status_styled(status: str) -> str:
return _STATUS_STYLE.get(status.upper(), status)
def cmd_event_list(backend: Backend, *, output: str = "table") -> None:
"""List recent background events."""
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("event list")
if is_agent_mode():
output = "agent"
import time as _time
_start = _time.perf_counter()
with timed_status(err_console, "Fetching events...") as _ts:
try:
results = backend.list_events()
except Exception as e:
_ts.error_msg = str(e)
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "agent":
format_agent_envelope(
console,
command="event list",
data=results,
count=len(results),
duration_ms=int(_elapsed * 1000),
)
return
if output == "json":
format_json(console, results)
return
if not results:
console.print()
print_info(console, "No events found.")
console.print()
return
table = Table(
border_style=BRAND_COLOR,
header_style=f"bold {ACCENT_COLOR}",
row_styles=["", "dim"],
padding=(0, 1),
)
table.add_column("Event ID", style="dim", max_width=10, no_wrap=True)
table.add_column("Type", max_width=14)
table.add_column("Status", max_width=12)
table.add_column("Latency", max_width=10, justify="right")
table.add_column("Created", max_width=20)
for ev in results:
ev_id = str(ev.get("id", ""))[:8]
ev_type = str(ev.get("event_type", ""))
status = str(ev.get("status", ""))
latency = ev.get("latency")
latency_str = f"{latency:.0f}ms" if isinstance(latency, (int, float)) else ""
created = str(ev.get("created_at", ""))[:19].replace("T", " ")
table.add_row(ev_id, ev_type, _status_styled(status), latency_str, created)
console.print()
console.print(table)
console.print(f" [{DIM_COLOR}]{len(results)} event{'s' if len(results) != 1 else ''}[/]")
console.print()
def cmd_event_status(backend: Backend, event_id: str, *, output: str = "text") -> None:
"""Get the status of a specific background event."""
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("event status")
if is_agent_mode():
output = "agent"
import time as _time
_start = _time.perf_counter()
with timed_status(err_console, "Fetching event...") as _ts:
try:
ev = backend.get_event(event_id)
except Exception as e:
_ts.error_msg = str(e)
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "agent":
format_agent_envelope(
console,
command="event status",
data=ev,
duration_ms=int(_elapsed * 1000),
)
return
if output == "json":
format_json(console, ev)
return
status = str(ev.get("status", ""))
ev_type = str(ev.get("event_type", ""))
latency = ev.get("latency")
latency_str = f"{latency:.0f}ms" if isinstance(latency, (int, float)) else ""
created = str(ev.get("created_at", ""))[:19].replace("T", " ")
updated = str(ev.get("updated_at", ""))[:19].replace("T", " ")
results = ev.get("results")
lines = []
lines.append(f" [{DIM_COLOR}]Event ID:[/] {event_id}")
lines.append(f" [{DIM_COLOR}]Type:[/] {ev_type}")
lines.append(f" [{DIM_COLOR}]Status:[/] {_status_styled(status)}")
lines.append(f" [{DIM_COLOR}]Latency:[/] {latency_str}")
lines.append(f" [{DIM_COLOR}]Created:[/] {created}")
lines.append(f" [{DIM_COLOR}]Updated:[/] {updated}")
if results:
lines.append("")
lines.append(f" [{DIM_COLOR}]Results ({len(results)}):[/]")
for r in results:
mem_id = str(r.get("id", ""))[:8]
data = r.get("data", {})
memory = data.get("memory", "") if isinstance(data, dict) else str(data)
ev_name = str(r.get("event", ""))
user = str(r.get("user_id", ""))
detail = f"{ev_name} {memory}"
if user:
detail += f" [{DIM_COLOR}](user_id={user})[/]"
lines.append(f" [{SUCCESS_COLOR}]·[/] {detail} [{DIM_COLOR}]({mem_id})[/]")
content = "\n".join(lines)
panel = Panel(
content,
title=f"[{BRAND_COLOR}]Event Status[/]",
title_align="left",
border_style=BRAND_COLOR,
padding=(1, 1),
)
console.print()
console.print(panel)
console.print()
@@ -0,0 +1,75 @@
"""mem0 identify — declare which agent owns the current agent-mode key.
Used when `mem0 init --agent` ran without --agent-caller, so the backend
saved agent_caller=NULL. The agent re-runs `mem0 identify <name>` to PATCH
its own row with its real identity. Idempotent — running it again just
overwrites.
"""
from __future__ import annotations
import httpx
import typer
from rich.console import Console
from mem0_cli.branding import print_error, print_success
from mem0_cli.config import load_config, save_config
console = Console()
err_console = Console(stderr=True)
_SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "python",
}
def run_identify(name: str) -> None:
"""PATCH the active agent-mode key's agent_caller field."""
config = load_config()
if not config.platform.api_key:
print_error(
err_console,
"No API key configured. Run `mem0 init --agent` first.",
)
raise typer.Exit(1)
if not config.platform.agent_mode:
print_error(
err_console,
"This command only works on unclaimed agent-mode keys.",
)
raise typer.Exit(1)
name = (name or "").strip()
if not name:
print_error(err_console, "Agent name is required.")
raise typer.Exit(1)
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
try:
with httpx.Client(timeout=30.0) as client:
resp = client.patch(
f"{base_url}/api/v1/auth/agent_mode/caller/",
headers={
**_SOURCE_HEADERS,
"Authorization": f"Token {config.platform.api_key}",
"Content-Type": "application/json",
},
json={"agent_caller": name},
)
except httpx.HTTPError as exc:
print_error(err_console, f"Network error: {exc}")
raise typer.Exit(1) from exc
if resp.status_code != 200:
try:
detail = resp.json().get("error", resp.text)
except Exception:
detail = resp.text
print_error(err_console, f"Identify failed: {detail}")
raise typer.Exit(1)
canonical = resp.json().get("agent_caller", name)
config.platform.agent_caller = canonical
save_config(config)
print_success(console, f"Identified as {canonical}.")
@@ -0,0 +1,566 @@
"""mem0 init — interactive setup wizard."""
from __future__ import annotations
import os
import re
import sys
import httpx
import typer
from rich.console import Console
from rich.prompt import Prompt
from mem0_cli.branding import (
BRAND_COLOR,
DIM_COLOR,
print_banner,
print_error,
print_info,
print_success,
)
from mem0_cli.config import (
CONFIG_FILE,
DEFAULT_BASE_URL,
Mem0Config,
load_config,
save_config,
)
console = Console()
err_console = Console(stderr=True)
def _prompt_secret(label: str) -> str:
"""Prompt for a secret value, echoing '*' for each character typed."""
sys.stdout.write(label)
sys.stdout.flush()
chars: list[str] = []
if sys.platform == "win32":
import msvcrt
while True:
ch = msvcrt.getwch()
if ch in ("\r", "\n"):
sys.stdout.write("\n")
sys.stdout.flush()
break
if ch == "\x03":
raise KeyboardInterrupt
if ch in ("\x08", "\x7f"): # backspace
if chars:
chars.pop()
sys.stdout.write("\b \b")
sys.stdout.flush()
else:
chars.append(ch)
sys.stdout.write("*")
sys.stdout.flush()
else:
import termios
import tty
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
while True:
ch = sys.stdin.read(1)
if ch in ("\r", "\n"):
sys.stdout.write("\r\n")
sys.stdout.flush()
break
if ch == "\x03":
raise KeyboardInterrupt
if ch in ("\x7f", "\x08"): # backspace/delete
if chars:
chars.pop()
sys.stdout.write("\b \b")
sys.stdout.flush()
elif ch == "\x15": # Ctrl+U — clear line
sys.stdout.write("\b \b" * len(chars))
sys.stdout.flush()
chars = []
elif ch >= " ": # ignore other control characters
chars.append(ch)
sys.stdout.write("*")
sys.stdout.flush()
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return "".join(chars)
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _validate_email(email: str) -> None:
"""Exit with an error if *email* doesn't look like a valid address."""
if not _EMAIL_RE.match(email):
print_error(err_console, f"Invalid email address: {email!r}")
raise typer.Exit(1)
def _ping_key(api_key: str, base_url: str, timeout: float = 5.0) -> bool:
"""Validate api_key against /v1/ping/.
Returns False ONLY on a definitive "invalid key" signal (HTTP 401 / 403).
Network errors, timeouts, and 5xx responses return True so we prefer
reusing an existing key over silently minting a new shadow on a transient
blip (which would also clobber config + plugin-sync targets).
"""
try:
resp = httpx.get(
f"{base_url.rstrip('/')}/v1/ping/",
headers={"Authorization": f"Token {api_key}"},
timeout=timeout,
)
except httpx.HTTPError:
return True # unknown — prefer reuse
return resp.status_code not in (401, 403)
def _email_login(
email: str,
code: str | None,
base_url: str,
) -> dict:
"""Run the email verification code login flow.
Returns the parsed JSON response from the verify endpoint.
The caller expects at minimum an ``api_key`` field.
"""
url = base_url.rstrip("/")
_source_headers = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "python",
}
with httpx.Client(timeout=30.0) as client:
# If code is already provided, skip sending — user already has a code
if not code:
# Step 1: Request verification code
resp = client.post(
f"{url}/api/v1/auth/email_code/",
json={"email": email},
headers=_source_headers,
)
if resp.status_code == 429:
print_error(err_console, "Too many attempts. Try again in a few minutes.")
raise typer.Exit(1)
if resp.status_code != 200:
try:
detail = resp.json().get("error", resp.text)
except Exception:
detail = resp.text
print_error(err_console, f"Failed to send code: {detail}")
raise typer.Exit(1)
print_success(console, "Verification code sent! Check your email.")
# Step 2: Get code from user
if not sys.stdin.isatty():
print_error(
err_console,
"No --code provided and terminal is non-interactive.",
hint="Run: mem0 init --email <email> --code <code>",
)
raise typer.Exit(1)
console.print()
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
if not code:
print_error(err_console, "Code is required.")
raise typer.Exit(1)
# Step 3: Verify code
resp = client.post(
f"{url}/api/v1/auth/email_code/verify/",
json={"email": email, "code": code.strip()},
headers=_source_headers,
)
if resp.status_code == 429:
print_error(err_console, "Too many attempts. Try again in a few minutes.")
raise typer.Exit(1)
if resp.status_code != 200:
try:
detail = resp.json().get("error", resp.text)
except Exception:
detail = resp.text
print_error(err_console, f"Verification failed: {detail}")
raise typer.Exit(1)
return resp.json()
def run_init(
*,
api_key: str | None = None,
user_id: str | None = None,
email: str | None = None,
code: str | None = None,
force: bool = False,
source: str | None = None,
agent: bool = False,
agent_caller: str | None = None,
) -> None:
"""Interactive setup wizard for mem0 CLI.
When both *api_key* and *user_id* are supplied, all prompts are skipped
(non-interactive mode). When running in a non-TTY without the required
flags, an error message is printed.
Agent Mode dispatch (no email/api-key flags):
- If existing config has an active API key → reuse (existing_key path).
- Else if any positive agent signal (--agent, --json global, agent env
var, or `agent` flag) → POST /api/v1/auth/agent_mode/ and write config.
- Else fall through to the interactive wizard.
Claim dispatch:
- If `--email` is set AND existing config has `agent_mode=true`, run the
claim device-flow against the existing key instead of minting a new
email-based key.
"""
from mem0_cli.agent_detect import detect_agent_caller
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend, claim_via_otp
from mem0_cli.state import is_agent_mode as _global_agent_mode
from mem0_cli.telemetry import capture_event
def _fire_init(mode: str, *, claimed: bool = False) -> None:
"""Fire cli.init telemetry with M1-M6 properties."""
props: dict = {"command": "init", "mode": mode}
if agent_caller:
# Self-declared via --agent-caller; not sniffed from env vars.
props["agent_caller"] = agent_caller
if source:
props["signup_source"] = source
if claimed:
props["claimed_agent_mode"] = True
capture_event("cli.init", props)
config = Mem0Config()
base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL)
config.platform.base_url = base_url
if code and not email:
print_error(err_console, "--code requires --email.")
raise typer.Exit(1)
# ── Email + existing agent-mode config → claim flow ─────────────────
if email and CONFIG_FILE.exists():
existing = load_config()
if existing.platform.agent_mode and existing.platform.api_key:
email = email.strip().lower()
_validate_email(email)
print_info(console, f"Claiming Agent Mode account to {email}...")
claim_via_otp(existing, email=email, code=code)
_fire_init("email", claimed=True)
return
# ── Agent Mode path runs BEFORE the existing-config guard ──────────
# Rules 1/2 REUSE a valid existing key (not overwrite), so we must
# short-circuit before the guard prompts. Rule 3 mints only when there
# is no valid key to reuse — in that case overwriting is correct.
_agent_ctx = agent or _global_agent_mode() or (detect_agent_caller() is not None)
if not api_key and not email and _agent_ctx:
from mem0_cli.output import format_json_envelope
from mem0_cli.state import is_agent_mode as _is_json_mode
def _emit_reuse(source: str) -> None:
if _is_json_mode():
format_json_envelope(
console,
command="init",
data={
"api_key_saved": False,
"api_key_source": source,
"agent_mode": False,
"message": "Existing Mem0 API key found and reused. No Agent Mode key was created.",
},
)
else:
msg = (
"Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted."
if source == "env"
else "Existing API key in config is valid; reusing it. No new Agent Mode key was minted."
)
print_success(console, msg)
def _maybe_identify(key: str) -> None:
"""Best-effort PATCH agent_caller when --agent-caller is supplied on a
reused key. Silent no-op on any failure — reuse must not break.
"""
if not agent_caller:
return
try:
resp = httpx.patch(
f"{base_url.rstrip('/')}/api/v1/auth/agent_mode/caller/",
headers={
"Authorization": f"Token {key}",
"Content-Type": "application/json",
},
json={"agent_caller": agent_caller},
timeout=10.0,
)
# Also reflect in local config so introspection matches backend.
if resp.status_code == 200 and CONFIG_FILE.exists():
try:
cfg = load_config()
cfg.platform.agent_caller = resp.json().get("agent_caller", agent_caller)
save_config(cfg)
except Exception:
pass
except httpx.HTTPError:
pass
# Rule 1: env MEM0_API_KEY valid → reuse, no new key.
_env_key = (os.environ.get("MEM0_API_KEY") or "").strip()
if _env_key and _ping_key(_env_key, base_url):
_maybe_identify(_env_key)
_emit_reuse("env")
_fire_init("existing_key")
return
# Rule 2: existing config api_key valid → reuse.
if CONFIG_FILE.exists():
_existing = load_config()
if _existing.platform.api_key and _ping_key(_existing.platform.api_key, base_url):
_maybe_identify(_existing.platform.api_key)
_emit_reuse("config")
_fire_init("existing_key")
return
# Rule 3: mint a fresh shadow (no valid key to reuse).
# agent_caller is the agent's self-declared identity from --agent-caller
# (Proof Editor-style). Env-var auto-detect is still used above to
# decide we're in an agent context, but never to fill identity.
bootstrap_via_backend(config, source=source, agent_caller=agent_caller)
_fire_init("agent")
return
# Warn if an existing config with an API key would be overwritten
if not force and CONFIG_FILE.exists():
existing = load_config()
if existing.platform.api_key:
from mem0_cli.config import redact_key
console.print(
f"\n [{BRAND_COLOR}]Existing configuration found[/] "
f"[{DIM_COLOR}](API key: {redact_key(existing.platform.api_key)})[/]"
)
if sys.stdin.isatty():
confirm = typer.confirm(" Overwrite existing config? This cannot be undone.")
if not confirm:
print_info(console, "Cancelled. Use --force to skip this check.")
raise typer.Exit(0)
else:
print_error(
err_console,
"Existing config would be overwritten.",
hint="Use --force to overwrite.",
)
raise typer.Exit(1)
# ── Email login flow ──────────────────────────────────────────────
if email:
if api_key:
print_error(err_console, "Cannot use both --api-key and --email.")
raise typer.Exit(1)
email = email.strip().lower()
_validate_email(email)
print_banner(console)
console.print()
print_info(console, f"Logging in as {email}...\n")
result = _email_login(email, code, base_url)
api_key_val = result.get("api_key")
if not api_key_val:
print_error(err_console, "Auth succeeded but no API key was returned. Contact support.")
raise typer.Exit(1)
config.platform.api_key = api_key_val
config.platform.base_url = base_url
config.platform.user_email = email
config.platform.created_via = "email"
config.defaults.user_id = (
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
)
save_config(config)
console.print()
print_success(console, "Authenticated! Configuration saved to ~/.mem0/config.json")
console.print()
console.print(f" [{DIM_COLOR}]Get started:[/]")
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
console.print()
return
# ── API key flow (existing) ───────────────────────────────────────
# (Agent Mode branch runs earlier — see above, before the existing-config
# guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.)
# Non-TTY: resolve defaults so partial flags work in pipelines / CI
if not sys.stdin.isatty():
if not api_key:
print_error(
err_console,
"Non-interactive terminal detected and --api-key is required.",
hint="Run: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
)
raise typer.Exit(1)
user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
# Fully non-interactive when both flags provided
if api_key and user_id:
config.platform.api_key = api_key
config.platform.created_via = "api_key"
config.defaults.user_id = user_id
_validate_platform(config)
save_config(config)
print_success(console, "Configuration saved to ~/.mem0/config.json")
return
print_banner(console)
console.print()
print_info(console, "Welcome! Let's set up your mem0 CLI.\n")
# If no flags at all, ask user how they want to authenticate
if not api_key:
console.print(f" [{BRAND_COLOR}]How would you like to authenticate?[/]")
console.print(f" [{DIM_COLOR}]1.[/] Login with email [{DIM_COLOR}](recommended)[/]")
console.print(f" [{DIM_COLOR}]2.[/] Enter API key manually")
console.print()
choice = Prompt.ask(f" [{BRAND_COLOR}]Choose[/]", choices=["1", "2"], default="1")
if choice == "1":
console.print()
email_addr = Prompt.ask(f" [{BRAND_COLOR}]Email[/]")
if not email_addr:
print_error(err_console, "Email is required.")
raise typer.Exit(1)
email_addr = email_addr.strip().lower()
_validate_email(email_addr)
print_info(console, f"Logging in as {email_addr}...\n")
result = _email_login(email_addr, None, base_url)
api_key_val = result.get("api_key")
if not api_key_val:
print_error(
err_console, "Auth succeeded but no API key was returned. Contact support."
)
raise typer.Exit(1)
config.platform.api_key = api_key_val
config.platform.base_url = base_url
config.platform.user_email = email_addr
config.platform.created_via = "email"
config.defaults.user_id = (
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
)
save_config(config)
console.print()
print_success(console, "Authenticated! Configuration saved to ~/.mem0/config.json")
console.print()
console.print(f" [{DIM_COLOR}]Get started:[/]")
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
console.print()
return
# API key flow
if api_key:
config.platform.api_key = api_key
config.platform.created_via = "api_key"
else:
_setup_platform(config)
if user_id:
config.defaults.user_id = user_id
else:
_setup_defaults(config)
_validate_platform(config)
save_config(config)
console.print()
print_success(console, "Configuration saved to ~/.mem0/config.json")
console.print()
console.print(f" [{DIM_COLOR}]Get started:[/]")
if config.defaults.user_id:
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
else:
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode" --user-id alice[/]')
console.print(f' [{DIM_COLOR}] mem0 search "preferences" --user-id alice[/]')
console.print()
def _setup_platform(config: Mem0Config) -> None:
"""Platform setup flow."""
console.print()
console.print(
f" [{DIM_COLOR}]Get your API key at https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python[/]"
)
console.print()
console.print(f" [{BRAND_COLOR}]API Key[/]: ", end="")
api_key = _prompt_secret("")
if not api_key:
print_error(err_console, "API key is required.")
raise typer.Exit(1)
config.platform.api_key = api_key
config.platform.created_via = "api_key"
def _setup_defaults(config: Mem0Config) -> None:
"""Collect default entity IDs."""
console.print()
print_info(console, "Set default entity IDs (press Enter to skip).\n")
_default_user = os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
user_id = Prompt.ask(
f" [{BRAND_COLOR}]Default User ID[/] [{DIM_COLOR}](recommended)[/]",
default=_default_user,
)
if user_id:
config.defaults.user_id = user_id
def _validate_platform(config: Mem0Config) -> None:
"""Validate platform connection after all inputs are collected."""
console.print()
print_info(console, "Validating connection...")
try:
from mem0_cli.backend.platform import PlatformBackend
backend = PlatformBackend(config.platform)
status = backend.status(
user_id=config.defaults.user_id or None,
agent_id=config.defaults.agent_id or None,
)
if status.get("connected"):
print_success(console, "Connected to mem0 Platform!")
# Cache user_email from ping response for telemetry distinct_id
try:
ping_data = backend.ping()
user_email = ping_data.get("user_email") if isinstance(ping_data, dict) else None
if user_email:
config.platform.user_email = user_email
except Exception:
pass
else:
print_error(
err_console,
f"Could not connect: {status.get('error', 'Unknown error')}",
hint="Visit https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python to get a new key, then run mem0 init again.",
)
except Exception as e:
print_error(err_console, f"Connection test failed: {e}")
+671
View File
@@ -0,0 +1,671 @@
"""Memory CRUD commands: add, search, get, list, update, delete."""
from __future__ import annotations
import json
import os
import stat as _stat_mod
import sys
import time as _time
from pathlib import Path
import typer
from rich.console import Console
from mem0_cli.backend.base import Backend
from mem0_cli.branding import (
print_error,
print_info,
print_scope,
print_success,
timed_status,
)
from mem0_cli.output import (
format_add_result,
format_agent_envelope,
format_json,
format_memories_table,
format_memories_text,
format_single_memory,
print_result_summary,
)
console = Console()
err_console = Console(stderr=True)
def _stdin_is_piped() -> bool:
"""Return True only when stdin is an actual pipe or file redirect."""
from mem0_cli.state import is_agent_mode
if is_agent_mode():
return False
try:
mode = os.fstat(sys.stdin.fileno()).st_mode
return _stat_mod.S_ISFIFO(mode) or _stat_mod.S_ISREG(mode)
except Exception:
return False
def cmd_add(
backend: Backend,
text: str | None,
*,
user_id: str | None,
agent_id: str | None,
app_id: str | None,
run_id: str | None,
messages: str | None,
file: Path | None,
metadata: str | None,
immutable: bool,
no_infer: bool,
expires: str | None,
categories: str | None,
output: str = "text",
) -> None:
"""Add a memory."""
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("add")
if is_agent_mode():
output = "agent"
msgs = None
content = text
# Read from file
if file:
try:
raw = Path(file).read_text()
msgs = json.loads(raw)
except (FileNotFoundError, json.JSONDecodeError) as e:
print_error(err_console, f"Failed to read file: {e}")
raise typer.Exit(1) from None
# Parse messages JSON
elif messages:
try:
msgs = json.loads(messages)
except json.JSONDecodeError as e:
print_error(err_console, f"Invalid JSON in --messages: {e}")
raise typer.Exit(1) from None
# Read from stdin only if stdin is an actual pipe or file redirect
elif not content and _stdin_is_piped():
content = sys.stdin.read().strip()
if not content and not msgs:
print_error(
err_console, "No content provided. Pass text, --messages, --file, or pipe via stdin."
)
raise typer.Exit(1)
meta = None
if metadata:
try:
meta = json.loads(metadata)
except json.JSONDecodeError:
print_error(err_console, "Invalid JSON in --metadata.")
raise typer.Exit(1) from None
cats = None
if categories:
try:
cats = json.loads(categories)
except json.JSONDecodeError:
cats = [c.strip() for c in categories.split(",")]
# Validate --expires
if expires:
import re
if not re.match(r"^\d{4}-\d{2}-\d{2}$", expires):
print_error(
err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)."
)
raise typer.Exit(1)
from datetime import date
if date.fromisoformat(expires) <= date.today():
print_error(err_console, "--expires date must be in the future.")
raise typer.Exit(1)
with timed_status(err_console, "Adding memory...") as ts:
try:
result = backend.add(
content=content,
messages=msgs,
user_id=user_id,
agent_id=agent_id,
app_id=app_id,
run_id=run_id,
metadata=meta,
immutable=immutable,
infer=not no_infer,
expires=expires,
categories=cats,
)
except Exception as e:
ts.error_msg = str(e)
raise typer.Exit(1) from None
if output == "quiet":
return
# Deduplicate PENDING entries sharing the same event_id across all output modes
results_list = result if isinstance(result, list) else result.get("results", [result])
seen_events: set[str] = set()
deduped: list[dict] = []
for r in results_list:
if r.get("status") == "PENDING":
eid = r.get("event_id", "")
if eid and eid in seen_events:
continue
if eid:
seen_events.add(eid)
deduped.append(r)
# Write back so downstream formatters see deduplicated data
if isinstance(result, dict) and "results" in result:
result = {**result, "results": deduped}
else:
result = deduped
if output == "agent":
scope = {
k: v
for k, v in {
"user_id": user_id,
"agent_id": agent_id,
"app_id": app_id,
"run_id": run_id,
}.items()
if v
}
format_agent_envelope(
console,
command="add",
data=deduped,
scope=scope or None,
count=len(deduped),
)
return
if output == "json":
format_add_result(console, result, output)
return
console.print()
print_scope(console, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
count = len(deduped)
all_pending = count > 0 and all(r.get("status") == "PENDING" for r in deduped)
if all_pending:
print_success(
console,
f"Memory queued — {count} event{'s' if count != 1 else ''} pending",
)
else:
print_success(
console, f"Memory processed — {count} memor{'y' if count == 1 else 'ies'} extracted"
)
format_add_result(console, result, output)
def cmd_search(
backend: Backend,
query: str,
*,
user_id: str | None,
agent_id: str | None,
app_id: str | None,
run_id: str | None,
top_k: int,
threshold: float,
rerank: bool,
keyword: bool,
filter_json: str | None,
fields: str | None,
output: str = "text",
) -> None:
"""Search memories."""
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("search")
if is_agent_mode():
output = "agent"
filters = None
if filter_json:
try:
filters = json.loads(filter_json)
except json.JSONDecodeError:
print_error(err_console, "Invalid JSON in --filter.")
raise typer.Exit(1) from None
field_list = None
if fields:
field_list = [f.strip() for f in fields.split(",")]
if top_k < 1:
print_error(err_console, "--top-k must be >= 1.")
raise typer.Exit(1)
if not (0.0 <= threshold <= 1.0):
print_error(err_console, "--threshold must be between 0.0 and 1.0.")
raise typer.Exit(1)
_start = _time.perf_counter()
with timed_status(err_console, "Searching memories...") as _ts:
try:
results = backend.search(
query,
user_id=user_id,
agent_id=agent_id,
app_id=app_id,
run_id=run_id,
top_k=top_k,
threshold=threshold,
rerank=rerank,
keyword=keyword,
filters=filters,
fields=field_list,
)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "quiet":
return
if output == "agent":
scope = {
k: v
for k, v in {
"user_id": user_id,
"agent_id": agent_id,
"app_id": app_id,
"run_id": run_id,
}.items()
if v
}
format_agent_envelope(
console,
command="search",
data=results,
scope=scope or None,
count=len(results),
duration_ms=int(_elapsed * 1000),
)
return
if output == "json":
format_json(console, results)
elif output == "table":
if results:
format_memories_table(console, results, show_score=True)
print_result_summary(
console, len(results), duration_secs=_elapsed, user_id=user_id, agent_id=agent_id
)
else:
console.print()
print_info(console, "No memories found matching your query.")
console.print()
else:
if results:
format_memories_text(console, results)
print_result_summary(
console, len(results), duration_secs=_elapsed, user_id=user_id, agent_id=agent_id
)
else:
console.print()
print_info(console, "No memories found matching your query.")
console.print()
def cmd_get(backend: Backend, memory_id: str, *, output: str) -> None:
"""Get a specific memory by ID."""
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("get")
if is_agent_mode():
output = "agent"
with timed_status(err_console, "Fetching memory...") as _ts:
try:
result = backend.get(memory_id)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
if output == "agent":
format_agent_envelope(console, command="get", data=result)
else:
format_single_memory(console, result, output)
def cmd_list(
backend: Backend,
*,
user_id: str | None,
agent_id: str | None,
app_id: str | None,
run_id: str | None,
page: int,
page_size: int,
category: str | None,
after: str | None,
before: str | None,
output: str = "table",
) -> None:
"""List memories."""
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("list")
if is_agent_mode():
output = "agent"
if page_size < 1:
print_error(err_console, "--page-size must be >= 1.")
raise typer.Exit(1)
if page < 1:
print_error(err_console, "--page must be >= 1.")
raise typer.Exit(1)
_start = _time.perf_counter()
with timed_status(err_console, "Listing memories...") as _ts:
try:
results = backend.list_memories(
user_id=user_id,
agent_id=agent_id,
app_id=app_id,
run_id=run_id,
page=page,
page_size=page_size,
category=category,
after=after,
before=before,
)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "quiet":
return
if output in ("json", "agent"):
scope = {
k: v
for k, v in {
"user_id": user_id,
"agent_id": agent_id,
"app_id": app_id,
"run_id": run_id,
}.items()
if v
}
format_agent_envelope(
console,
command="list",
data=results,
scope=scope or None,
count=len(results),
duration_ms=int(_elapsed * 1000),
)
elif output == "table":
if results:
format_memories_table(console, results)
print_result_summary(
console,
len(results),
duration_secs=_elapsed,
page=page,
user_id=user_id,
agent_id=agent_id,
)
else:
console.print()
print_info(console, "No memories found.")
console.print()
else:
if results:
format_memories_text(console, results, title="memories")
print_result_summary(
console,
len(results),
duration_secs=_elapsed,
page=page,
user_id=user_id,
agent_id=agent_id,
)
else:
console.print()
print_info(console, "No memories found.")
console.print()
def cmd_update(
backend: Backend,
memory_id: str,
text: str | None,
*,
metadata: str | None,
output: str,
) -> None:
"""Update a memory."""
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("update")
if is_agent_mode():
output = "agent"
meta = None
if metadata:
try:
meta = json.loads(metadata)
except json.JSONDecodeError:
print_error(err_console, "Invalid JSON in --metadata.")
raise typer.Exit(1) from None
_start = _time.perf_counter()
with timed_status(err_console, "Updating memory...") as _ts:
try:
result = backend.update(memory_id, content=text, metadata=meta)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "agent":
format_agent_envelope(
console,
command="update",
data=result,
duration_ms=int(_elapsed * 1000),
)
elif output == "json":
format_json(console, result)
elif output != "quiet":
print_success(console, f"Memory {memory_id[:8]} updated ({_elapsed:.2f}s)")
def cmd_delete(
backend: Backend,
memory_id: str,
*,
dry_run: bool = False,
force: bool = False,
output: str,
) -> None:
"""Delete a single memory by ID."""
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("delete")
if is_agent_mode():
output = "agent"
if dry_run:
# Fetch and display what would be deleted
try:
mem = backend.get(memory_id)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
format_single_memory(console, mem, output)
print_info(console, "No changes made (dry run).")
return
_start = _time.perf_counter()
with timed_status(err_console, "Deleting...") as _ts:
try:
result = backend.delete(memory_id=memory_id)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "agent":
format_agent_envelope(
console,
command="delete",
data={"id": memory_id, "deleted": True},
duration_ms=int(_elapsed * 1000),
)
elif output == "json":
format_json(console, result)
elif output != "quiet":
print_success(console, f"Memory {memory_id[:8]} deleted ({_elapsed:.2f}s)")
def cmd_delete_all(
backend: Backend,
*,
force: bool,
dry_run: bool = False,
all_: bool = False,
user_id: str | None,
agent_id: str | None,
app_id: str | None,
run_id: str | None,
output: str,
) -> None:
"""Delete all memories matching a scope."""
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("delete-all")
if is_agent_mode():
output = "agent"
if not force:
print_error(err_console, "Destructive operation requires --force in agent mode.")
raise typer.Exit(1)
if all_:
# Project-wide wipe using wildcard entity IDs
# Note: --dry-run is ignored here because the API has no count-before-delete endpoint.
if not force:
confirm = typer.confirm(
"\n ⚠ Delete ALL memories across the ENTIRE project? This cannot be undone."
)
if not confirm:
print_info(console, "Cancelled.")
raise typer.Exit(0)
_start = _time.perf_counter()
with timed_status(err_console, "Deleting all memories project-wide...") as _ts:
try:
result = backend.delete(
all=True,
user_id="*",
agent_id="*",
app_id="*",
run_id="*",
)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "agent":
format_agent_envelope(
console,
command="delete-all",
data={"deleted": True, "scope": "project"},
duration_ms=int(_elapsed * 1000),
)
elif output == "json":
format_json(console, result)
elif output != "quiet":
if isinstance(result, dict) and "message" in result:
print_info(console, "Deletion started. Memories will be removed in the background.")
else:
print_success(console, f"All project memories deleted ({_elapsed:.2f}s)")
return
if dry_run:
# List matching memories and show count
try:
results = backend.list_memories(
user_id=user_id,
agent_id=agent_id,
app_id=app_id,
run_id=run_id,
)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
count = len(results)
print_info(console, f"Would delete {count} memor{'y' if count == 1 else 'ies'}.")
print_info(console, "No changes made (dry run).")
return
if not force:
scope_parts = []
if user_id:
scope_parts.append(f"user={user_id}")
if agent_id:
scope_parts.append(f"agent={agent_id}")
if app_id:
scope_parts.append(f"app={app_id}")
if run_id:
scope_parts.append(f"run={run_id}")
scope = ", ".join(scope_parts) if scope_parts else "ALL entities"
confirm = typer.confirm(f"\n ⚠ Delete ALL memories for {scope}? This cannot be undone.")
if not confirm:
print_info(console, "Cancelled.")
raise typer.Exit(0)
_start = _time.perf_counter()
with timed_status(err_console, "Deleting all memories...") as _ts:
try:
result = backend.delete(
all=True,
user_id=user_id,
agent_id=agent_id,
app_id=app_id,
run_id=run_id,
)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
scope = {
k: v
for k, v in {
"user_id": user_id,
"agent_id": agent_id,
"app_id": app_id,
"run_id": run_id,
}.items()
if v
}
if output == "agent":
format_agent_envelope(
console,
command="delete-all",
data={"deleted": True},
scope=scope or None,
duration_ms=int(_elapsed * 1000),
)
elif output == "json":
format_json(console, result)
elif output != "quiet":
if isinstance(result, dict) and "message" in result:
print_info(console, "Deletion started. Memories will be removed in the background.")
else:
print_success(console, f"All matching memories deleted ({_elapsed:.2f}s)")
+162
View File
@@ -0,0 +1,162 @@
"""Utility commands: status, version, import."""
from __future__ import annotations
import json
import time as _time
from pathlib import Path
import typer
from rich.console import Console
from rich.panel import Panel
from rich.progress import track
from mem0_cli import __version__
from mem0_cli.backend.base import Backend
from mem0_cli.branding import (
BRAND_COLOR,
DIM_COLOR,
ERROR_COLOR,
SUCCESS_COLOR,
print_error,
print_success,
timed_status,
)
console = Console()
err_console = Console(stderr=True)
def cmd_status(
backend: Backend,
*,
user_id: str | None = None,
agent_id: str | None = None,
output: str = "text",
) -> None:
"""Check connectivity and auth."""
from mem0_cli.output import format_agent_envelope
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("status")
if is_agent_mode():
output = "agent"
_start = _time.perf_counter()
with timed_status(err_console, "Checking connection...") as _ts:
result = backend.status(user_id=user_id, agent_id=agent_id)
_elapsed = _time.perf_counter() - _start
if output in ("json", "agent"):
format_agent_envelope(
console,
command="status",
data={
"connected": result.get("connected", False),
"backend": result.get("backend", "?"),
"base_url": result.get("base_url", ""),
},
duration_ms=int(_elapsed * 1000),
)
return
lines = []
if result.get("connected"):
lines.append(f" [{SUCCESS_COLOR}]●[/] Connected")
else:
lines.append(f" [{ERROR_COLOR}]●[/] Disconnected")
lines.append(f" [{DIM_COLOR}]Backend:[/] {result.get('backend', '?')}")
if result.get("base_url"):
lines.append(f" [{DIM_COLOR}]API URL:[/] {result['base_url']}")
if result.get("error"):
lines.append(f" [{ERROR_COLOR}]Error:[/] {result['error']}")
if "Authentication failed" in str(result["error"]):
lines.append("")
lines.append(
f" [{DIM_COLOR}]Run [bold]mem0 init[/bold] to reconfigure your API key[/]"
)
lines.append(
f" [{DIM_COLOR}]Get a key at [bold]https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python[/bold][/]"
)
lines.append(f" [{DIM_COLOR}]Latency:[/] {_elapsed:.2f}s")
content = "\n".join(lines)
panel = Panel(
content,
title=f"[{BRAND_COLOR}]Connection Status[/]",
title_align="left",
border_style=BRAND_COLOR,
padding=(1, 1),
)
console.print()
console.print(panel)
console.print()
def cmd_version() -> None:
"""Show version."""
console.print(f" [{BRAND_COLOR}]◆ Mem0[/] CLI v{__version__}")
def cmd_import(
backend: Backend,
file_path: str,
*,
user_id: str | None,
agent_id: str | None,
output: str = "text",
) -> None:
"""Import memories from a JSON file."""
from mem0_cli.output import format_agent_envelope
from mem0_cli.state import is_agent_mode, set_current_command
set_current_command("import")
if is_agent_mode():
output = "agent"
try:
data = json.loads(Path(file_path).read_text())
except (FileNotFoundError, json.JSONDecodeError) as e:
print_error(err_console, f"Failed to read file: {e}")
raise typer.Exit(1) from None
if not isinstance(data, list):
data = [data]
added = 0
failed = 0
_start = _time.perf_counter()
for item in track(
data, description=f"[{DIM_COLOR}]Importing memories...[/]", console=err_console
):
content = item.get("memory", item.get("text", item.get("content", "")))
if not content:
failed += 1
continue
try:
backend.add(
content=content,
user_id=user_id or item.get("user_id"),
agent_id=agent_id or item.get("agent_id"),
metadata=item.get("metadata"),
)
added += 1
except Exception:
failed += 1
_elapsed = _time.perf_counter() - _start
if output in ("json", "agent"):
scope = {k: v for k, v in {"user_id": user_id, "agent_id": agent_id}.items() if v}
format_agent_envelope(
console,
command="import",
data={"added": added, "failed": failed},
scope=scope or None,
duration_ms=int(_elapsed * 1000),
)
return
print_success(err_console, f"Imported {added} memories ({_elapsed:.2f}s)")
if failed:
print_error(err_console, f"{failed} memories failed to import.")
@@ -0,0 +1,25 @@
"""mem0 whoami — print the active agent's default_user_id (AGENTRUSH identifier)."""
from __future__ import annotations
import typer
from rich.console import Console
from mem0_cli.branding import BRAND_COLOR, print_error, print_info
from mem0_cli.config import load_config
console = Console()
err_console = Console(stderr=True)
def run_whoami() -> None:
config = load_config()
session_id = config.platform.default_user_id if config.platform else None
if not session_id:
print_error(
err_console,
"No default_user_id found. Run `mem0 init --agent` first.",
)
raise typer.Exit(1)
console.print(f"Your AGENTRUSH identifier: [{BRAND_COLOR}]{session_id}[/{BRAND_COLOR}]")
print_info(console, "Find your row at https://mem0.ai/agentrush")
+244
View File
@@ -0,0 +1,244 @@
"""Configuration management for mem0 CLI.
Config precedence (highest to lowest):
1. CLI flags (--api-key, --base-url, etc.)
2. Environment variables (MEM0_API_KEY, etc.)
3. Config file (~/.mem0/config.json)
4. Defaults
"""
from __future__ import annotations
import json
import os
import stat
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
CONFIG_DIR = Path.home() / ".mem0"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_BASE_URL = "https://api.mem0.ai"
CONFIG_VERSION = 1
@dataclass
class PlatformConfig:
api_key: str = ""
base_url: str = DEFAULT_BASE_URL
user_email: str = ""
# Agent Mode (unclaimed-shadow signup)
agent_mode: bool = False # True while the key is an unclaimed agent-mode key
created_via: str = "" # "agent_mode" | "email" | "api_key" | "existing_key"
agent_caller: str = (
"" # canonical agent name when created_via == "agent_mode" (e.g. "claude-code")
)
claimed_at: str = "" # ISO timestamp once the agent has been claimed by a human
default_user_id: str = "" # `user_<slug>` returned by bootstrap; used as auto-default
@dataclass
class DefaultsConfig:
user_id: str = ""
agent_id: str = ""
app_id: str = ""
run_id: str = ""
@dataclass
class TelemetryConfig:
anonymous_id: str = ""
@dataclass
class AgentRushConfig:
# ISO timestamp the human acknowledged the "memories are public" warning.
# Empty until first interactive `mem0 agent-rush add`.
acknowledged_at: str = ""
@dataclass
class Mem0Config:
version: int = CONFIG_VERSION
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
platform: PlatformConfig = field(default_factory=PlatformConfig)
telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
agent_rush: AgentRushConfig = field(default_factory=AgentRushConfig)
SHORT_KEY_ALIASES: dict[str, str] = {
"api_key": "platform.api_key",
"base_url": "platform.base_url",
"user_email": "platform.user_email",
"user_id": "defaults.user_id",
"agent_id": "defaults.agent_id",
"app_id": "defaults.app_id",
"run_id": "defaults.run_id",
}
def ensure_config_dir() -> Path:
"""Create ~/.mem0 directory with secure permissions if it doesn't exist."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(CONFIG_DIR, stat.S_IRWXU) # 0700
return CONFIG_DIR
def load_config() -> Mem0Config:
"""Load config from file, applying env var overrides."""
config = Mem0Config()
if CONFIG_FILE.exists():
with open(CONFIG_FILE) as f:
data = json.load(f)
config.version = data.get("version", CONFIG_VERSION)
plat = data.get("platform", {})
config.platform.api_key = plat.get("api_key", "")
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
config.platform.user_email = plat.get("user_email", "")
config.platform.agent_mode = bool(plat.get("agent_mode", False))
config.platform.created_via = plat.get("created_via", "")
config.platform.agent_caller = plat.get("agent_caller", "")
config.platform.claimed_at = plat.get("claimed_at", "")
config.platform.default_user_id = plat.get("default_user_id", "")
defaults = data.get("defaults", {})
config.defaults.user_id = defaults.get("user_id", "")
config.defaults.agent_id = defaults.get("agent_id", "")
config.defaults.app_id = defaults.get("app_id", "")
config.defaults.run_id = defaults.get("run_id", "")
telemetry = data.get("telemetry", {})
config.telemetry.anonymous_id = telemetry.get("anonymous_id", "")
agent_rush = data.get("agent_rush", {})
config.agent_rush.acknowledged_at = agent_rush.get("acknowledged_at", "")
# Environment variable overrides
env_key = os.environ.get("MEM0_API_KEY")
if env_key:
config.platform.api_key = env_key
env_base = os.environ.get("MEM0_BASE_URL")
if env_base:
config.platform.base_url = env_base
env_user_id = os.environ.get("MEM0_USER_ID")
if env_user_id:
config.defaults.user_id = env_user_id
env_agent_id = os.environ.get("MEM0_AGENT_ID")
if env_agent_id:
config.defaults.agent_id = env_agent_id
env_app_id = os.environ.get("MEM0_APP_ID")
if env_app_id:
config.defaults.app_id = env_app_id
env_run_id = os.environ.get("MEM0_RUN_ID")
if env_run_id:
config.defaults.run_id = env_run_id
return config
def save_config(config: Mem0Config) -> None:
"""Write config to disk with secure permissions."""
ensure_config_dir()
data: dict[str, Any] = {
"version": config.version,
"defaults": {
"user_id": config.defaults.user_id,
"agent_id": config.defaults.agent_id,
"app_id": config.defaults.app_id,
"run_id": config.defaults.run_id,
},
"platform": {
"api_key": config.platform.api_key,
"base_url": config.platform.base_url,
"user_email": config.platform.user_email,
"agent_mode": config.platform.agent_mode,
"created_via": config.platform.created_via,
"agent_caller": config.platform.agent_caller,
"claimed_at": config.platform.claimed_at,
"default_user_id": config.platform.default_user_id,
},
"telemetry": {
"anonymous_id": config.telemetry.anonymous_id,
},
"agent_rush": {
"acknowledged_at": config.agent_rush.acknowledged_at,
},
}
with open(CONFIG_FILE, "w") as f:
json.dump(data, f, indent=2)
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600
# Propagate the active api_key to ecosystem touchpoints (Claude Code
# plugin env injection, shell rc exports). Idempotent — only updates
# EXISTING entries; never creates new ones. Best-effort: any IOError
# in the sync is swallowed so config.json is always the authoritative
# write, never blocked by plugin-state issues.
if config.platform.api_key:
try:
from mem0_cli.plugin_sync import sync_api_key
sync_api_key(config.platform.api_key)
except Exception:
pass
def redact_key(key: str) -> str:
"""Redact an API key for display: m0-xxx...xxx"""
if not key:
return "(not set)"
if len(key) <= 8:
return key[:2] + "***"
return key[:4] + "..." + key[-4:]
def get_nested_value(config: Mem0Config, dotted_key: str) -> Any:
"""Get a config value by dotted path, e.g. 'platform.api_key' or short form 'api_key'."""
dotted_key = SHORT_KEY_ALIASES.get(dotted_key, dotted_key)
parts = dotted_key.split(".")
obj: Any = config
for part in parts:
if hasattr(obj, part):
obj = getattr(obj, part)
else:
return None
return obj
def set_nested_value(config: Mem0Config, dotted_key: str, value: str) -> bool:
"""Set a config value by dotted path. Returns True on success."""
dotted_key = SHORT_KEY_ALIASES.get(dotted_key, dotted_key)
parts = dotted_key.split(".")
obj: Any = config
for part in parts[:-1]:
if hasattr(obj, part):
obj = getattr(obj, part)
else:
return False
final_key = parts[-1]
if not hasattr(obj, final_key):
return False
current = getattr(obj, final_key)
# Type coercion
if isinstance(current, bool):
value = value.lower() in ("true", "1", "yes") # type: ignore[assignment]
elif isinstance(current, int):
try:
value = int(value) # type: ignore[assignment]
except ValueError:
return False
setattr(obj, final_key, value)
return True
+378
View File
@@ -0,0 +1,378 @@
"""Output formatting for mem0 CLI — text, JSON, table, quiet modes."""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from mem0_cli.branding import ACCENT_COLOR, BRAND_COLOR, DIM_COLOR, SUCCESS_COLOR, _sym
def format_memories_text(console: Console, memories: list[dict], title: str = "memories") -> None:
"""Render memories in human-friendly text mode."""
count = len(memories)
console.print(f"\n[{BRAND_COLOR}]Found {count} {title}:[/]\n")
for i, mem in enumerate(memories, 1):
memory_text = mem.get("memory") or mem.get("text") or ""
mem_id = (mem.get("id") or "")[:8]
score = mem.get("score")
created = _format_date(mem.get("created_at"))
category = mem.get("categories", [None])
if isinstance(category, list):
category = category[0] if category else None
line = Text()
line.append(f" {i}. ", style="bold")
line.append(memory_text, style="white")
console.print(line)
details = []
if score is not None:
details.append(f"Score: {score:.2f}")
if mem_id:
details.append(f"ID: {mem_id}")
if created:
details.append(f"Created: {created}")
if category:
details.append(f"Category: {category}")
if details:
detail_str = " · ".join(details)
console.print(f" [{DIM_COLOR}]{detail_str}[/]")
console.print()
def format_memories_table(
console: Console, memories: list[dict], *, show_score: bool = False
) -> None:
"""Render memories in a rich table."""
table = Table(
border_style=BRAND_COLOR,
header_style=f"bold {ACCENT_COLOR}",
row_styles=["", "dim"],
padding=(0, 1),
)
table.add_column("ID", style="dim", max_width=38, no_wrap=True)
if show_score:
table.add_column("Score", max_width=7, justify="right")
table.add_column("Memory", max_width=50, no_wrap=False)
table.add_column("Category", max_width=14)
table.add_column("Created", max_width=12)
for mem in memories:
mem_id = mem.get("id") or ""
memory_text = mem.get("memory") or mem.get("text") or ""
if len(memory_text) > 60:
memory_text = memory_text[:57] + "..."
categories = mem.get("categories", [])
if isinstance(categories, list) and categories:
cat = (
categories[0]
if len(categories) == 1
else f"{categories[0]} (+{len(categories) - 1})"
)
else:
cat = ""
created = _format_date(mem.get("created_at")) or ""
if show_score:
score = mem.get("score")
score_str = f"{score:.2f}" if score is not None else ""
table.add_row(mem_id, score_str, memory_text, cat, created)
else:
table.add_row(mem_id, memory_text, cat, created)
console.print()
console.print(table)
console.print()
def format_json(console: Console, data: Any) -> None:
"""Output data as pretty-printed JSON."""
console.print_json(json.dumps(data, default=str))
def format_single_memory(console: Console, mem: dict, output: str = "text") -> None:
"""Format a single memory for display."""
if output == "json":
format_json(console, mem)
return
memory_text = mem.get("memory") or mem.get("text") or ""
mem_id = mem.get("id") or ""
lines = []
lines.append(f" [white bold]{memory_text}[/]")
lines.append("")
if mem_id:
lines.append(f" [{DIM_COLOR}]ID:[/] {mem_id}")
created = _format_date(mem.get("created_at"))
if created:
lines.append(f" [{DIM_COLOR}]Created:[/] {created}")
updated = _format_date(mem.get("updated_at"))
if updated:
lines.append(f" [{DIM_COLOR}]Updated:[/] {updated}")
meta = mem.get("metadata")
if meta:
lines.append(f" [{DIM_COLOR}]Metadata:[/] {json.dumps(meta)}")
categories = mem.get("categories")
if categories:
cat_str = ", ".join(categories) if isinstance(categories, list) else categories
lines.append(f" [{DIM_COLOR}]Categories:[/] {cat_str}")
content = "\n".join(lines)
panel = Panel(
content,
title=f"[{BRAND_COLOR}]Memory[/]",
title_align="left",
border_style=BRAND_COLOR,
padding=(1, 1),
)
console.print()
console.print(panel)
console.print()
def format_add_result(console: Console, result: dict | list, output: str = "text") -> None:
"""Format the result of an add operation."""
if output == "json":
format_json(console, result)
return
if output == "quiet":
return
# result from API is typically {"results": [...]}
results = result if isinstance(result, list) else result.get("results", [result])
if not results:
console.print(f" [{DIM_COLOR}]No memories extracted.[/]")
return
console.print()
seen_pending_events: set[str] = set()
for r in results:
# Detect async PENDING response from Platform API
if r.get("status") == "PENDING":
event_id = r.get("event_id", "")
# Deduplicate PENDING entries with the same event_id
if event_id and event_id in seen_pending_events:
continue
if event_id:
seen_pending_events.add(event_id)
icon = f"[{ACCENT_COLOR}]{_sym('', '...')}[/]"
parts = [f" {icon} [{DIM_COLOR}]{'Queued':<10}[/]"]
parts.append("[white]Processing in background[/]")
console.print(" ".join(parts))
if event_id:
console.print(f" [{DIM_COLOR}] event_id: {event_id}[/]")
console.print(f" [{DIM_COLOR}] → Check status: mem0 event status {event_id}[/]")
continue
event = r.get("event", "ADD")
memory = r.get("memory") or r.get("text") or r.get("content") or r.get("data") or ""
mem_id = (r.get("id") or r.get("memory_id") or "")[:8]
if event == "ADD":
icon = f"[{SUCCESS_COLOR}]+[/]"
label = "Added"
elif event == "UPDATE":
icon = f"[{ACCENT_COLOR}]~[/]"
label = "Updated"
elif event == "DELETE":
icon = "[red]-[/]"
label = "Deleted"
elif event == "NOOP":
icon = f"[{DIM_COLOR}]·[/]"
label = "No change"
else:
icon = f"[{DIM_COLOR}]?[/]"
label = event
# Build the display line
parts = [f" {icon} [{DIM_COLOR}]{label:<10}[/]"]
if memory:
parts.append(f"[white]{memory}[/]")
if mem_id:
parts.append(f"[{DIM_COLOR}]({mem_id})[/]")
console.print(" ".join(parts))
console.print()
def format_json_envelope(
console: Console,
*,
command: str,
data: Any,
duration_ms: int | None = None,
scope: dict | None = None,
count: int | None = None,
status: str = "success",
error: str | None = None,
) -> None:
"""Output structured JSON envelope for AI agent consumption."""
envelope: dict[str, Any] = {
"status": status,
"command": command,
}
if duration_ms is not None:
envelope["duration_ms"] = duration_ms
if scope is not None:
envelope["scope"] = scope
if count is not None:
envelope["count"] = count
if error:
envelope["error"] = error
envelope["data"] = data
# If the platform flagged this as an unclaimed Agent Mode account, surface
# the notice inside the JSON envelope so an agent consuming the output
# sees it without needing to inspect HTTP headers.
from mem0_cli.state import take_notice
notice = take_notice()
if notice:
envelope["mem0_notice"] = notice
console.print_json(json.dumps(envelope, default=str))
def sanitize_agent_data(command: str, data: Any) -> Any:
"""Project API response data to minimal relevant fields for agent consumption."""
def pick(obj: dict, keys: list) -> dict:
return {k: obj[k] for k in keys if k in obj}
if data is None:
return data
if command == "add":
items = data if isinstance(data, list) else [data]
result = []
for item in items:
if item.get("status") == "PENDING":
result.append(pick(item, ["status", "event_id"]))
else:
result.append(pick(item, ["id", "memory", "event"]))
return result
if command == "search":
return [pick(r, ["id", "memory", "score", "created_at", "categories"]) for r in data]
if command == "list":
return [pick(r, ["id", "memory", "created_at", "categories"]) for r in data]
if command == "get":
return pick(data, ["id", "memory", "created_at", "updated_at", "categories", "metadata"])
if command == "update":
return pick(data, ["id", "memory"])
if command in ("delete", "delete-all", "entity delete"):
return data
if command == "entity list":
result = []
for r in data:
item = pick(r, ["type", "count"])
item["name"] = r.get("name") or r.get("id", "")
result.append(item)
return result
if command == "event list":
return [pick(r, ["id", "event_type", "status", "latency", "created_at"]) for r in data]
if command == "event status":
ev = data
raw_results = ev.get("results") or []
sanitized_results = []
for r in raw_results:
nested = r.get("data") or {}
memory = nested.get("memory") if isinstance(nested, dict) else None
sanitized_results.append(
{
"id": r.get("id"),
"event": r.get("event"),
"user_id": r.get("user_id"),
"memory": memory,
}
)
result = pick(ev, ["id", "event_type", "status", "latency", "created_at", "updated_at"])
result["results"] = sanitized_results
return result
# Pass-through: status, import, config show/get/set
return data
def format_agent_envelope(
console: Console,
*,
command: str,
data: Any,
duration_ms: int | None = None,
scope: dict | None = None,
count: int | None = None,
) -> None:
"""Output structured JSON envelope for agent/programmatic use (--json/--agent mode)."""
envelope: dict[str, Any] = {
"status": "success",
"command": command,
}
if duration_ms is not None:
envelope["duration_ms"] = duration_ms
if scope:
filtered = {k: v for k, v in scope.items() if v}
if filtered:
envelope["scope"] = filtered
if count is not None:
envelope["count"] = count
envelope["data"] = sanitize_agent_data(command, data)
# Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an
# agent reading the JSON output sees it without inspecting HTTP headers.
from mem0_cli.state import take_notice
notice = take_notice()
if notice:
envelope["mem0_notice"] = notice
console.print_json(json.dumps(envelope, default=str))
def print_result_summary(
console: Console,
count: int,
*,
duration_secs: float | None = None,
page: int | None = None,
**scope_ids: str | None,
) -> None:
"""Print a summary footer after result lists."""
parts = [f"{count} result{'s' if count != 1 else ''}"]
if page is not None:
parts.append(f"page {page}")
scope_parts = [f"{k}={v}" for k, v in scope_ids.items() if v]
if scope_parts:
parts.append(", ".join(scope_parts))
if duration_secs is not None:
parts.append(f"{duration_secs:.2f}s")
summary = " · ".join(parts)
console.print(f" [{DIM_COLOR}]{summary}[/]")
console.print()
def _format_date(dt_str: str | None) -> str | None:
if not dt_str:
return None
try:
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d")
except (ValueError, AttributeError):
return str(dt_str)[:10] if dt_str else None
+119
View File
@@ -0,0 +1,119 @@
"""Sync the active Mem0 API key into other ecosystem touchpoints.
Why this exists:
The CLI canonical state lives in ``~/.mem0/config.json``. But MCP servers
(Claude Code plugin, Codex plugin, etc.) read ``MEM0_API_KEY`` from env
vars or their own config files. Without a sync, an agent-mode bootstrap
mints a new key into config.json but the plugin's MCP keeps using the
old key from env — silent surprise.
Design:
- Update ONLY entries that already exist (never create new ones)
- Preserve all surrounding content / formatting / other keys
- Atomic writes (tmpfile + rename) so a crash mid-write doesn't corrupt
- Idempotent — re-running with the same key is a no-op
- Skip on dry_run
Targets currently handled:
- ``~/.claude/settings.json::env::MEM0_API_KEY`` (Claude Code env injection)
- ``~/.zshrc`` / ``~/.bashrc`` ``export MEM0_API_KEY="..."`` lines
Out of scope (deliberately not touched):
- Codex / Cursor MCP configs — would require schema-aware edits and
those tools don't have mem0 entries by default
- Plugin's own ``<plugin-dir>/.api_key`` file — plugin-managed
"""
from __future__ import annotations
import contextlib
import json
import os
import re
import tempfile
from pathlib import Path
# Files we know how to update safely.
_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
_SHELL_RCS = [Path.home() / ".zshrc", Path.home() / ".bashrc", Path.home() / ".bash_profile"]
def sync_api_key(api_key: str) -> list[str]:
"""Propagate ``api_key`` into known ecosystem touchpoints.
Returns the list of paths actually updated. Empty list means nothing
needed updating (either targets didn't exist or already had this value).
"""
if not api_key:
return []
updated: list[str] = []
if _update_claude_settings(_CLAUDE_SETTINGS, api_key):
updated.append(str(_CLAUDE_SETTINGS))
for rc in _SHELL_RCS:
if _update_shell_rc(rc, api_key):
updated.append(str(rc))
return updated
def _update_claude_settings(path: Path, api_key: str) -> bool:
"""Update ``env.MEM0_API_KEY`` in path. Returns True if file was changed."""
if not path.is_file():
return False
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return False
env = data.get("env")
if not isinstance(env, dict) or "MEM0_API_KEY" not in env:
# No existing entry — don't create one.
return False
if env["MEM0_API_KEY"] == api_key:
return False # already in sync
env["MEM0_API_KEY"] = api_key
_atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
return True
# Match `export MEM0_API_KEY="..."` (or single quotes, or no quotes).
# Use [ \t]* (not \s*) for trailing whitespace so a trailing newline at
# end-of-file is preserved when MEM0_API_KEY is the last line.
_RC_LINE = re.compile(
r'^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["\']?)([^"\'\n]*)(["\']?)[ \t]*$',
re.MULTILINE,
)
def _update_shell_rc(path: Path, api_key: str) -> bool:
"""Update an existing ``export MEM0_API_KEY=...`` line in path."""
if not path.is_file():
return False
try:
text = path.read_text(encoding="utf-8")
except OSError:
return False
match = _RC_LINE.search(text)
if not match:
return False # no existing line
if match.group(3) == api_key:
return False
new_text = _RC_LINE.sub(lambda m: f'{m.group(1)}"{api_key}"', text, count=1)
_atomic_write_text(path, new_text)
return True
def _atomic_write_text(path: Path, content: str) -> None:
"""Write content to path atomically (temp + rename)."""
dirname = path.parent
fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=dirname)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
# Preserve mode if the original existed.
if path.exists():
os.chmod(tmp_path, path.stat().st_mode & 0o777)
os.replace(tmp_path, path)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
+45
View File
@@ -0,0 +1,45 @@
"""Agent mode state — set by the root callback, read by commands and branding."""
from __future__ import annotations
_agent_mode: bool = False
_current_command: str = ""
_pending_notice: str = ""
def is_agent_mode() -> bool:
return _agent_mode
def set_agent_mode(val: bool) -> None:
global _agent_mode
_agent_mode = val
def get_current_command() -> str:
return _current_command
def set_current_command(name: str) -> None:
global _current_command
_current_command = name
def capture_notice(notice: str | None) -> None:
"""Stash a Mem0 backend notice for end-of-command surfacing.
Called from the platform backend after each response so the notice can
be printed once per command (regardless of how many sub-requests fired).
Last-write-wins is fine — the message text is identical across requests.
"""
global _pending_notice
if notice:
_pending_notice = notice
def take_notice() -> str:
"""Return and clear the pending notice."""
global _pending_notice
msg = _pending_notice
_pending_notice = ""
return msg
+155
View File
@@ -0,0 +1,155 @@
"""CLI telemetry — anonymous usage tracking via PostHog.
Sends fire-and-forget events to PostHog by spawning a detached subprocess
(telemetry_sender.py). The parent CLI process exits immediately; the
subprocess handles email resolution, caching, and the HTTP POST.
Disable with: MEM0_TELEMETRY=false
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import os
import platform
import subprocess
import sys
import uuid
from typing import Any
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
def _is_telemetry_enabled() -> bool:
val = os.environ.get("MEM0_TELEMETRY", "true").lower()
return val not in ("false", "0", "no")
def _get_or_create_anonymous_id() -> str:
"""Return a persistent per-machine anonymous ID, generating one if needed.
Stored in ~/.mem0/config.json under `telemetry.anonymous_id` so that
repeat runs on the same machine share one PostHog identity instead of
collapsing into a single shared fallback string.
"""
from mem0_cli.config import load_config, save_config
config = load_config()
if config.telemetry.anonymous_id:
return config.telemetry.anonymous_id
new_id = f"cli-anon-{uuid.uuid4().hex}"
config.telemetry.anonymous_id = new_id
with contextlib.suppress(Exception):
save_config(config)
return new_id
def _get_distinct_id() -> str:
"""Return a stable anonymous identifier for the current user.
Priority: cached user_email (from /v1/ping/) > MD5(api_key) >
persistent per-machine anonymous ID.
"""
try:
from mem0_cli.config import load_config
config = load_config()
if config.platform.user_email:
return config.platform.user_email
if config.platform.api_key:
return hashlib.md5(config.platform.api_key.encode()).hexdigest()
except Exception:
pass
try:
return _get_or_create_anonymous_id()
except Exception:
return f"cli-anon-{uuid.uuid4().hex}"
def capture_event(
event_name: str,
properties: dict[str, Any] | None = None,
pre_resolved_email: str | None = None,
) -> None:
"""Fire a PostHog event via a detached subprocess (non-blocking).
When *pre_resolved_email* is provided (e.g. from an upfront ping
validation), it is used directly as the PostHog distinct ID and the
subprocess skips its own ``/v1/ping/`` call.
"""
if not _is_telemetry_enabled():
return
try:
from mem0_cli import __version__
from mem0_cli.config import CONFIG_FILE, load_config, save_config
config = load_config()
distinct_id = pre_resolved_email or _get_distinct_id()
# Detect anonymous → identified transition. If a stored anonymous_id
# exists and we just resolved to a real identity, fire a one-shot
# $identify event so PostHog stitches the pre-signup history onto
# the authenticated profile. Clear the stored id so we don't re-alias.
anon_id_to_alias: str | None = None
if (
distinct_id
and not distinct_id.startswith("cli-anon-")
and config.telemetry.anonymous_id
):
anon_id_to_alias = config.telemetry.anonymous_id
config.telemetry.anonymous_id = ""
with contextlib.suppress(Exception):
save_config(config)
# M4: every cli.* event carries agent_mode based on the config flag
# (unclaimed Agent Mode key). This is the growth-doc property used to
# join init → add → search funnels in PostHog.
payload = {
"api_key": POSTHOG_API_KEY,
"distinct_id": distinct_id,
"event": event_name,
"properties": {
"source": "CLI",
"language": "python",
"cli_version": __version__,
"agent_mode": bool(config.platform.agent_mode),
"python_version": sys.version,
"os": sys.platform,
"os_version": platform.version(),
"$process_person_profile": False,
"$lib": "posthog-python",
**(properties or {}),
},
}
context = {
"payload": payload,
"posthog_host": POSTHOG_HOST,
"needs_email": not distinct_id or "@" not in distinct_id,
"mem0_api_key": config.platform.api_key or "",
"mem0_base_url": config.platform.base_url or "https://api.mem0.ai",
"config_path": str(CONFIG_FILE),
"anon_distinct_id_to_alias": anon_id_to_alias,
}
child = subprocess.Popen(
[sys.executable, "-m", "mem0_cli.telemetry_sender"],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
close_fds=True,
text=True,
)
if child.stdin:
with contextlib.suppress(Exception):
child.stdin.write(json.dumps(context))
with contextlib.suppress(Exception):
child.stdin.close()
except Exception:
pass
+119
View File
@@ -0,0 +1,119 @@
"""Standalone telemetry sender — runs as a detached subprocess.
Usage: python -m mem0_cli.telemetry_sender (JSON context is read from stdin;
a single argv argument is still accepted as a legacy fallback)
This module is spawned by telemetry.capture_event() and runs independently
of the parent CLI process. It:
1. Resolves the user's email via /v1/ping/ if not already cached
2. Caches the email in ~/.mem0/config.json for future runs
3. Sends the PostHog event
All errors are silently swallowed — this process must never produce output
or affect the user experience.
"""
from __future__ import annotations
import json
import sys
import urllib.request
def _load_context() -> dict:
"""Load telemetry context from stdin, falling back to argv for compatibility."""
raw = ""
if not sys.stdin.isatty():
raw = sys.stdin.read().strip()
if not raw and len(sys.argv) > 1:
raw = sys.argv[1]
return json.loads(raw)
def main() -> None:
ctx = _load_context()
payload = ctx["payload"]
if ctx.get("needs_email") and ctx.get("mem0_api_key"):
_resolve_and_cache_email(ctx, payload)
# Fire $identify *after* email resolution so PostHog links the stored
# anonymous id directly to the final identity (email, not the api-key
# hash). The regular event is sent next so it lands under the merged
# profile.
anon_id = ctx.get("anon_distinct_id_to_alias")
if anon_id:
_send_identify_event(ctx, payload, anon_id)
_send_posthog_event(ctx["posthog_host"], payload)
def _send_identify_event(ctx: dict, payload: dict, anon_id: str) -> None:
"""Send a PostHog $identify event aliasing anon_id → payload['distinct_id']."""
identify_payload = {
"api_key": payload["api_key"],
"event": "$identify",
"distinct_id": payload["distinct_id"],
"properties": {
"$anon_distinct_id": anon_id,
"$lib": payload.get("properties", {}).get("$lib", "posthog-python"),
},
}
_send_posthog_event(ctx["posthog_host"], identify_payload)
def _resolve_and_cache_email(ctx: dict, payload: dict) -> None:
"""Call /v1/ping/ to get the user's email, update the payload, and cache it."""
try:
ping_url = ctx["mem0_base_url"].rstrip("/") + "/v1/ping/"
req = urllib.request.Request(
ping_url,
headers={
"Authorization": "Token " + ctx["mem0_api_key"],
"Content-Type": "application/json",
},
)
resp = urllib.request.urlopen(req, timeout=10)
data = json.loads(resp.read())
email = data.get("user_email")
if email:
payload["distinct_id"] = email
_cache_email(ctx.get("config_path"), email)
except Exception:
pass
def _cache_email(config_path: str | None, email: str) -> None:
"""Write user_email into the config file for future runs."""
if not config_path:
return
try:
with open(config_path) as f:
cfg = json.load(f)
cfg.setdefault("platform", {})["user_email"] = email
with open(config_path, "w") as f:
json.dump(cfg, f, indent=2)
except Exception:
pass
def _send_posthog_event(posthog_host: str, payload: dict) -> None:
"""POST the event to PostHog."""
try:
body = json.dumps(payload).encode()
req = urllib.request.Request(
posthog_host,
data=body,
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=10)
except Exception:
pass
if __name__ == "__main__":
import contextlib
with contextlib.suppress(Exception):
main()
View File
+146
View File
@@ -0,0 +1,146 @@
"""Shared fixtures for mem0 CLI tests."""
from __future__ import annotations
import os
from unittest.mock import MagicMock
import pytest
from mem0_cli.backend.base import Backend
from mem0_cli.config import Mem0Config
@pytest.fixture(autouse=True)
def isolate_config(tmp_path, monkeypatch):
"""Redirect config to a temp directory so tests don't touch real config."""
fake_config_dir = tmp_path / ".mem0"
fake_config_file = fake_config_dir / "config.json"
monkeypatch.setattr("mem0_cli.config.CONFIG_DIR", fake_config_dir)
monkeypatch.setattr("mem0_cli.config.CONFIG_FILE", fake_config_file)
# Also patch the commands that import config
monkeypatch.setattr("mem0_cli.commands.config_cmd.CONFIG_DIR", fake_config_dir, raising=False)
# Clear any MEM0 env vars
for key in list(os.environ.keys()):
if key.startswith("MEM0_"):
monkeypatch.delenv(key, raising=False)
return fake_config_dir
@pytest.fixture
def mock_backend():
"""Return a mock backend with all methods stubbed."""
backend = MagicMock(spec=Backend)
# Default return values
backend.add.return_value = {
"results": [
{
"id": "abc-123-def-456",
"memory": "User prefers dark mode",
"event": "ADD",
}
]
}
backend.search.return_value = [
{
"id": "abc-123-def-456",
"memory": "User prefers dark mode",
"score": 0.92,
"created_at": "2026-02-15T10:30:00Z",
"categories": ["preferences"],
},
{
"id": "ghi-789-jkl-012",
"memory": "User uses vim keybindings",
"score": 0.78,
"created_at": "2026-03-01T14:00:00Z",
"categories": ["tools"],
},
]
backend.get.return_value = {
"id": "abc-123-def-456",
"memory": "User prefers dark mode",
"created_at": "2026-02-15T10:30:00Z",
"updated_at": "2026-02-20T08:00:00Z",
"metadata": {"source": "onboarding"},
"categories": ["preferences"],
}
backend.list_memories.return_value = [
{
"id": "abc-123-def-456",
"memory": "User prefers dark mode",
"created_at": "2026-02-15T10:30:00Z",
"categories": ["preferences"],
},
{
"id": "ghi-789-jkl-012",
"memory": "User uses vim keybindings",
"created_at": "2026-03-01T14:00:00Z",
"categories": ["tools"],
},
]
backend.update.return_value = {"id": "abc-123-def-456", "memory": "Updated memory"}
backend.delete.return_value = {"status": "deleted"}
backend.status.return_value = {
"connected": True,
"backend": "platform",
"base_url": "https://api.mem0.ai",
}
backend.delete_entities.return_value = {"message": "Entity deleted"}
backend.entities.return_value = [
{"name": "alice", "count": 5},
{"name": "bob", "count": 3},
]
backend.list_events.return_value = [
{
"id": "evt-abc-123-def-456",
"event_type": "ADD",
"status": "SUCCEEDED",
"graph_status": None,
"latency": 1234.5,
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-01T10:00:01Z",
},
{
"id": "evt-def-456-ghi-789",
"event_type": "SEARCH",
"status": "PENDING",
"graph_status": None,
"latency": None,
"created_at": "2026-04-01T10:01:00Z",
"updated_at": "2026-04-01T10:01:00Z",
},
]
backend.get_event.return_value = {
"id": "evt-abc-123-def-456",
"event_type": "ADD",
"status": "SUCCEEDED",
"graph_status": "SUCCEEDED",
"latency": 1234.5,
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-01T10:00:01Z",
"results": [
{
"id": "mem-abc-123",
"event": "ADD",
"user_id": "alice",
"data": {"memory": "User prefers dark mode"},
}
],
}
return backend
@pytest.fixture
def sample_config():
"""Return a sample config object."""
config = Mem0Config()
config.platform.api_key = "m0-test-key-12345678"
config.platform.base_url = "https://api.mem0.ai"
return config
+160
View File
@@ -0,0 +1,160 @@
"""Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
Mirror of ``cli/node/tests/agent-mode.test.ts`` — both files MUST stay in
sync so that the Python and Node CLIs expose an identical surface for the
Agent Mode entrypoint. If you add a flag here, add the same assertion on
the Node side (and vice versa).
Network-bound bootstrap is covered by the platform-side E2E suite
(``backend/tests/e2e/test_05_agent_mode.py``); these tests only verify
the CLI surface that ships in the binary.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import pytest
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
def _strip_ansi(text: str) -> str:
return _ANSI_RE.sub("", text)
def _run(args: list[str], home_dir: str | None = None) -> subprocess.CompletedProcess:
env = os.environ.copy()
for key in list(env.keys()):
if key.startswith("MEM0_"):
del env[key]
env.pop("FORCE_COLOR", None)
env["PYTHONIOENCODING"] = "utf-8"
if home_dir:
env["HOME"] = home_dir
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", *args],
capture_output=True,
encoding="utf-8",
env=env,
timeout=15,
)
return subprocess.CompletedProcess(
args=result.args,
returncode=result.returncode,
stdout=_strip_ansi(result.stdout),
stderr=_strip_ansi(result.stderr),
)
@pytest.fixture
def clean_home(tmp_path):
return str(tmp_path)
class TestInitFlagSurface:
"""`mem0 init --help` must expose the Agent Mode flags."""
def test_init_help_lists_agent_flag(self):
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--agent" in result.stdout
def test_init_help_describes_agent_mode(self):
result = _run(["init", "--help"])
assert result.returncode == 0
# Description must mention what --agent actually does so an agent
# reading the help can self-discover the bootstrap entrypoint.
assert "Agent Mode" in result.stdout or "unattended" in result.stdout.lower()
def test_init_help_lists_source_flag(self):
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--source" in result.stdout
def test_init_help_lists_email_and_code(self):
# Claim flow flags must remain present alongside Agent Mode flags.
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--email" in result.stdout
assert "--code" in result.stdout
class TestArgvPreprocessing:
"""`--agent` on `init` must reach init_cmd, not be eaten by the global preprocessor.
Regression for the bug where the top-level `--agent` JSON-alias was
stripped from ``sys.argv`` before Typer could bind it to the init
subcommand, making ``mem0 init --agent`` indistinguishable from a
plain ``mem0 init`` (interactive wizard).
"""
def test_init_with_agent_reaches_subcommand(self, clean_home):
# We can't hit a real backend in unit tests, so we point the CLI at
# a guaranteed-dead URL and assert the failure is the bootstrap
# request failing — proving the --agent flag was honored and the
# bootstrap branch ran, not the interactive wizard.
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", "init", "--agent"],
capture_output=True,
encoding="utf-8",
env={
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
"HOME": clean_home,
"MEM0_BASE_URL": "http://127.0.0.1:1", # blackhole
"FORCE_COLOR": "0",
"PYTHONIOENCODING": "utf-8",
},
timeout=15,
)
combined = _strip_ansi(result.stdout + result.stderr).lower()
# Either we got a connection/network error from the bootstrap POST,
# or the CLI surfaced an Agent Mode-specific failure message.
assert (
"agent" in combined
or "connect" in combined
or "network" in combined
or "fetch" in combined
or "bootstrap" in combined
), f"Expected bootstrap attempt, got: {combined!r}"
class TestJsonEnvelopeParity:
"""`mem0 init --agent --json` should produce a JSON envelope on success.
Without a live backend we can only assert the failure shape: when the
backend is unreachable, the CLI must still exit non-zero AND not crash
on a Python traceback (which would mean we leaked an exception past
the agent-mode handler).
"""
def test_init_agent_json_no_traceback_on_network_failure(self, clean_home):
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", "init", "--agent", "--json"],
capture_output=True,
encoding="utf-8",
env={
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
"HOME": clean_home,
"MEM0_BASE_URL": "http://127.0.0.1:1",
"FORCE_COLOR": "0",
"PYTHONIOENCODING": "utf-8",
},
timeout=15,
)
combined = _strip_ansi(result.stdout + result.stderr)
assert "Traceback (most recent call last)" not in combined
assert result.returncode != 0
class TestInitInCommandList:
"""`mem0 --help` must list `init` so agents walking the top-level help
can discover the Agent Mode entrypoint without prior knowledge."""
def test_top_level_help_lists_init(self):
result = _run(["--help"])
assert result.returncode == 0
assert "init" in result.stdout
+54
View File
@@ -0,0 +1,54 @@
"""Tests for branding and output helpers."""
from __future__ import annotations
from io import StringIO
from rich.console import Console
from mem0_cli.branding import print_banner, print_error, print_info, print_success, print_warning
def _make_console() -> tuple[Console, StringIO]:
buf = StringIO()
return Console(file=buf, force_terminal=False, no_color=True, width=80), buf
class TestBranding:
def test_print_banner(self):
console, buf = _make_console()
print_banner(console)
output = buf.getvalue()
# Banner contains the mem0 ASCII art and tagline
assert "Memory Layer" in output or "mem" in output.lower()
def test_print_success(self):
console, buf = _make_console()
print_success(console, "It worked!")
output = buf.getvalue()
assert "It worked!" in output
def test_print_error(self):
console, buf = _make_console()
print_error(console, "Something failed", hint="Try this fix")
output = buf.getvalue()
assert "Something failed" in output
assert "Try this fix" in output
def test_print_error_no_hint(self):
console, buf = _make_console()
print_error(console, "Failed")
output = buf.getvalue()
assert "Failed" in output
def test_print_warning(self):
console, buf = _make_console()
print_warning(console, "Watch out")
output = buf.getvalue()
assert "Watch out" in output
def test_print_info(self):
console, buf = _make_console()
print_info(console, "FYI")
output = buf.getvalue()
assert "FYI" in output
+248
View File
@@ -0,0 +1,248 @@
"""Integration tests — invoke CLI as subprocess to test end-to-end.
These tests launch the CLI as a real subprocess, so they must manage
environment isolation themselves (monkeypatch doesn't cross process
boundaries).
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import pytest
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes so substring checks work regardless of color mode."""
return _ANSI_RE.sub("", text)
def _run(
args: list[str],
env_override: dict | None = None,
home_dir: str | None = None,
) -> subprocess.CompletedProcess:
"""Run mem0 CLI command and capture output.
Args:
args: CLI arguments.
env_override: Extra env vars to set.
home_dir: If provided, set HOME to this path so the subprocess
reads config from ``<home_dir>/.mem0/config.json`` instead
of the user's real config. This is critical for tests that
depend on a clean (no API key) or custom config state.
Returns a CompletedProcess whose stdout/stderr have ANSI escape codes
stripped. GitHub Actions sets FORCE_COLOR=1 which causes Rich/Typer to
fragment option names like --user-id into separately-styled ANSI segments,
making plain ``in`` checks fail. Stripping here is version-agnostic and
ensures all assertions see the same plain text regardless of terminal env.
"""
env = os.environ.copy()
# Strip all MEM0_ env vars so tests start clean
for key in list(env.keys()):
if key.startswith("MEM0_"):
del env[key]
env.pop("FORCE_COLOR", None)
env["PYTHONIOENCODING"] = "utf-8"
if home_dir:
env["HOME"] = home_dir
if env_override:
env.update(env_override)
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", *args],
capture_output=True,
encoding="utf-8",
env=env,
)
return subprocess.CompletedProcess(
args=result.args,
returncode=result.returncode,
stdout=_strip_ansi(result.stdout),
stderr=_strip_ansi(result.stderr),
)
@pytest.fixture
def clean_home(tmp_path):
"""Return a temp directory to use as HOME, ensuring no ~/.mem0 exists."""
return str(tmp_path)
class TestCLIIntegration:
"""Tests that only inspect help text / version — no config needed."""
def test_help(self):
result = _run(["--help"])
assert result.returncode == 0
assert "mem0" in result.stdout
assert "add" in result.stdout
assert "search" in result.stdout
def test_add_help(self):
result = _run(["add", "--help"])
assert result.returncode == 0
assert "user-id" in result.stdout
assert "messages" in result.stdout
def test_add_help_has_scope_panel(self):
"""Verify rich_help_panel grouping shows in help output."""
result = _run(["add", "--help"])
assert result.returncode == 0
assert "Scope" in result.stdout
def test_search_help(self):
result = _run(["search", "--help"])
assert result.returncode == 0
assert "top-k" in result.stdout
def test_list_help(self):
result = _run(["list", "--help"])
assert result.returncode == 0
assert "page-size" in result.stdout
def test_delete_help(self):
result = _run(["delete", "--help"])
assert result.returncode == 0
assert "--all" in result.stdout
assert "--entity" in result.stdout
assert "--project" in result.stdout
assert "--force" in result.stdout
assert "--dry-run" in result.stdout
def test_entity_list_help(self):
result = _run(["entity", "list", "--help"])
assert result.returncode == 0
assert "entity-type" in result.stdout.lower() or "entity_type" in result.stdout.lower()
def test_entity_delete_help(self):
result = _run(["entity", "delete", "--help"])
assert result.returncode == 0
assert "--user-id" in result.stdout
assert "--force" in result.stdout
def test_import_help(self):
result = _run(["import", "--help"])
assert result.returncode == 0
def test_no_args_shows_help(self):
"""no_args_is_help=True makes Typer print help and exit with code 2."""
result = _run([])
# Typer returns exit code 2 for "no command given" — this is standard
# Click/Typer behaviour and not an error.
assert result.returncode in (0, 2)
assert "Usage" in result.stdout
class TestCLIIsolated:
"""Tests that need a clean HOME to avoid reading the user's real config."""
def test_add_no_key_errors(self, clean_home):
"""Without an API key, `mem0 add` must fail with a helpful message."""
result = _run(
["add", "test", "--user-id", "alice"],
home_dir=clean_home,
)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert "API key" in combined or "api" in combined.lower() or "Error" in combined
def test_search_no_key_errors(self, clean_home):
"""Without an API key, `mem0 search` must fail."""
result = _run(
["search", "preferences", "--user-id", "alice"],
home_dir=clean_home,
)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert "API key" in combined or "Error" in combined
def test_list_no_key_errors(self, clean_home):
"""Without an API key, `mem0 list` must fail."""
result = _run(["list"], home_dir=clean_home)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert "API key" in combined or "Error" in combined
def test_delete_no_id_no_all_errors(self, clean_home):
"""Delete without memory_id, --all, or --entity must fail."""
result = _run(
["delete", "--api-key", "m0-fake-key"],
home_dir=clean_home,
)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert (
"memory ID" in combined.lower()
or "--all" in combined
or "--entity" in combined
or "Error" in combined
)
def test_config_show_clean(self, clean_home):
"""config show with no config should still work."""
result = _run(["config", "show"], home_dir=clean_home)
assert result.returncode == 0
assert "backend" in result.stdout.lower() or "platform" in result.stdout.lower()
def test_config_set_and_get_roundtrip(self, clean_home):
"""config set then config get should return the set value."""
_run(
["config", "set", "defaults.user_id", "integration-test-user"],
home_dir=clean_home,
)
result = _run(
["config", "get", "defaults.user_id"],
home_dir=clean_home,
)
assert result.returncode == 0
assert "integration-test-user" in result.stdout
def test_import_nonexistent_file(self, clean_home):
"""Importing a nonexistent file should fail gracefully."""
result = _run(
["import", "/nonexistent/file.json", "--api-key", "m0-fake"],
home_dir=clean_home,
)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert "Failed" in combined or "Error" in combined or "error" in combined
def test_add_no_content_errors(self, clean_home):
"""add with no text/messages/file should fail."""
result = _run(
["add", "--user-id", "alice", "--api-key", "m0-fake"],
home_dir=clean_home,
)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert "No content" in combined or "Error" in combined
class TestCLINewFeatures:
"""Tests for MCP parity features: --limit, entities delete."""
def test_search_help_has_limit(self):
result = _run(["search", "--help"])
assert result.returncode == 0
assert "--limit" in result.stdout
def test_delete_entity_via_delete_flag(self):
"""delete --entity should appear in help output."""
result = _run(["delete", "--help"])
assert result.returncode == 0
assert "--entity" in result.stdout
def test_entity_delete_has_scope_options(self):
"""entity delete should expose scope options."""
result = _run(["entity", "delete", "--help"])
assert result.returncode == 0
assert "--user-id" in result.stdout
assert "--force" in result.stdout
assert "--app-id" in result.stdout
assert "--run-id" in result.stdout
File diff suppressed because it is too large Load Diff
+201
View File
@@ -0,0 +1,201 @@
"""Tests for configuration management."""
from __future__ import annotations
import os
from mem0_cli.config import (
Mem0Config,
get_nested_value,
load_config,
redact_key,
save_config,
set_nested_value,
)
class TestRedactKey:
def test_empty_key(self):
assert redact_key("") == "(not set)"
def test_short_key(self):
assert redact_key("abc") == "ab***"
def test_normal_key(self):
result = redact_key("m0-abcdefgh12345678")
assert result == "m0-a...5678"
assert "abcdefgh" not in result
def test_exact_8_chars(self):
# 8 chars is <= 8, so it gets the short redaction
assert redact_key("12345678") == "12***"
class TestConfig:
def test_default_config(self):
config = Mem0Config()
assert config.platform.base_url == "https://api.mem0.ai"
assert config.platform.api_key == ""
def test_save_and_load(self, isolate_config):
config = Mem0Config()
config.platform.api_key = "m0-test-key"
save_config(config)
loaded = load_config()
assert loaded.platform.api_key == "m0-test-key"
def test_env_var_override(self, isolate_config, monkeypatch):
config = Mem0Config()
config.platform.api_key = "file-key"
save_config(config)
monkeypatch.setenv("MEM0_API_KEY", "env-key")
loaded = load_config()
assert loaded.platform.api_key == "env-key"
def test_load_nonexistent_config(self, isolate_config):
config = load_config()
assert config.platform.api_key == ""
def test_config_file_permissions(self, isolate_config):
config = Mem0Config()
config.platform.api_key = "secret"
save_config(config)
from mem0_cli.config import CONFIG_FILE
mode = os.stat(CONFIG_FILE).st_mode & 0o777
if os.name != "nt":
assert mode == 0o600
def test_defaults_save_and_load(self, isolate_config):
config = Mem0Config()
config.defaults.user_id = "alice"
config.defaults.agent_id = "support-bot"
config.defaults.app_id = "my-app"
config.defaults.run_id = "run-001"
save_config(config)
loaded = load_config()
assert loaded.defaults.user_id == "alice"
assert loaded.defaults.agent_id == "support-bot"
assert loaded.defaults.app_id == "my-app"
assert loaded.defaults.run_id == "run-001"
def test_defaults_env_var_override(self, isolate_config, monkeypatch):
config = Mem0Config()
config.defaults.user_id = "file-user"
save_config(config)
monkeypatch.setenv("MEM0_USER_ID", "env-user")
monkeypatch.setenv("MEM0_AGENT_ID", "env-agent")
loaded = load_config()
assert loaded.defaults.user_id == "env-user"
assert loaded.defaults.agent_id == "env-agent"
def test_backward_compat_no_defaults_key(self, isolate_config):
"""Old config files without 'defaults' key should load fine."""
import json
from mem0_cli.config import CONFIG_FILE, ensure_config_dir
ensure_config_dir()
# Write a config without the "defaults" key
data = {
"version": 1,
"platform": {"api_key": "m0-test", "base_url": "https://api.mem0.ai"},
}
with open(CONFIG_FILE, "w") as f:
json.dump(data, f)
loaded = load_config()
assert loaded.platform.api_key == "m0-test"
assert loaded.defaults.user_id == ""
assert loaded.defaults.agent_id == ""
def test_default_config_has_empty_defaults(self):
config = Mem0Config()
assert config.defaults.user_id == ""
assert config.defaults.agent_id == ""
assert config.defaults.app_id == ""
assert config.defaults.run_id == ""
class TestNestedAccess:
def test_get_nested_value(self):
config = Mem0Config()
config.platform.api_key = "test-key"
assert get_nested_value(config, "platform.api_key") == "test-key"
def test_get_nonexistent_key(self):
config = Mem0Config()
assert get_nested_value(config, "nonexistent.key") is None
def test_set_nested_value(self):
config = Mem0Config()
assert set_nested_value(config, "platform.api_key", "new-key")
assert config.platform.api_key == "new-key"
def test_set_int_value_rejects_invalid_input(self):
config = Mem0Config()
assert set_nested_value(config, "version", "abc") is False
assert config.version == 1
def test_set_nonexistent_key(self):
config = Mem0Config()
assert set_nested_value(config, "nonexistent.key", "val") is False
def test_get_defaults_user_id(self):
config = Mem0Config()
config.defaults.user_id = "alice"
assert get_nested_value(config, "defaults.user_id") == "alice"
def test_set_defaults_user_id(self):
config = Mem0Config()
assert set_nested_value(config, "defaults.user_id", "bob")
assert config.defaults.user_id == "bob"
class TestResolveIds:
def test_cli_flag_overrides_default(self):
from mem0_cli.app import _resolve_ids
config = Mem0Config()
config.defaults.user_id = "default-user"
ids = _resolve_ids(
config,
user_id="cli-user",
agent_id=None,
)
assert ids["user_id"] == "cli-user"
def test_default_used_when_flag_is_none(self):
from mem0_cli.app import _resolve_ids
config = Mem0Config()
config.defaults.user_id = "default-user"
config.defaults.agent_id = "default-agent"
ids = _resolve_ids(config, user_id=None, agent_id=None)
assert ids["user_id"] == "default-user"
assert ids["agent_id"] == "default-agent"
def test_none_when_neither_set(self):
from mem0_cli.app import _resolve_ids
config = Mem0Config()
ids = _resolve_ids(config, user_id=None, agent_id=None)
assert ids["user_id"] is None
assert ids["agent_id"] is None
assert ids["app_id"] is None
assert ids["run_id"] is None
def test_empty_string_treated_as_unset(self):
from mem0_cli.app import _resolve_ids
config = Mem0Config()
config.defaults.user_id = ""
ids = _resolve_ids(config, user_id=None)
assert ids["user_id"] is None
+206
View File
@@ -0,0 +1,206 @@
"""Unit tests for init internals — decision tree primitives + plugin sync.
These tests exercise the units that the high-level subprocess parity tests in
``test_agent_mode.py`` deliberately can't reach:
- ``_ping_key`` must NOT treat network errors as "invalid key" (else a VPN
flap silently mints a new shadow over a working key).
- ``plugin_sync`` must only update entries that already exist, preserve
trailing newlines, and never mangle other lines.
- The 403→ratelimit translation in ``bootstrap_via_backend`` surfaces the
real cause instead of DRF's opaque "You do not have permission" string.
Mirror surface lives in ``cli/node/tests/agent-mode.test.ts``; if you add a
behavioral assertion here, mirror it on the Node side and vice versa.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import httpx
import pytest
from mem0_cli.commands.init_cmd import _ping_key
from mem0_cli.plugin_sync import _update_claude_settings, _update_shell_rc
# ── _ping_key ──────────────────────────────────────────────────────────────
class _Resp:
def __init__(self, status_code: int) -> None:
self.status_code = status_code
def test_ping_key_200_is_valid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(200))
assert _ping_key("k", "http://x") is True
def test_ping_key_401_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(401))
assert _ping_key("k", "http://x") is False
def test_ping_key_403_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(403))
assert _ping_key("k", "http://x") is False
def test_ping_key_5xx_is_not_definitively_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
# Transient upstream failure must NOT cause a shadow to be minted.
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(503))
assert _ping_key("k", "http://x") is True
def test_ping_key_connect_error_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
# Network blip (DNS, captive portal, etc.) — must NOT trigger a re-mint.
def boom(*a, **kw):
raise httpx.ConnectError("nope")
monkeypatch.setattr(httpx, "get", boom)
assert _ping_key("k", "http://x") is True
def test_ping_key_timeout_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
def boom(*a, **kw):
raise httpx.ReadTimeout("slow")
monkeypatch.setattr(httpx, "get", boom)
assert _ping_key("k", "http://x") is True
# ── plugin_sync._update_shell_rc ──────────────────────────────────────────
def test_shell_rc_updates_existing_export_preserves_trailing_newline(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text('export MEM0_API_KEY="old"\n', encoding="utf-8")
changed = _update_shell_rc(rc, "newkey")
assert changed is True
assert rc.read_text(encoding="utf-8") == 'export MEM0_API_KEY="newkey"\n'
def test_shell_rc_does_not_create_new_export(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text("alias ll='ls -la'\n", encoding="utf-8")
changed = _update_shell_rc(rc, "newkey")
assert changed is False
assert rc.read_text(encoding="utf-8") == "alias ll='ls -la'\n"
def test_shell_rc_preserves_surrounding_content(tmp_path) -> None:
rc = tmp_path / ".zshrc"
original = "# my zshrc\nalias ll='ls -la'\nexport MEM0_API_KEY='old'\nexport OTHER=keepme\n"
rc.write_text(original, encoding="utf-8")
_update_shell_rc(rc, "newkey")
after = rc.read_text(encoding="utf-8")
assert "alias ll='ls -la'\n" in after
assert "export OTHER=keepme\n" in after
assert "# my zshrc\n" in after
assert 'export MEM0_API_KEY="newkey"\n' in after
def test_shell_rc_idempotent_when_already_matching(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text('export MEM0_API_KEY="same"\n', encoding="utf-8")
assert _update_shell_rc(rc, "same") is False
def test_shell_rc_missing_file_is_noop(tmp_path) -> None:
rc = tmp_path / ".zshrc" # does not exist
assert _update_shell_rc(rc, "x") is False
# ── plugin_sync._update_claude_settings ────────────────────────────────────
def test_claude_settings_does_not_create_env_block(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"otherKey": 1}), encoding="utf-8")
changed = _update_claude_settings(settings, "newkey")
assert changed is False
# Original content unchanged.
assert json.loads(settings.read_text(encoding="utf-8")) == {"otherKey": 1}
def test_claude_settings_does_not_create_mem0_entry_in_existing_env(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"env": {"OTHER_KEY": "x"}}), encoding="utf-8")
changed = _update_claude_settings(settings, "newkey")
assert changed is False
def test_claude_settings_updates_existing_entry(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(
json.dumps({"env": {"MEM0_API_KEY": "old", "OTHER": "y"}}, indent=2),
encoding="utf-8",
)
changed = _update_claude_settings(settings, "fresh")
assert changed is True
data = json.loads(settings.read_text(encoding="utf-8"))
assert data["env"]["MEM0_API_KEY"] == "fresh"
assert data["env"]["OTHER"] == "y" # other keys preserved
def test_claude_settings_idempotent(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"env": {"MEM0_API_KEY": "same"}}), encoding="utf-8")
assert _update_claude_settings(settings, "same") is False
def test_claude_settings_malformed_json_is_noop(tmp_path) -> None:
settings = tmp_path / "settings.json"
settings.write_text("{ this is not json", encoding="utf-8")
assert _update_claude_settings(settings, "x") is False
# ── bootstrap rate-limit translation ──────────────────────────────────────
def test_bootstrap_403_permission_surfaces_ratelimit(monkeypatch, capsys) -> None:
"""DRF 403 'You do not have permission' must be translated to the daily limit message."""
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend
from mem0_cli.config import Mem0Config
fake_resp = MagicMock()
fake_resp.status_code = 403
fake_resp.text = '{"detail": "You do not have permission to perform this action."}'
fake_resp.json = MagicMock(
return_value={"detail": "You do not have permission to perform this action."}
)
class _Client:
def __init__(self, *a, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def post(self, *a, **kw):
return fake_resp
monkeypatch.setattr(httpx, "Client", _Client)
cfg = Mem0Config()
cfg.platform.base_url = "https://api.mem0.ai"
import typer
with pytest.raises(typer.Exit):
bootstrap_via_backend(cfg)
captured = capsys.readouterr()
combined = captured.out + captured.err
assert "Daily Agent Mode signup limit reached" in combined
assert "permission to perform this action" not in combined
+305
View File
@@ -0,0 +1,305 @@
"""Tests for output formatting."""
from __future__ import annotations
from io import StringIO
from rich.console import Console
from mem0_cli.output import (
format_add_result,
format_memories_table,
format_memories_text,
format_single_memory,
sanitize_agent_data,
)
def _make_console() -> tuple[Console, StringIO]:
buf = StringIO()
return Console(file=buf, force_terminal=False, no_color=True, width=120, highlight=False), buf
SAMPLE_MEMORIES = [
{
"id": "abc-123-def-456",
"memory": "User prefers dark mode",
"score": 0.92,
"created_at": "2026-02-15T10:30:00Z",
"categories": ["preferences"],
},
{
"id": "ghi-789-jkl-012",
"memory": "User uses vim keybindings",
"score": 0.78,
"created_at": "2026-03-01T14:00:00Z",
"categories": ["tools"],
},
]
class TestTextFormat:
def test_format_memories_text(self):
console, buf = _make_console()
format_memories_text(console, SAMPLE_MEMORIES)
output = buf.getvalue()
assert "Found 2 memories" in output
assert "dark mode" in output
assert "vim keybindings" in output
assert "0.92" in output
def test_format_memories_text_empty(self):
console, buf = _make_console()
format_memories_text(console, [])
output = buf.getvalue()
assert "Found 0" in output
def test_format_memories_text_handles_null_fields(self):
console, buf = _make_console()
format_memories_text(console, [{"id": None, "memory": None, "created_at": None}])
assert "Found 1 memories" in buf.getvalue()
class TestTableFormat:
def test_format_memories_table(self):
console, buf = _make_console()
format_memories_table(console, SAMPLE_MEMORIES)
output = buf.getvalue()
assert "dark mode" in output
assert "abc-123-" in output
def test_format_memories_table_empty(self):
console, buf = _make_console()
format_memories_table(console, [])
output = buf.getvalue()
# Should still render (empty table)
assert "ID" in output
def test_format_memories_table_handles_null_fields(self):
console, buf = _make_console()
format_memories_table(console, [{"id": None, "memory": None, "created_at": None}])
output = buf.getvalue()
assert "ID" in output
assert "Memory" in output
class TestSingleMemory:
def test_format_single_memory_text(self):
console, buf = _make_console()
mem = SAMPLE_MEMORIES[0]
format_single_memory(console, mem, "text")
output = buf.getvalue()
assert "dark mode" in output
assert "abc-123-def-456" in output
def test_format_single_memory_json(self):
console, buf = _make_console()
mem = SAMPLE_MEMORIES[0]
format_single_memory(console, mem, "json")
output = buf.getvalue()
assert '"memory"' in output
def test_format_single_memory_handles_null_fields(self):
console, buf = _make_console()
format_single_memory(
console,
{"id": None, "memory": None, "text": "Fallback memory", "created_at": None},
"text",
)
output = buf.getvalue()
assert "Fallback memory" in output
assert "ID:" not in output
class TestAddResult:
def test_format_add_result_text(self):
console, buf = _make_console()
result = {
"results": [
{"id": "abc-123-def-456", "memory": "User prefers dark mode", "event": "ADD"},
]
}
format_add_result(console, result, "text")
output = buf.getvalue()
assert "dark mode" in output
assert "Added" in output
def test_format_add_result_update_event(self):
console, buf = _make_console()
result = {
"results": [
{"id": "abc-123", "memory": "Updated pref", "event": "UPDATE"},
]
}
format_add_result(console, result, "text")
output = buf.getvalue()
assert "Updated" in output
def test_format_add_result_noop(self):
console, buf = _make_console()
result = {
"results": [
{"id": "abc-123", "memory": "Same thing", "event": "NOOP"},
]
}
format_add_result(console, result, "text")
output = buf.getvalue()
assert "No change" in output
def test_format_add_result_quiet(self):
console, buf = _make_console()
result = {"results": [{"id": "abc-123", "memory": "Quiet", "event": "ADD"}]}
format_add_result(console, result, "quiet")
output = buf.getvalue()
assert output.strip() == ""
def test_format_add_result_deduplicates_pending_by_event_id(self):
console, buf = _make_console()
result = {
"results": [
{"status": "PENDING", "event_id": "evt-dup"},
{"status": "PENDING", "event_id": "evt-dup"},
]
}
format_add_result(console, result, "text")
output = buf.getvalue()
# Should show only one PENDING block despite two entries with same event_id
assert output.count("evt-dup") == 2 # event_id line + status hint line
assert output.count("Queued") == 1
def test_format_add_result_empty(self):
console, buf = _make_console()
format_add_result(console, {"results": []}, "text")
output = buf.getvalue()
assert "No memories extracted" in output
class TestSanitizeAgentData:
def test_add_projects_fields(self):
raw = [
{
"id": "abc",
"memory": "test",
"event": "ADD",
"metadata": {"x": 1},
"categories": ["a"],
}
]
result = sanitize_agent_data("add", raw)
assert result == [{"id": "abc", "memory": "test", "event": "ADD"}]
def test_add_pending_passthrough(self):
raw = [{"status": "PENDING", "event_id": "evt-123", "metadata": "noise"}]
result = sanitize_agent_data("add", raw)
assert result == [{"status": "PENDING", "event_id": "evt-123"}]
def test_search_projects_fields(self):
raw = [
{
"id": "abc",
"memory": "test",
"score": 0.9,
"created_at": "2026-01-01",
"categories": ["a"],
"user_id": "u1",
"agent_id": None,
}
]
result = sanitize_agent_data("search", raw)
assert result == [
{
"id": "abc",
"memory": "test",
"score": 0.9,
"created_at": "2026-01-01",
"categories": ["a"],
}
]
def test_list_projects_fields(self):
raw = [
{
"id": "abc",
"memory": "test",
"created_at": "2026-01-01",
"categories": ["a"],
"user_id": "u1",
}
]
result = sanitize_agent_data("list", raw)
assert result == [
{"id": "abc", "memory": "test", "created_at": "2026-01-01", "categories": ["a"]}
]
def test_get_projects_fields(self):
raw = {
"id": "abc",
"memory": "test",
"created_at": "2026-01-01",
"updated_at": "2026-01-02",
"categories": ["a"],
"metadata": {"k": "v"},
"user_id": "u1",
}
result = sanitize_agent_data("get", raw)
assert "user_id" not in result
assert "id" in result and "memory" in result
def test_update_projects_fields(self):
raw = {"id": "abc", "memory": "updated", "extra": "noise"}
result = sanitize_agent_data("update", raw)
assert result == {"id": "abc", "memory": "updated"}
def test_event_list_projects_fields(self):
raw = [
{
"id": "evt-1",
"event_type": "ADD",
"status": "SUCCEEDED",
"graph_status": None,
"latency": 100.0,
"created_at": "2026-01-01",
"updated_at": "2026-01-02",
}
]
result = sanitize_agent_data("event list", raw)
assert result == [
{
"id": "evt-1",
"event_type": "ADD",
"status": "SUCCEEDED",
"latency": 100.0,
"created_at": "2026-01-01",
}
]
assert "updated_at" not in result[0]
assert "graph_status" not in result[0]
def test_event_status_flattens_results(self):
raw = {
"id": "evt-1",
"event_type": "ADD",
"status": "SUCCEEDED",
"latency": 100.0,
"created_at": "2026-01-01",
"updated_at": "2026-01-02",
"results": [
{"id": "mem-1", "event": "ADD", "user_id": "alice", "data": {"memory": "dark mode"}}
],
}
result = sanitize_agent_data("event status", raw)
assert result["results"][0] == {
"id": "mem-1",
"event": "ADD",
"user_id": "alice",
"memory": "dark mode",
}
assert "data" not in result["results"][0]
def test_passthrough_commands(self):
for cmd in ("status", "import", "config show", "config get", "config set"):
data = {"key": "value", "other": "stuff"}
assert sanitize_agent_data(cmd, data) == data
def test_none_data(self):
assert sanitize_agent_data("add", None) is None
+46
View File
@@ -0,0 +1,46 @@
"""Tests for the Platform backend (mem0 Platform API client)."""
from __future__ import annotations
from unittest.mock import patch
from mem0_cli.backend.platform import PlatformBackend
from mem0_cli.config import PlatformConfig
def _make_backend() -> PlatformBackend:
# api_key/base_url are only used to build the httpx client; every test here
# patches _request, so no real network calls are made.
return PlatformBackend(PlatformConfig(api_key="test-key", base_url="https://api.mem0.ai"))
class TestDeleteEntities:
def test_multiple_entities_returns_all_results(self):
backend = _make_backend()
responses = {
"/v2/entities/user/alice/": {"message": "user deleted"},
"/v2/entities/agent/bob/": {"message": "agent deleted"},
}
with patch.object(backend, "_request") as mock_request:
mock_request.side_effect = lambda method, path, **kw: responses[path]
result = backend.delete_entities(user_id="alice", agent_id="bob")
# Regression: previously only the last entity's response survived.
assert result == {
"user": {"message": "user deleted"},
"agent": {"message": "agent deleted"},
}
assert mock_request.call_count == 2
def test_single_entity_keyed_by_type(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value={"message": "user deleted"}):
result = backend.delete_entities(user_id="alice")
assert result == {"user": {"message": "user deleted"}}
def test_no_entities_raises(self):
backend = _make_backend()
import pytest
with pytest.raises(ValueError):
backend.delete_entities()
@@ -0,0 +1,43 @@
from unittest.mock import MagicMock
from mem0_cli.backend.platform import PlatformBackend
def _backend(sample_config):
backend = PlatformBackend(sample_config.platform)
backend._client = MagicMock()
backend._client.request.return_value = MagicMock(
status_code=200,
json=lambda: {"message": "ok"},
headers={},
raise_for_status=lambda: None,
)
return backend
def test_memory_id_path_segments_are_encoded(sample_config):
backend = _backend(sample_config)
backend.get("mem/a?b#c")
backend.update("mem/a?b#c", content="updated")
backend.delete("mem/a?b#c")
paths = [call.args[1] for call in backend._client.request.call_args_list]
assert paths == [
"/v1/memories/mem%2Fa%3Fb%23c/",
"/v1/memories/mem%2Fa%3Fb%23c/",
"/v1/memories/mem%2Fa%3Fb%23c/",
]
def test_entity_and_event_path_segments_are_encoded(sample_config):
backend = _backend(sample_config)
backend.delete_entities(user_id="org/team?active#frag")
backend.get_event("evt/a?b#c")
paths = [call.args[1] for call in backend._client.request.call_args_list]
assert paths == [
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
"/v1/event/evt%2Fa%3Fb%23c/",
]
+80
View File
@@ -0,0 +1,80 @@
"""Tests for telemetry subprocess secret handling."""
from __future__ import annotations
import io
import json
import subprocess
import sys
from mem0_cli.config import Mem0Config, save_config
from mem0_cli.telemetry import capture_event
from mem0_cli.telemetry_sender import _load_context
class _CaptureStdin:
def __init__(self):
self.buffer = ""
self.closed = False
def write(self, value: str) -> None:
self.buffer += value
def close(self) -> None:
self.closed = True
class _DummyProcess:
def __init__(self):
self.stdin = _CaptureStdin()
def test_capture_event_writes_context_to_stdin_not_argv(isolate_config, monkeypatch):
config = Mem0Config()
config.platform.api_key = "m0-test-secret"
config.telemetry.anonymous_id = "cli-anon-test"
save_config(config)
captured: dict[str, object] = {}
proc = _DummyProcess()
def fake_popen(args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
return proc
monkeypatch.setattr("mem0_cli.telemetry.subprocess.Popen", fake_popen)
capture_event("unit_test_event", {"case": "stdin-secret"})
argv = captured["args"]
assert argv == [sys.executable, "-m", "mem0_cli.telemetry_sender"]
assert all("m0-test-secret" not in arg for arg in argv)
kwargs = captured["kwargs"]
assert kwargs["stdin"] == subprocess.PIPE
assert kwargs["text"] is True
ctx = json.loads(proc.stdin.buffer)
assert ctx["mem0_api_key"] == "m0-test-secret"
assert ctx["payload"]["event"] == "unit_test_event"
assert proc.stdin.closed
def test_load_context_reads_from_stdin(monkeypatch):
monkeypatch.setattr("sys.argv", ["telemetry_sender"])
monkeypatch.setattr("sys.stdin", io.StringIO('{"payload": {"event": "stdin"}}'))
ctx = _load_context()
assert ctx["payload"]["event"] == "stdin"
def test_load_context_falls_back_to_argv(monkeypatch):
monkeypatch.setattr("sys.argv", ["telemetry_sender", '{"payload": {"event": "argv"}}'])
monkeypatch.setattr("sys.stdin", io.StringIO(""))
ctx = _load_context()
assert ctx["payload"]["event"] == "argv"