From 3225e308590e52c8e6620b571ce4a1b6c9e0de56 Mon Sep 17 00:00:00 2001 From: Saket Aryan Date: Sat, 28 Mar 2026 05:03:01 +0530 Subject: [PATCH] feat: add official mem0 CLI (Python & TypeScript) (#4575) --- README.md | 14 + cli/CLI_SPECIFICATION.md | 1440 ++++++++++++ cli/README.md | 98 + cli/cli-spec.json | 543 +++++ cli/node/README.md | 75 + cli/node/development.md | 91 + cli/node/package.json | 39 + cli/node/pnpm-lock.yaml | 2066 +++++++++++++++++ cli/node/src/backend/base.ts | 115 + cli/node/src/backend/index.ts | 7 + cli/node/src/backend/platform.ts | 303 +++ cli/node/src/branding.ts | 145 ++ cli/node/src/commands/config.ts | 90 + cli/node/src/commands/entities.ts | 139 ++ cli/node/src/commands/init.ts | 182 ++ cli/node/src/commands/memory.ts | 487 ++++ cli/node/src/commands/utils.ts | 139 ++ cli/node/src/config.ts | 159 ++ cli/node/src/help.ts | 374 +++ cli/node/src/index.ts | 501 ++++ cli/node/src/output.ts | 230 ++ cli/node/tests/branding.test.ts | 98 + cli/node/tests/cli-integration.test.ts | 156 ++ cli/node/tests/commands.test.ts | 221 ++ cli/node/tests/config.test.ts | 113 + cli/node/tests/output.test.ts | 115 + cli/node/tests/setup.ts | 75 + cli/node/tsconfig.json | 18 + cli/python/Makefile | 30 + cli/python/README.md | 29 + cli/python/development.md | 76 + cli/python/pyproject.toml | 77 + cli/python/src/mem0_cli/__init__.py | 3 + cli/python/src/mem0_cli/__main__.py | 5 + cli/python/src/mem0_cli/app.py | 1021 ++++++++ cli/python/src/mem0_cli/backend/__init__.py | 5 + cli/python/src/mem0_cli/backend/base.py | 113 + cli/python/src/mem0_cli/backend/platform.py | 325 +++ cli/python/src/mem0_cli/branding.py | 130 ++ cli/python/src/mem0_cli/commands/__init__.py | 1 + .../src/mem0_cli/commands/config_cmd.py | 108 + cli/python/src/mem0_cli/commands/entities.py | 133 ++ cli/python/src/mem0_cli/commands/init_cmd.py | 195 ++ cli/python/src/mem0_cli/commands/memory.py | 469 ++++ cli/python/src/mem0_cli/commands/utils.py | 142 ++ cli/python/src/mem0_cli/config.py | 176 ++ cli/python/src/mem0_cli/output.py | 242 ++ cli/python/tests/__init__.py | 0 cli/python/tests/conftest.py | 109 + cli/python/tests/test_branding.py | 54 + cli/python/tests/test_cli_integration.py | 238 ++ cli/python/tests/test_commands.py | 1018 ++++++++ cli/python/tests/test_config.py | 240 ++ cli/python/tests/test_output.py | 136 ++ docs/docs.json | 1 + docs/introduction.mdx | 14 + docs/platform/cli.mdx | 303 +++ docs/platform/quickstart.mdx | 12 + 58 files changed, 13438 insertions(+) create mode 100644 cli/CLI_SPECIFICATION.md create mode 100644 cli/README.md create mode 100644 cli/cli-spec.json create mode 100644 cli/node/README.md create mode 100644 cli/node/development.md create mode 100644 cli/node/package.json create mode 100644 cli/node/pnpm-lock.yaml create mode 100644 cli/node/src/backend/base.ts create mode 100644 cli/node/src/backend/index.ts create mode 100644 cli/node/src/backend/platform.ts create mode 100644 cli/node/src/branding.ts create mode 100644 cli/node/src/commands/config.ts create mode 100644 cli/node/src/commands/entities.ts create mode 100644 cli/node/src/commands/init.ts create mode 100644 cli/node/src/commands/memory.ts create mode 100644 cli/node/src/commands/utils.ts create mode 100644 cli/node/src/config.ts create mode 100644 cli/node/src/help.ts create mode 100644 cli/node/src/index.ts create mode 100644 cli/node/src/output.ts create mode 100644 cli/node/tests/branding.test.ts create mode 100644 cli/node/tests/cli-integration.test.ts create mode 100644 cli/node/tests/commands.test.ts create mode 100644 cli/node/tests/config.test.ts create mode 100644 cli/node/tests/output.test.ts create mode 100644 cli/node/tests/setup.ts create mode 100644 cli/node/tsconfig.json create mode 100644 cli/python/Makefile create mode 100644 cli/python/README.md create mode 100644 cli/python/development.md create mode 100644 cli/python/pyproject.toml create mode 100644 cli/python/src/mem0_cli/__init__.py create mode 100644 cli/python/src/mem0_cli/__main__.py create mode 100644 cli/python/src/mem0_cli/app.py create mode 100644 cli/python/src/mem0_cli/backend/__init__.py create mode 100644 cli/python/src/mem0_cli/backend/base.py create mode 100644 cli/python/src/mem0_cli/backend/platform.py create mode 100644 cli/python/src/mem0_cli/branding.py create mode 100644 cli/python/src/mem0_cli/commands/__init__.py create mode 100644 cli/python/src/mem0_cli/commands/config_cmd.py create mode 100644 cli/python/src/mem0_cli/commands/entities.py create mode 100644 cli/python/src/mem0_cli/commands/init_cmd.py create mode 100644 cli/python/src/mem0_cli/commands/memory.py create mode 100644 cli/python/src/mem0_cli/commands/utils.py create mode 100644 cli/python/src/mem0_cli/config.py create mode 100644 cli/python/src/mem0_cli/output.py create mode 100644 cli/python/tests/__init__.py create mode 100644 cli/python/tests/conftest.py create mode 100644 cli/python/tests/test_branding.py create mode 100644 cli/python/tests/test_cli_integration.py create mode 100644 cli/python/tests/test_commands.py create mode 100644 cli/python/tests/test_config.py create mode 100644 cli/python/tests/test_output.py create mode 100644 docs/platform/cli.mdx diff --git a/README.md b/README.md index dedd88cf4..ee2138da7 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,20 @@ Install sdk via npm: npm install mem0ai ``` +### CLI + +Manage memories from your terminal: + +```bash +pip install mem0-cli # or: npm install -g @mem0/cli + +mem0 init +mem0 add "Prefers dark mode and vim keybindings" --user-id alice +mem0 search "What does Alice prefer?" --user-id alice +``` + +See the [CLI documentation](https://docs.mem0.ai/platform/cli) for the full command reference. + ### Basic Usage Mem0 requires an LLM to function, with `gpt-4.1-nano-2025-04-14 from OpenAI as the default. However, it supports a variety of LLMs; for details, refer to our [Supported LLMs documentation](https://docs.mem0.ai/components/llms/overview). diff --git a/cli/CLI_SPECIFICATION.md b/cli/CLI_SPECIFICATION.md new file mode 100644 index 000000000..145b1af18 --- /dev/null +++ b/cli/CLI_SPECIFICATION.md @@ -0,0 +1,1440 @@ +# mem0 CLI SDK Specification + +Complete reference for the mem0 CLI. This document is the authoritative guide for any developer or AI agent working on this SDK. + +--- + +## Table of Contents + +1. [Project Overview](#1-project-overview) +2. [Architecture](#2-architecture) +3. [Complete Command Reference](#3-complete-command-reference) +4. [API Endpoints](#4-api-endpoints) +5. [Configuration](#5-configuration) +6. [Key Behavioral Patterns](#6-key-behavioral-patterns) +7. [Output Modes](#7-output-modes) +8. [Agent-Friendly Design Decisions](#8-agent-friendly-design-decisions) +9. [Adding a New Command](#9-adding-a-new-command) +10. [Adding a New Language Implementation](#10-adding-a-new-language-implementation) + +--- + +## 1. Project Overview + +### What is mem0 CLI? + +mem0 CLI is the official command-line interface for [mem0](https://mem0.ai) -- the memory layer for AI agents. It lets developers and AI agents add, search, list, update, and delete memories via the mem0 Platform API from the terminal. + +### Who is it for? + +- Developers integrating mem0 into their workflows +- AI agents that need persistent memory (the CLI is designed with `--output json` and `help --json` specifically for machine consumption) +- DevOps/CI pipelines that need to manage memories programmatically + +### Project Structure + +The `cli/` directory provides the mem0 CLI in two languages with a shared specification for behavioral consistency. + +| Language | Directory | Package Name | Install Command | +|------------|------------|---------------|----------------------------| +| Python | `python/` | `mem0-cli` | `pip install mem0-cli` | +| TypeScript | `node/` | `@mem0/cli` | `npm install -g @mem0/cli` | + +Both implementations produce a binary named `mem0` and provide **identical CLI behavior** -- same commands, same options, same output formats, same error messages. + +### Version + +Current version: `0.1.0` (defined in `cli-spec.json`, `node/package.json`, and `python/pyproject.toml`). + +### License + +Apache-2.0 + +--- + +## 2. Architecture + +### Directory Layout + +``` +. +├── cli-spec.json # Shared CLI specification (source of truth) +├── README.md # CLI README +├── SDK_SPECIFICATION.md # This file +├── python/ +│ ├── pyproject.toml # Python package config (hatchling build) +│ ├── README.md +│ └── src/mem0_cli/ +│ ├── __init__.py # __version__ +│ ├── app.py # Main Typer app, command registration, helpers +│ ├── config.py # Config loading/saving, env var overrides +│ ├── branding.py # Colors, icons, banner, timed_status, print helpers +│ ├── output.py # Output formatting (text, json, table, quiet) +│ ├── backend/ +│ │ ├── __init__.py # Re-exports get_backend +│ │ ├── base.py # Abstract Backend ABC, get_backend factory +│ │ └── platform.py # PlatformBackend (httpx), error classes +│ └── commands/ +│ ├── memory.py # cmd_add, cmd_search, cmd_get, cmd_list, cmd_update, cmd_delete, cmd_delete_all +│ ├── init_cmd.py # run_init (interactive wizard) +│ ├── config_cmd.py # cmd_config_show, cmd_config_get, cmd_config_set +│ ├── entities.py # cmd_entities_list, cmd_entities_delete +│ └── utils.py # cmd_status, cmd_version, cmd_import +└── node/ + ├── package.json # Node package config (tsup build) + ├── README.md + └── src/ + ├── index.ts # Main Commander.js app, command registration, helpers + ├── config.ts # Config loading/saving, env var overrides + ├── branding.ts # Colors, icons, banner, timedStatus, print helpers + ├── output.ts # Output formatting (text, json, table, quiet) + ├── help.ts # Rich-style help formatter (panels, command ordering) + ├── backend/ + │ ├── index.ts # Re-exports + │ ├── base.ts # Backend interface, error classes, getBackend factory + │ └── platform.ts # PlatformBackend (native fetch), _buildFilters + └── commands/ + ├── memory.ts # cmdAdd, cmdSearch, cmdGet, cmdList, cmdUpdate, cmdDelete, cmdDeleteAll + ├── init.ts # runInit (interactive wizard) + ├── config.ts # cmdConfigShow, cmdConfigGet, cmdConfigSet + ├── entities.ts # cmdEntitiesList, cmdEntitiesDelete + └── utils.ts # cmdStatus, cmdVersion, cmdImport +``` + +### How Both CLIs Mirror Each Other + +Every command, option, argument, and behavioral pattern is implemented identically in both CLIs. The shared `cli-spec.json` is the source of truth for: + +- All command names, descriptions, and usage strings +- All arguments and options (names, types, defaults, help text, panel grouping) +- API endpoint paths and methods +- Branding constants (colors, icons, logo) +- Config schema (sections, fields, env var mappings) +- Error messages and templates +- Option grouping (Scope, Search, Pagination, Filters, Output, Connection) + +### Tech Stacks + +| Concern | Python | Node | +|------------------|-------------------------------------|-----------------------------------------| +| CLI framework | Typer >= 0.9.0 | Commander.js ^12.0.0 | +| Rich output | Rich >= 13.0.0 | chalk ^5.3.0 + cli-table3 ^0.6.4 | +| Spinners | Rich Status | ora ^8.0.0 | +| Boxed panels | Rich Panel | boxen ^7.1.0 | +| HTTP client | httpx >= 0.24.0 | Native fetch (Node >= 18) | +| Build system | Hatchling | tsup ^8.0.0 | +| Test framework | pytest >= 7.0 | vitest ^1.5.0 | +| Linter | ruff >= 0.1.0 | Biome ^1.7.0 | +| Type checking | (ruff type checks) | TypeScript ^5.4.0 | +| Min runtime | Python >= 3.10 | Node >= 18.0.0 | +| Module format | Standard Python package | ESM (`"type": "module"`) | +| Entrypoint | `mem0 = "mem0_cli.app:main"` | `"bin": { "mem0": "./dist/index.js" }` | + +--- + +## 3. Complete Command Reference + +### 3.1 `init` + +Interactive setup wizard for mem0 CLI. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 init [OPTIONS]` | +| needsBackend | No | +| needsConfig | No | +| resolveIds | No | +| resolveGraph | No | +| confirmDangerous | No | + +**Options:** + +| Flag | Type | Required | Default | Help | +|-----------------|--------|----------|---------|------| +| `--api-key` | string | No | - | API key (skip prompt). | +| `-u, --user-id` | string | No | - | Default user ID (skip prompt). | + +**Behavior:** +- If both `--api-key` and `--user-id` are provided, runs non-interactively (no prompts). +- If running in a non-TTY without both flags, prints an error with usage hint and exits. +- Interactive mode: prints banner, prompts for API key (masked with `*`), prompts for default user ID (default: `mem0-cli`), validates connection, saves config. +- API key input uses raw terminal mode to echo `*` for each character typed. Supports backspace and Ctrl+U (clear line). + +**Examples:** +```bash +mem0 init +mem0 init --api-key m0-xxx --user-id alice +``` + +--- + +### 3.2 `add` + +Add a memory from text, messages, file, or stdin. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 add [OPTIONS]` | +| needsBackend | Yes | +| needsConfig | Yes | +| resolveIds | Yes | +| resolveGraph | Yes | +| confirmDangerous | No | +| Output formats | text, json, quiet | +| Default output | text | +| API endpoint | `POST /v1/memories/` | + +**Arguments:** + +| Name | Type | Required | Help | +|--------|--------|----------|------| +| `text` | string | No | Text content to add as a memory. | + +**Options:** + +| Flag | Type | Default | Panel | Help | +|------------------|---------|---------|------------|------| +| `-u, --user-id` | string | - | Scope | Scope to user. | +| `--agent-id` | string | - | Scope | Scope to agent. | +| `--app-id` | string | - | Scope | Scope to app. | +| `--run-id` | string | - | Scope | Scope to run. | +| `--messages` | string | - | - | Conversation messages as JSON. | +| `-f, --file` | path | - | - | Read messages from JSON file. | +| `-m, --metadata` | string | - | - | Custom metadata as JSON. | +| `--immutable` | boolean | false | - | Prevent future updates. | +| `--no-infer` | boolean | false | - | Skip inference, store raw. | +| `--expires` | string | - | - | Expiration date (YYYY-MM-DD). | +| `--categories` | string | - | - | Categories (JSON array or comma-separated). | +| `--graph` | boolean | false | Scope | Enable graph memory extraction. | +| `--no-graph` | boolean | false | Scope | Disable graph memory extraction. | +| `-o, --output` | string | "text" | Output | Output format: text, json, quiet. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**Input priority:** `--file` > `--messages` > text argument > stdin (if piped and no text). + +**Content wrapping:** Text content is wrapped as `[{"role": "user", "content": ""}]` before sending to the API. Messages from `--messages` or `--file` are sent as-is. + +**Examples:** +```bash +mem0 add "I prefer dark mode" --user-id alice +echo "text" | mem0 add -u alice +mem0 add --file msgs.json -u alice -o json +``` + +--- + +### 3.3 `search` + +Search memories by semantic query. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 search [OPTIONS]` | +| needsBackend | Yes | +| needsConfig | Yes | +| resolveIds | Yes | +| resolveGraph | Yes | +| confirmDangerous | No | +| Output formats | text, json, table | +| Default output | text | +| API endpoint | `POST /v2/memories/search/` | + +**Arguments:** + +| Name | Type | Required | Help | +|---------|--------|----------|------| +| `query` | string | Yes | Search query. | + +**Options:** + +| Flag | Type | Default | Panel | Help | +|-------------------------|---------|---------|------------|------| +| `-u, --user-id` | string | - | Scope | Filter by user. | +| `--agent-id` | string | - | Scope | Filter by agent. | +| `--app-id` | string | - | Scope | Filter by app. | +| `--run-id` | string | - | Scope | Filter by run. | +| `-k, --top-k, --limit` | integer | 10 | Search | Number of results. | +| `--threshold` | float | 0.3 | Search | Minimum similarity score. | +| `--rerank` | boolean | false | Search | Enable reranking (Platform only). | +| `--keyword` | boolean | false | Search | Use keyword search. | +| `--filter` | string | - | Search | Advanced filter expression (JSON). | +| `--fields` | string | - | Search | Specific fields to return (comma-separated). | +| `--graph` | boolean | false | Search | Enable graph in search. | +| `--no-graph` | boolean | false | Search | Disable graph in search. | +| `-o, --output` | string | "text" | Output | Output: text, json, table. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**Stdin fallback:** If no query argument is provided and stdin is piped, reads query from stdin. + +**Examples:** +```bash +mem0 search "preferences" --user-id alice +mem0 search "tools" -u alice -o json -k 5 +echo "preferences" | mem0 search -u alice +``` + +--- + +### 3.4 `get` + +Get a specific memory by ID. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 get [OPTIONS]` | +| needsBackend | Yes | +| needsConfig | No | +| resolveIds | No | +| resolveGraph | No | +| confirmDangerous | No | +| Output formats | text, json | +| Default output | text | +| API endpoint | `GET /v1/memories/{memory_id}/` | + +**Arguments:** + +| Name | Type | Required | Help | +|-------------|--------|----------|------| +| `memory_id` | string | Yes | Memory ID to retrieve. | + +**Options:** + +| Flag | Type | Default | Panel | Help | +|----------------|--------|---------|------------|------| +| `-o, --output` | string | "text" | Output | Output: text, json. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**Examples:** +```bash +mem0 get abc-123-def-456 +mem0 get abc-123-def-456 -o json +``` + +--- + +### 3.5 `list` + +List memories with optional filters. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 list [OPTIONS]` | +| needsBackend | Yes | +| needsConfig | Yes | +| resolveIds | Yes | +| resolveGraph | Yes | +| confirmDangerous | No | +| Output formats | text, json, table | +| Default output | table | +| API endpoint | `POST /v2/memories/` | + +**Arguments:** None. + +**Options:** + +| Flag | Type | Default | Panel | Help | +|------------------|---------|---------|------------|------| +| `-u, --user-id` | string | - | Scope | Filter by user. | +| `--agent-id` | string | - | Scope | Filter by agent. | +| `--app-id` | string | - | Scope | Filter by app. | +| `--run-id` | string | - | Scope | Filter by run. | +| `--page` | integer | 1 | Pagination | Page number. | +| `--page-size` | integer | 100 | Pagination | Results per page. | +| `--category` | string | - | Filters | Filter by category. | +| `--after` | string | - | Filters | Created after (YYYY-MM-DD). | +| `--before` | string | - | Filters | Created before (YYYY-MM-DD). | +| `--graph` | boolean | false | Filters | Enable graph in listing. | +| `--no-graph` | boolean | false | Filters | Disable graph in listing. | +| `-o, --output` | string | "table" | Output | Output: text, json, table. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**Examples:** +```bash +mem0 list -u alice +mem0 list --category prefs --after 2024-01-01 -o json +``` + +--- + +### 3.6 `update` + +Update a memory's text or metadata. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 update [text] [OPTIONS]` | +| needsBackend | Yes | +| needsConfig | No | +| resolveIds | No | +| resolveGraph | No | +| confirmDangerous | No | +| Output formats | text, json, quiet | +| Default output | text | +| API endpoint | `PUT /v1/memories/{memory_id}/` | + +**Arguments:** + +| Name | Type | Required | Help | +|-------------|--------|----------|------| +| `memory_id` | string | Yes | Memory ID to update. | +| `text` | string | No | New memory text. | + +**Options:** + +| Flag | Type | Default | Panel | Help | +|------------------|--------|---------|------------|------| +| `-m, --metadata` | string | - | - | Update metadata (JSON). | +| `-o, --output` | string | "text" | Output | Output: text, json, quiet. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**Stdin fallback:** If no text argument is provided and no `--metadata` flag is set and stdin is piped, reads text from stdin. + +**Examples:** +```bash +mem0 update abc-123 "new text" +mem0 update abc-123 --metadata '{"key":"val"}' +echo "new text" | mem0 update abc-123 +``` + +--- + +### 3.7 `delete` + +Delete a memory, all memories matching a scope, or an entity. This is a consolidated command with three mutually exclusive modes. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 delete [memory_id] [OPTIONS]` | +| needsBackend | Yes | +| needsConfig | Yes | +| resolveIds | Yes | +| resolveGraph | No | +| confirmDangerous | Yes | +| Output formats | text, json, quiet | +| Default output | text | +| API endpoint | `DELETE /v1/memories/{memory_id}/` (single), `DELETE /v1/memories/` (--all), `DELETE /v1/entities/` (--entity) | + +**Arguments:** + +| Name | Type | Required | Help | +|-------------|--------|----------|------| +| `memory_id` | string | No | Memory ID to delete (omit when using --all or --entity). | + +**Options:** + +| Flag | Type | Default | Panel | Help | +|-----------------|---------|---------|------------|------| +| `--all` | boolean | false | - | Delete all memories matching scope filters. | +| `--entity` | boolean | false | - | Delete the entity itself and all its memories (cascade). | +| `--project` | boolean | false | - | With --all: delete ALL memories project-wide. | +| `--dry-run` | boolean | false | - | Show what would be deleted without deleting. | +| `--force` | boolean | false | - | Skip confirmation. | +| `-u, --user-id` | string | - | Scope | Scope to user. | +| `--agent-id` | string | - | Scope | Scope to agent. | +| `--app-id` | string | - | Scope | Scope to app. | +| `--run-id` | string | - | Scope | Scope to run. | +| `-o, --output` | string | "text" | Output | Output: text, json, quiet. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**Three modes (mutually exclusive):** + +1. **Single memory:** `mem0 delete ` -- deletes one memory by ID. Cannot combine with `--all` or `--entity`. +2. **Bulk delete:** `mem0 delete --all [scope]` -- deletes all memories matching scope filters. Use `--project` with `--all` to wipe all memories project-wide (sends wildcard `*` entity IDs). Cannot combine with `` or `--entity`. +3. **Entity cascade:** `mem0 delete --entity [scope]` -- deletes the entity itself and all its memories. Cannot combine with `` or `--all`. + +If none of ``, `--all`, or `--entity` is provided, the command prints a usage hint and exits with an error. + +**Dry-run behavior:** +- Single: fetches the memory via `GET`, displays it, then prints "No changes made." +- `--all`: lists matching memories and displays the count, then prints "No changes made." +- `--entity`: shows the scope that would be affected without deleting. + +**Confirmation:** Without `--force`, prompts the user with "[y/N]" confirmation. With `--all --project`, the prompt warns about entire project wipe. + +**`--all --project` wildcard behavior:** Sends `DELETE /v1/memories/` with query params `user_id=*&agent_id=*&app_id=*&run_id=*`. The API typically returns an async response with a `message` field (deletion happens in background). The CLI detects this and prints "Deletion started. Memories will be removed in the background." + +**Examples:** +```bash +mem0 delete abc-123-def-456 # single memory +mem0 delete --all -u alice --force # all memories for user +mem0 delete --all --project --force # project-wide wipe +mem0 delete --entity -u alice --force # entity + all its memories +mem0 delete abc-123 --dry-run # preview single delete +``` + +--- + +### 3.8 `import` + +Import memories from a JSON file. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 import [OPTIONS]` | +| needsBackend | Yes | +| needsConfig | Yes | +| resolveIds | Yes | +| resolveGraph | No | +| confirmDangerous | No | +| Output formats | text, json | +| Default output | text | +| API endpoint | `POST /v1/memories/` (per item) | + +**Arguments:** + +| Name | Type | Required | Help | +|-------------|--------|----------|------| +| `file_path` | string | Yes | JSON file to import. | + +**Options:** + +| Flag | Type | Default | Panel | Help | +|-----------------|--------|---------|------------|------| +| `-u, --user-id` | string | - | Scope | Override user ID. | +| `--agent-id` | string | - | Scope | Override agent ID. | +| `-o, --output` | string | "text" | Output | Output: text, json. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**File format:** JSON array (or single object) where each item has `memory`, `text`, or `content` field for the text, plus optional `user_id`, `agent_id`, and `metadata` fields. + +**Behavior:** Iterates through items, calling `backend.add()` for each. CLI-provided `--user-id` and `--agent-id` override per-item values. Displays progress indicator (every 10 items in Node, Rich progress bar in Python). Reports `added` and `failed` counts. + +**JSON output envelope:** +```json +{ + "status": "success", + "command": "import", + "data": { "added": 42, "failed": 0, "duration_s": 3.14 }, + "duration_ms": 3140 +} +``` + +**Examples:** +```bash +mem0 import data.json --user-id alice +mem0 import data.json -u alice -o json +``` + +--- + +### 3.9 `config show` + +Display current configuration (secrets redacted). + +| Property | Value | +|------------------|-------| +| Usage | `mem0 config show [OPTIONS]` | +| needsBackend | No | +| needsConfig | No | + +**Options:** + +| Flag | Type | Default | Help | +|----------------|--------|---------|------| +| `-o, --output` | string | "text" | Output: text, json. | + +**Behavior:** Loads config (file + env vars), displays as table (text) or JSON envelope. API keys are always redacted using `redact_key()`. + +**Examples:** +```bash +mem0 config show +mem0 config show -o json +``` + +--- + +### 3.10 `config get` + +Get a configuration value. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 config get ` | +| needsBackend | No | +| needsConfig | No | + +**Arguments:** + +| Name | Type | Required | Help | +|-------|--------|----------|------| +| `key` | string | Yes | Config key (e.g. `platform.api_key`). | + +**Valid keys:** `platform.api_key`, `platform.base_url`, `defaults.user_id`, `defaults.agent_id`, `defaults.app_id`, `defaults.run_id`, `defaults.enable_graph`. + +**Behavior:** Prints the value to stdout. API keys are redacted. Unknown keys print an error. + +**Examples:** +```bash +mem0 config get platform.api_key +mem0 config get defaults.user_id +``` + +--- + +### 3.11 `config set` + +Set a configuration value. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 config set ` | +| needsBackend | No | +| needsConfig | No | + +**Arguments:** + +| Name | Type | Required | Help | +|---------|--------|----------|------| +| `key` | string | Yes | Config key (e.g. `platform.api_key`). | +| `value` | string | Yes | Value to set. | + +**Type coercion:** Boolean fields accept `true/1/yes` (case-insensitive) as true, anything else as false. Integer fields are parsed via `parseInt`. String fields are stored as-is. + +**Examples:** +```bash +mem0 config set defaults.user_id alice +mem0 config set platform.base_url https://api.mem0.ai +``` + +--- + +### 3.12 `entity list` + +List all entities of a given type. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 entity list ` | +| needsBackend | Yes | +| needsConfig | No | +| resolveIds | No | +| resolveGraph | No | +| confirmDangerous | No | +| Output formats | table, json | +| Default output | table | +| API endpoint | `GET /v1/entities/` | + +**Arguments:** + +| Name | Type | Required | Choices | Help | +|---------------|--------|----------|--------------------------------|------| +| `entity_type` | string | Yes | `users`, `agents`, `apps`, `runs` | Entity type to list. | + +**Behavior:** Calls `GET /v1/entities/` which returns ALL entity types, then filters client-side using the type map: `users` -> `user`, `agents` -> `agent`, `apps` -> `app`, `runs` -> `run`. Displays a table with "Name / ID" and "Created" columns. + +**Options:** + +| Flag | Type | Default | Panel | Help | +|----------------|--------|---------|------------|------| +| `-o, --output` | string | "table" | Output | Output: table, json. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**Examples:** +```bash +mem0 entity list users +mem0 entity list agents -o json +``` + +--- + +### 3.13 `entity delete` + +Delete an entity and ALL its memories (cascade). Also accessible via `mem0 delete --entity`. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 entity delete [OPTIONS]` | +| needsBackend | Yes | +| needsConfig | No | +| resolveIds | No | +| resolveGraph | No | +| confirmDangerous | Yes | +| Output formats | text, json, quiet | +| Default output | text | +| API endpoint | `DELETE /v1/entities/` | + +**Arguments:** None. + +**Options:** + +| Flag | Type | Default | Panel | Help | +|-----------------|---------|---------|------------|------| +| `-u, --user-id` | string | - | Scope | User ID. | +| `--agent-id` | string | - | Scope | Agent ID. | +| `--app-id` | string | - | Scope | App ID. | +| `--run-id` | string | - | Scope | Run ID. | +| `--dry-run` | boolean | false | - | Show what would be deleted without deleting. | +| `--force` | boolean | false | - | Skip confirmation. | +| `-o, --output` | string | "text" | Output | Output: text, json, quiet. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**Validation:** At least one entity ID is required. Errors if none provided. + +**Examples:** +```bash +mem0 entity delete --user-id alice --force +mem0 entity delete --user-id alice --dry-run +``` + +--- + +### 3.14 `status` + +Check connectivity and authentication. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 status [OPTIONS]` | +| needsBackend | Yes | +| needsConfig | Yes | +| resolveIds | No | +| resolveGraph | No | +| confirmDangerous | No | + +**Options:** + +| Flag | Type | Default | Panel | Help | +|----------------|--------|---------|------------|------| +| `-o, --output` | string | "text" | Output | Output: text, json. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**Behavior:** If config has a default `user_id` or `agent_id`, validates by making a minimal `POST /v2/memories/` with `page=1&page_size=1`. Otherwise validates via `GET /v1/entities/`. Displays connection status in a boxed panel (text) or JSON envelope. + +**JSON output:** +```json +{ + "status": "success", + "command": "status", + "data": { + "connected": true, + "backend": "platform", + "base_url": "https://api.mem0.ai", + "latency_ms": 245 + }, + "duration_ms": 245 +} +``` + +**Examples:** +```bash +mem0 status +mem0 status -o json +``` + +--- + +### 3.15 `help` + +Show help. Use `--json` for machine-readable output (for LLM agents). + +| Property | Value | +|------------------|-------| +| Usage | `mem0 help [OPTIONS]` | +| needsBackend | No | +| needsConfig | No | + +**Options:** + +| Flag | Type | Default | Help | +|----------|---------|---------|------| +| `--json` | boolean | false | Output machine-readable JSON for LLM agents. | + +**Behavior:** +- Without `--json`: prints a human-readable summary of all commands. +- With `--json`: Node outputs the entire `cli-spec.json` file. Python outputs a hand-built JSON object describing all commands, arguments, options, and global options. + +**Examples:** +```bash +mem0 help +mem0 help --json +``` + +--- + +## 4. API Endpoints + +### Base URL + +Default: `https://api.mem0.ai` (configurable via `--base-url`, `MEM0_BASE_URL`, or `platform.base_url` in config). + +### Authentication + +All requests include the header: +``` +Authorization: Token +``` + +The auth header name is `Authorization` and the scheme is `Token` (not Bearer). + +### Timeout + +30 seconds for all requests (Python: `httpx.Client(timeout=30.0)`, Node: `AbortSignal.timeout(30_000)`). + +### Endpoint Reference + +| Operation | Method | Path | Request Body | Query Params | +|-----------------|----------|-------------------------------|------------------|-------------------------| +| Add memory | `POST` | `/v1/memories/` | JSON payload | - | +| Search | `POST` | `/v2/memories/search/` | JSON payload | - | +| Get memory | `GET` | `/v1/memories/{memory_id}/` | - | - | +| List memories | `POST` | `/v2/memories/` | JSON payload | `page`, `page_size` | +| Update memory | `PUT` | `/v1/memories/{memory_id}/` | JSON payload | - | +| Delete memory | `DELETE` | `/v1/memories/{memory_id}/` | - | - | +| Delete all | `DELETE` | `/v1/memories/` | - | entity ID params | +| List entities | `GET` | `/v1/entities/` | - | - | +| Delete entities | `DELETE` | `/v1/entities/` | - | entity ID params | + +### How Filters Are Built (`_buildFilters` / `_build_filters`) + +Both CLIs use an identical filter-building algorithm: + +1. If the caller passed a pre-built filter structure containing `AND` or `OR` keys (e.g. from `--filter`), use it directly. +2. Otherwise, build an array of AND conditions: + - Each entity ID becomes `{"user_id": "..."}`, `{"agent_id": "..."}`, etc. + - Extra filters (category, date ranges) are appended as additional conditions. +3. If exactly 1 condition: return it directly (no wrapping). +4. If 2+ conditions: return `{"AND": [condition1, condition2, ...]}`. +5. If 0 conditions: return `undefined`/`None`. + +**Category filter format:** `{"categories": {"contains": ""}}` + +**Date filter format:** `{"created_at": {"gte": "YYYY-MM-DD"}}` and/or `{"created_at": {"lte": "YYYY-MM-DD"}}`. If both `after` and `before` are set, they merge into one `created_at` object: `{"created_at": {"gte": "...", "lte": "..."}}`. + +### How Pagination Works + +For the `list` command (and `search` internally): +- `page` and `page_size` are sent as **query parameters** (not in the POST body). +- Filters and `enable_graph` are sent in the **POST body**. +- Default: `page=1`, `page_size=100`. + +### Response Normalization + +Both CLIs handle inconsistent API response formats: +``` +# For search and list, the API may return: +result = [...] # Direct array +result = {"results": [...]} # Wrapped in results key +result = {"memories": [...]} # Wrapped in memories key + +# Normalization logic (identical in both CLIs): +if isinstance(result, list): + return result +else: + return result.get("results", result.get("memories", [])) +``` + +### Error Handling + +HTTP errors are mapped to typed exceptions: + +| HTTP Status | Error Class | Message Template | +|-------------|----------------|-----------------| +| 401 | `AuthError` | "Authentication failed. Your API key may be invalid or expired." | +| 404 | `NotFoundError`| "Resource not found: {path}" | +| 400 | `APIError` | "Bad request to {path}: {detail}" (detail extracted from response JSON `.detail` field) | +| 204 | (success) | Returns `{}` (empty object) | +| Other | Generic Error | "HTTP {status}: {statusText}" | + +--- + +## 5. Configuration + +### Config File Location + +- Directory: `~/.mem0/` (created with permissions `0700`) +- File: `~/.mem0/config.json` (written with permissions `0600`) + +### Config Precedence (highest to lowest) + +1. **CLI flags** (`--api-key`, `--base-url`, `--user-id`, etc.) +2. **Environment variables** (`MEM0_API_KEY`, etc.) +3. **Config file** (`~/.mem0/config.json`) +4. **Defaults** (hardcoded) + +### Environment Variables + +| Variable | Config Path | Type | Default | +|--------------------|--------------------------|---------|-----------------------| +| `MEM0_API_KEY` | `platform.api_key` | string | `""` | +| `MEM0_BASE_URL` | `platform.base_url` | string | `"https://api.mem0.ai"` | +| `MEM0_USER_ID` | `defaults.user_id` | string | `""` | +| `MEM0_AGENT_ID` | `defaults.agent_id` | string | `""` | +| `MEM0_APP_ID` | `defaults.app_id` | string | `""` | +| `MEM0_RUN_ID` | `defaults.run_id` | string | `""` | +| `MEM0_ENABLE_GRAPH`| `defaults.enable_graph` | boolean | `false` | + +**Boolean parsing for `MEM0_ENABLE_GRAPH`:** Accepted truthy values are `"true"`, `"1"`, `"yes"` (case-insensitive). Everything else is `false`. + +### Config File JSON Schema + +```json +{ + "version": 1, + "defaults": { + "user_id": "", + "agent_id": "", + "app_id": "", + "run_id": "", + "enable_graph": false + }, + "platform": { + "api_key": "", + "base_url": "https://api.mem0.ai" + } +} +``` + +| Field | Type | Default | Description | +|--------------------------|---------|--------------------------|-------------| +| `version` | integer | `1` | Config schema version. | +| `defaults.user_id` | string | `""` | Default user ID for scoping. | +| `defaults.agent_id` | string | `""` | Default agent ID for scoping. | +| `defaults.app_id` | string | `""` | Default app ID for scoping. | +| `defaults.run_id` | string | `""` | Default run ID for scoping. | +| `defaults.enable_graph` | boolean | `false` | Default graph memory extraction. | +| `platform.api_key` | string | `""` | API key for mem0 Platform. | +| `platform.base_url` | string | `"https://api.mem0.ai"` | Base URL for API requests. | + +### Config Key Map (for `config get`/`config set`) + +The dotted key paths map to internal config objects as follows: + +| Dotted Key | Section | Field | +|-------------------------|------------|--------------| +| `platform.api_key` | platform | apiKey / api_key | +| `platform.base_url` | platform | baseUrl / base_url | +| `defaults.user_id` | defaults | userId / user_id | +| `defaults.agent_id` | defaults | agentId / agent_id | +| `defaults.app_id` | defaults | appId / app_id | +| `defaults.run_id` | defaults | runId / run_id | +| `defaults.enable_graph` | defaults | enableGraph / enable_graph | + +### API Key Redaction + +The `redact_key`/`redactKey` function: +- Empty string: returns `"(not set)"` +- Length <= 8: returns first 2 chars + `"***"` +- Length > 8: returns first 4 chars + `"..."` + last 4 chars + +--- + +## 6. Key Behavioral Patterns + +These patterns are the **contract** both CLIs must follow. Any new implementation must replicate them exactly. + +### 6.1 Entity ID Resolution + +**Function:** `_resolve_ids` (Python) / `resolveIds` (Node) + +**Rule:** If **any** explicit entity ID is provided via CLI flags, only use the explicitly provided IDs. Do NOT mix in defaults for other entity types (which would over-filter). If **no** explicit IDs are provided, fall back to **all** configured defaults. + +``` +if any(user_id, agent_id, app_id, run_id): + # Only use what was explicitly passed; others become None + return {user_id or None, agent_id or None, app_id or None, run_id or None} +else: + # Fall back to all configured defaults + return {config.user_id or None, config.agent_id or None, ...} +``` + +**Rationale:** If a user passes `--user-id alice` and the config also has `agent_id=bot1`, they probably want only Alice's memories, not the intersection of Alice AND bot1. + +### 6.2 Graph Tri-State Resolution + +**Rule:** `--no-graph` > `--graph` > config default. + +``` +if opts.no_graph: return false +if opts.graph: return true +return config.defaults.enable_graph +``` + +This is resolved in the main app file (not in the command handlers) before calling the command function. + +### 6.3 Category Parsing + +**Rule:** Try JSON parse first, fallback to comma-split. + +``` +if categories: + try: + cats = JSON.parse(categories) # e.g. '["a","b"]' + except: + cats = categories.split(",").map(s => s.trim()) # e.g. "a, b" +``` + +### 6.4 Stdin Detection + +**Rule:** Read from stdin if no text argument is provided AND stdin is piped (not a TTY). + +- `add`: If no `text`, no `--messages`, no `--file`, and stdin is piped -> read content from stdin. +- `search`: If no `query` argument and stdin is piped -> read query from stdin. +- `update`: If no `text` argument and no `--metadata` and stdin is piped -> read text from stdin. + +Detection method: +- Python: `not sys.stdin.isatty()` +- Node: `!process.stdin.isTTY` + +Reading method: +- Python: `sys.stdin.read().strip()` +- Node: `fs.readFileSync(0, "utf-8").trim()` + +### 6.5 Filter Building (`_buildFilters`) + +Detailed algorithm (see Section 4 for full description): + +1. If `extraFilters` has `AND` or `OR` key -> return it as-is (pre-built filter). +2. Collect AND conditions from entity IDs. +3. Append extra filters (category, date ranges). +4. 0 conditions -> `undefined`/`None`. +5. 1 condition -> return that single object. +6. 2+ conditions -> `{"AND": [...]}`. + +### 6.6 API Response Normalization + +All `search` and `listMemories`/`list_memories` calls normalize the response: + +``` +if Array.isArray(result): return result +return result.results ?? result.memories ?? [] +``` + +This handles both direct array responses and wrapped `{results: [...]}` or `{memories: [...]}` formats. + +### 6.7 Config File Permissions + +- Config directory (`~/.mem0/`): created with mode `0o700` (owner read+write+execute only). +- Config file (`~/.mem0/config.json`): written with mode `0o600` (owner read+write only). +- Python uses `os.chmod()` with `stat.S_IRWXU` (dir) and `stat.S_IRUSR | stat.S_IWUSR` (file). +- Node uses `fs.mkdirSync(..., { mode: 0o700 })` and `fs.chmodSync(file, 0o600)`. + +### 6.8 Timed Status Pattern + +Every API call is wrapped in a spinner + timing pattern: + +**Python:** +```python +with timed_status(err_console, "Adding memory...") as ts: + result = backend.add(...) +``` +Uses Rich `Status` context manager on stderr. On success, prints `ts.success_msg` with elapsed time. On error, prints `ts.error_msg` with elapsed time. + +**Node:** +```typescript +result = await timedStatus("Adding memory...", async (ctx) => { + return backend.add(...); +}); +``` +Uses `ora` spinner on stderr. On success, prints `ctx.successMsg` with elapsed time. On error, prints `ctx.errorMsg` with elapsed time. + +Both use `performance.now()` / `time.perf_counter()` for timing. Elapsed time is formatted as `{seconds:.2f}s`. + +**Key:** Spinners and timing messages always go to **stderr** so they never contaminate machine-readable stdout. + +### 6.9 Error Hierarchy + +``` +AuthError (HTTP 401) -> "Authentication failed. Your API key may be invalid or expired." +NotFoundError (HTTP 404) -> "Resource not found: {path}" +APIError (HTTP 400) -> "Bad request to {path}: {detail}" +``` + +For HTTP 400, the CLI attempts to extract a `detail` field from the JSON response body. If parsing fails, falls back to `resp.statusText`/`resp.text`. + +HTTP 204 is treated as success with empty body (`{}`). + +Any other non-OK response throws a generic error with `"HTTP {status}: {statusText}"`. + +### 6.10 `delete --all --project` Wildcard Behavior + +When `delete --all --project` is used, the CLI sends wildcard entity IDs (`user_id=*`, `agent_id=*`, `app_id=*`, `run_id=*`) to `DELETE /v1/memories/`. The API typically returns an **asynchronous response** with a `message` field (the deletion happens in the background). The CLI detects the `message` key in the response and prints "Deletion started. Memories will be removed in the background." instead of a success count. + +### 6.11 Non-Interactive Init + +When both `--api-key` and `--user-id` are provided to `mem0 init`: +1. Sets config values directly (no prompts). +2. Validates the platform connection. +3. Saves config to disk. +4. Prints success message. + +When running in a non-TTY (piped input) without both flags, prints an error with usage hint: +``` +"Non-interactive terminal detected and missing required flags." +"Usage: mem0 init --api-key --user-id " +``` + +### 6.12 Add Result Event Display + +The `format_add_result` function handles the API response from `POST /v1/memories/`: + +The response is either a direct array or `{results: [...]}`. Each result item has an `event` field: + +| Event | Icon | Label | +|----------|------|------------| +| `ADD` | `+` | Added | +| `UPDATE` | `~` | Updated | +| `DELETE` | `-` | Deleted | +| `NOOP` | `.` | No change | +| `PENDING`| hourglass | Queued (async) | + +For `PENDING` events, displays "Processing in background" with the event ID. + +--- + +## 7. Output Modes + +### 7.1 Supported Modes Per Command + +| Command | text | json | table | quiet | +|----------------|------|------|-------|-------| +| add | Y | Y | - | Y | +| search | Y | Y | Y | - | +| get | Y | Y | - | - | +| list | Y | Y | Y (default) | - | +| update | Y | Y | - | Y | +| delete | Y | Y | - | Y | +| import | Y | Y | - | - | +| config show | Y | Y | - | - | +| config get | (raw value) | - | - | - | +| config set | (success msg) | - | - | - | +| entity list | - | Y | Y (default) | - | +| entity delete | Y | Y | - | Y | +| status | Y | Y | - | - | +| help | Y | Y (--json) | - | - | + +### 7.2 JSON Envelope Format (`formatJsonEnvelope`) + +Used by `config show`, `status`, and `import` for structured JSON output: + +```json +{ + "status": "success", + "command": "", + "duration_ms": 245, + "scope": {"user_id": "alice", "agent_id": null}, + "count": 10, + "error": null, + "data": { ... } +} +``` + +Fields: +- `status`: Always `"success"` (errors go to stderr before exit). +- `command`: The command name (e.g. `"status"`, `"config show"`, `"import"`). +- `duration_ms`: Optional, elapsed time in milliseconds. +- `scope`: Optional, active entity scope. +- `count`: Optional, result count. +- `error`: Optional, error message string. +- `data`: The primary payload. + +### 7.3 Text Output + +- `formatMemoriesText`: Numbered list with memory text, score, ID (first 8 chars), created date, and category, separated by ` . ` in dim color. +- `formatSingleMemory`: Boxed panel (boxen/Rich Panel) showing memory text, ID, created date, updated date, metadata, and categories. +- `formatAddResult`: Event-based output with icons (+, ~, -, .) and labels. + +### 7.4 Table Output + +Uses `cli-table3` (Node) or `rich.table.Table` (Python) with columns: +- ID (first 8 chars, dim) +- Memory (truncated to 60 chars with "...") +- Category (first category from array) +- Created (YYYY-MM-DD) + +### 7.5 Quiet Mode + +Commands that support `--output quiet` (`add`, `update`, `delete`, `entity delete`) produce **no stdout output** in quiet mode. The operation still executes. Exit code indicates success/failure. + +### 7.6 Error Output + +- **Errors always go to stderr.** Both CLIs use a separate stderr console: + - Python: `Console(stderr=True)` for `print_error` calls. + - Node: `console.error()` in `printError`, spinner on `process.stderr` stream. +- **Data always goes to stdout.** JSON output, table output, and text output all go to stdout. + +### 7.7 Unicode Symbol Degradation + +The `_sym`/`sym` function selects symbols based on terminal capability: + +| Condition | Fancy Symbol | Plain Fallback | +|----------------------------------------|-------------|----------------| +| `!stdout.isTTY` or `NO_COLOR` env set | - | Used | +| Interactive TTY with color | Used | - | + +| Symbol | Fancy | Plain | +|----------|-------|-----------| +| Success | `checkmark` | `[ok]` | +| Error | `X` | `[error]` | +| Warning | `warning triangle` | `[warn]` | +| Info | `diamond` | `*` | + +### 7.8 Result Summary Footer + +After list/search results, a summary line is printed in dim: +``` + 10 results . page 1 . user id=alice . 0.45s +``` + +Format: `{count} result(s) . page {n} . {scope} . {elapsed}s` + +### 7.9 Date Formatting + +All dates are normalized to `YYYY-MM-DD` format for display. The formatting handles ISO 8601 strings with `Z` timezone suffix by replacing it with `+00:00` before parsing. + +--- + +## 8. Agent-Friendly Design Decisions + +### Why `--dry-run` exists on destructive commands + +The `delete` command (all modes) and `entity delete` support `--dry-run`. This lets AI agents preview the effect of a destructive operation before committing. For `delete `, it fetches the memory and displays it. For `delete --all`, it lists matching memories and shows the count. For `delete --entity` / `entity delete`, it shows the scope that would be affected. + +### Why `--force` exists + +Destructive commands (`delete --all`, `delete --entity`, `entity delete`) require interactive confirmation by default. The `--force` flag skips this confirmation, which is essential for: +- AI agents (non-interactive) +- CI/CD pipelines +- Scripting + +### Why `--output json` is on every command + +Every data-returning command supports `--output json` (or `--json` for `help`). This enables machine consumption by AI agents and scripts. JSON output goes to stdout while human-readable spinners/timing go to stderr, so piping `mem0 list -o json | jq .` works cleanly. + +### Why stdin is supported + +Commands `add`, `search`, and `update` can read from stdin when piped. This enables composability: +```bash +echo "I prefer dark mode" | mem0 add -u alice +cat query.txt | mem0 search -u alice +echo "updated text" | mem0 update abc-123 +``` + +### Why `help --json` exists + +The `help --json` command outputs the complete CLI specification in machine-readable JSON. AI agents can call this once to discover all available commands, their arguments, options, and valid values -- enabling self-documenting tool use. + +### Why errors go to stderr + +All error messages, warnings, spinners, and timing information go to stderr. This means `--output json` produces **only** valid JSON on stdout, with no interleaved human-readable messages. An AI agent can safely parse stdout as JSON. + +--- + +## 9. Adding a New Command + +Step-by-step guide for adding a new command to both CLIs. + +### Step 1: Add to `cli-spec.json` + +Add a new entry to the `commands` array with all required fields: + +```json +{ + "name": "my-command", + "description": "What this command does.", + "usage": "mem0 my-command [OPTIONS]", + "needsBackend": true, + "needsConfig": true, + "resolveIds": true, + "resolveGraph": false, + "confirmDangerous": false, + "outputFormats": ["text", "json"], + "defaultOutput": "text", + "arguments": [ + { + "name": "arg", + "type": "string", + "required": true, + "help": "Argument description." + } + ], + "options": [ + { + "name": "user_id", + "flags": ["--user-id", "-u"], + "type": "string", + "help": "Scope to user.", + "panel": "Scope" + }, + { + "name": "output", + "flags": ["--output", "-o"], + "type": "string", + "default": "text", + "help": "Output format.", + "panel": "Output" + } + ], + "apiEndpoint": "myEndpoint" +} +``` + +If the command calls a new API endpoint, also add it to `api.endpoints`. + +### Step 2: Add Backend Method (if new API endpoint) + +**Python** (`python/src/mem0_cli/backend/base.py` and `platform.py`): +1. Add abstract method to `Backend` ABC in `base.py`. +2. Implement in `PlatformBackend` in `platform.py`. + +**Node** (`node/src/backend/base.ts` and `platform.ts`): +1. Add method signature to `Backend` interface in `base.ts`. +2. Implement in `PlatformBackend` class in `platform.ts`. + +### Step 3: Add Command Handler + +**Python** (`python/src/mem0_cli/commands/`): +Create a function `cmd_my_command(backend, ...)` in the appropriate commands file. Follow the patterns: +- Use `timed_status(err_console, "...")` for API calls. +- Use `print_error(err_console, ...)` for errors. +- Use `format_json(console, ...)` for JSON output. +- Raise `typer.Exit(1)` on errors. + +**Node** (`node/src/commands/`): +Create an async function `cmdMyCommand(backend, ...)`. Follow the patterns: +- Use `await timedStatus("...", async () => { ... })` for API calls. +- Use `printError(...)` for errors. +- Use `formatJson(...)` for JSON output. +- Call `process.exit(1)` on errors. + +### Step 4: Register in App Entrypoint + +**Python** (`python/src/mem0_cli/app.py`): +```python +@app.command(name="my-command") +def my_command( + arg: str = typer.Argument(..., help="..."), + output: str = typer.Option("text", "--output", "-o", help="...", rich_help_panel="Output"), + api_key: str | None = typer.Option(None, "--api-key", help="...", rich_help_panel="Connection"), + base_url: str | None = typer.Option(None, "--base-url", help="...", rich_help_panel="Connection"), +) -> None: + """Command description.""" + from mem0_cli.commands.my_module import cmd_my_command + backend, config = _get_backend_and_config(api_key, base_url) + ids = _resolve_ids(config, ...) + cmd_my_command(backend, arg, **ids, output=output) +``` + +**Node** (`node/src/index.ts`): +```typescript +program + .command("my-command ") + .description("Command description.") + .option("-o, --output ", "Output format.", "text") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .action(async (arg, opts) => { + const { cmdMyCommand } = await import("./commands/my-module.js"); + const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); + const ids = resolveIds(config, opts); + await cmdMyCommand(backend, arg, { ...ids, output: opts.output }); + }); +``` + +### Step 5: Add Help Examples + +Both CLIs include examples in the help text: +- Python: In the docstring of the Typer command function. +- Node: Via `.addHelpText("after", "\nExamples:\n $ mem0 ...")`. + +### Step 6: Add to Help Display and Command Order + +**Node** (`node/src/help.ts`): +1. Add `"my-command"` to `COMMAND_ORDER` array (determines display order in `--help`). +2. Add option-to-panel mappings in `OPTION_PANELS["my-command"]`. + +**Python**: Options are assigned to panels via `rich_help_panel="..."` on each `typer.Option()`. The `help` command's `_build_help_json()` function needs a new entry. + +### Step 7: Add to `help` Command Output + +**Python** (`python/src/mem0_cli/app.py`): +1. Add entry in `_build_help_json()` dict. +2. Add line in the `help` command's human-readable output. + +**Node** (`node/src/index.ts`): +Add line in the `help` command's human-readable output listing. + +### Step 8: Add Tests + +- Python: Add tests in `python/tests/`. +- Node: Add tests in `node/src/__tests__/` or similar. + +### Step 9: Update This Specification + +Add the command to the Complete Command Reference (Section 3) with all arguments, options, behavior notes, and examples. + +--- + +## 10. Adding a New Language Implementation + +To add a new language implementation (e.g., Go, Rust, Ruby), you need to replicate the exact behavioral contract defined in `cli-spec.json` and this document. Here is what is required: + +### 10.1 Core Modules to Implement + +| Module | Purpose | +|---------------|---------| +| **config** | Load `~/.mem0/config.json`, apply env var overrides, enforce precedence. Implement `load_config`, `save_config`, `ensure_config_dir`, `redact_key`, `get_nested_value`, `set_nested_value`. | +| **backend/base** | Define the `Backend` interface/trait with all 8 methods: `add`, `search`, `get`, `list_memories`, `update`, `delete`, `delete_entities`, `status`, `entities`. Define error types: `AuthError`, `NotFoundError`, `APIError`. | +| **backend/platform** | Implement `PlatformBackend` with HTTP client. Must implement `_build_filters` logic exactly. Must handle response normalization. Must set `Authorization: Token ` header. 30s timeout. | +| **branding** | Implement print helpers (`print_success`, `print_error`, `print_warning`, `print_info`, `print_scope`), `print_banner`, `timed_status` pattern, `sym` function for Unicode degradation. Colors must match the hex values in `cli-spec.json`. | +| **output** | Implement `format_memories_text`, `format_memories_table`, `format_single_memory`, `format_add_result`, `format_json`, `format_json_envelope`, `print_result_summary`. Date formatting to YYYY-MM-DD. ID truncation to 8 chars. Memory text truncation to 60 chars in tables. | +| **commands/** | Implement all command handlers matching the exact behavior described in Section 3. | +| **app/main** | CLI entrypoint with all commands registered. Implement `resolve_ids`, `resolve_graph`, stdin detection, and the `getBackendAndConfig` helper. | +| **help** | Implement help formatter with grouped option panels (Scope, Search, Pagination, Filters, Output, Connection). Implement `help --json` output. | + +### 10.2 Behavioral Checklist + +Every new implementation MUST: + +- [ ] Read and respect `cli-spec.json` for all command names, descriptions, argument names, option flags, and defaults +- [ ] Implement config precedence: CLI flags > env vars > config file > defaults +- [ ] Implement entity ID resolution (explicit IDs only vs. all defaults) +- [ ] Implement graph tri-state (`--no-graph` > `--graph` > config default) +- [ ] Implement category parsing (JSON first, comma-split fallback) +- [ ] Implement stdin detection and reading for `add`, `search`, `update` +- [ ] Implement `_build_filters` with AND/OR structure +- [ ] Implement response normalization (array vs `{results}` vs `{memories}`) +- [ ] Set config directory permissions to 0700 and file to 0600 +- [ ] Implement timed status with spinner on stderr + elapsed time +- [ ] Implement error hierarchy (AuthError 401, NotFoundError 404, APIError 400) +- [ ] Implement `delete --all --project` with wildcard `*` entity IDs and async response handling +- [ ] Implement non-interactive `init` when both `--api-key` and `--user-id` provided +- [ ] Implement `--dry-run` on delete (all modes) and entity delete +- [ ] Implement `--force` on delete --all, delete --entity, and entity delete +- [ ] Send errors to stderr, data to stdout +- [ ] Implement Unicode symbol degradation for non-TTY/NO_COLOR +- [ ] Implement JSON envelope format for status, config show, import +- [ ] Support `--output` on all data-returning commands +- [ ] Implement `help --json` for machine-readable command discovery +- [ ] Implement masked API key input during `init` (echo `*` per character) +- [ ] Implement confirmation prompts for dangerous commands (unless `--force`) +- [ ] Binary must be named `mem0` + +### 10.3 Package Metadata + +Follow the naming conventions: +- Package description: "The official CLI for mem0 -- the memory layer for AI agents" +- Author: `mem0.ai ` +- License: Apache-2.0 +- Keywords: `mem0`, `memory`, `ai`, `agents`, `cli` + +### 10.4 Testing + +Conformance tests should verify: +- All commands from `cli-spec.json` are registered +- All options from `cli-spec.json` are accepted +- Config precedence is correct +- Entity ID resolution matches the spec +- Filter building produces correct structures +- Output modes produce expected formats +- Error codes are mapped correctly +- Stdin reading works for supported commands diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 000000000..558649e9b --- /dev/null +++ b/cli/README.md @@ -0,0 +1,98 @@ +# mem0 CLI + +The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents. Works with the Mem0 Platform API. Available in Python and Node.js. + +## Installation + +```bash +pip install mem0-cli +``` + +```bash +npm install -g @mem0/cli +``` + +Both packages install a `mem0` binary with identical behavior. + +## Quick start + +```bash +# Authenticate and save config +mem0 init + +# 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 + +# Update a memory +mem0 update "I switched to light mode" + +# Delete a memory +mem0 delete +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `mem0 init` | Interactive setup wizard — configures API key and default user ID | +| `mem0 add` | Add a memory from text, JSON messages, a file, or stdin | +| `mem0 search` | Search memories using natural language | +| `mem0 list` | List memories with optional filters and pagination | +| `mem0 get` | Retrieve a specific memory by ID | +| `mem0 update` | Update the text or metadata of a memory | +| `mem0 delete` | Delete a memory, all memories for a scope, or an entity | +| `mem0 import` | Bulk import memories from a JSON file | +| `mem0 config` | View or modify CLI configuration | +| `mem0 entities` | List or delete entities (users, agents, apps) | +| `mem0 status` | Verify API connection and display current project | +| `mem0 version` | Print the CLI version | + +Run `mem0 --help` for detailed usage on any command. + +## 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` or agent consumption | +| `table` | Tabular format (default for `list`) | +| `quiet` | Minimal — just IDs or status codes | + +```bash +mem0 search "preferences" --user-id alice --output json | jq '.data.results[].memory' +``` + +## 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`) | + +## Implementations + +| Language | Directory | Package | Docs | +|----------|-----------|---------|------| +| Python | [`python/`](./python/) | `mem0-cli` | [README](./python/README.md) | +| TypeScript | [`node/`](./node/) | `@mem0/cli` | [README](./node/README.md) | + +## Documentation + +Full documentation is available at [docs.mem0.ai/platform/cli](https://docs.mem0.ai/platform/cli). + +## License + +Apache-2.0 diff --git a/cli/cli-spec.json b/cli/cli-spec.json new file mode 100644 index 000000000..dc97e2f64 --- /dev/null +++ b/cli/cli-spec.json @@ -0,0 +1,543 @@ +{ + "specVersion": 1, + "cli": { + "name": "mem0", + "version": "0.1.0", + "description": "The Memory Layer for AI Agents", + "helpText": "\u25c6 mem0 CLI \u2014 The Memory Layer for AI Agents" + }, + "branding": { + "logoMini": "\u25c6 mem0", + "tagline": "The Memory Layer for AI Agents", + "colors": { + "brand": "#8b5cf6", + "accent": "#a78bfa", + "success": "#22c55e", + "error": "#ef4444", + "warning": "#f59e0b", + "dim": "#6b7280" + }, + "icons": { + "success": "\u2713", + "error": "\u2717", + "warning": "\u26a0", + "info": "\u25c6", + "pending": "\u29d7", + "add": "+", + "update": "~", + "delete": "-", + "noop": "\u00b7", + "connected": "\u25cf", + "disconnected": "\u25cf" + }, + "logo": "███\u2557 ███\u2557███████\u2557███\u2557 ███\u2557 ██████\u2557 ██████\u2557██\u2557 ██\u2557\n████\u2557 ████\u2551██\u2554\u2550\u2550\u2550\u2550\u255d████\u2557 ████\u2551██\u2554\u2550████\u2557 ██\u2554\u2550\u2550\u2550\u2550\u255d██\u2551 ██\u2551\n██\u2554████\u2554██\u2551█████\u2557 ██\u2554████\u2554██\u2551██\u2551██\u2554██\u2551 ██\u2551 ██\u2551 ██\u2551\n██\u2551\u255a██\u2554\u255d██\u2551██\u2554\u2550\u2550\u255d ██\u2551\u255a██\u2554\u255d██\u2551████\u2554\u255d██\u2551 ██\u2551 ██\u2551 ██\u2551\n██\u2551 \u255a\u2550\u255d ██\u2551███████\u2557██\u2551 \u255a\u2550\u255d ██\u2551\u255a██████\u2554\u255d \u255a██████\u2557███████\u2557██\u2551\n\u255a\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d" + }, + "config": { + "configDir": "~/.mem0", + "configFile": "config.json", + "version": 1, + "defaultBaseUrl": "https://api.mem0.ai", + "sections": { + "platform": { + "fields": { + "api_key": { + "type": "string", + "default": "", + "envVar": "MEM0_API_KEY", + "redact": true + }, + "base_url": { + "type": "string", + "default": "https://api.mem0.ai", + "envVar": "MEM0_BASE_URL" + } + } + }, + "defaults": { + "fields": { + "user_id": { + "type": "string", + "default": "", + "envVar": "MEM0_USER_ID" + }, + "agent_id": { + "type": "string", + "default": "", + "envVar": "MEM0_AGENT_ID" + }, + "app_id": { + "type": "string", + "default": "", + "envVar": "MEM0_APP_ID" + }, + "run_id": { + "type": "string", + "default": "", + "envVar": "MEM0_RUN_ID" + }, + "enable_graph": { + "type": "boolean", + "default": false, + "envVar": "MEM0_ENABLE_GRAPH" + } + } + } + } + }, + "api": { + "endpoints": { + "add": { "method": "POST", "path": "/v1/memories/" }, + "search": { "method": "POST", "path": "/v2/memories/search/" }, + "get": { "method": "GET", "path": "/v1/memories/{memory_id}/" }, + "list": { "method": "POST", "path": "/v2/memories/" }, + "update": { "method": "PUT", "path": "/v1/memories/{memory_id}/" }, + "delete": { "method": "DELETE", "path": "/v1/memories/{memory_id}/" }, + "deleteAll": { "method": "DELETE", "path": "/v1/memories/" }, + "entities": { "method": "GET", "path": "/v1/entities/" }, + "deleteEntities": { "method": "DELETE", "path": "/v1/entities/" } + }, + "authHeader": "Authorization", + "authScheme": "Token", + "timeout": 30, + "entityTypeMap": { + "users": "user", + "agents": "agent", + "apps": "app", + "runs": "run" + } + }, + "errors": { + "AuthError": { + "httpStatus": 401, + "message": "Authentication failed. Your API key may be invalid or expired." + }, + "NotFoundError": { + "httpStatus": 404, + "messageTemplate": "Resource not found: {path}" + }, + "APIError": { + "httpStatus": 400, + "messageTemplate": "Bad request to {path}: {detail}" + }, + "noApiKey": { + "message": "No API key configured.", + "hint": "Run 'mem0 init' or set MEM0_API_KEY environment variable." + } + }, + "optionGroups": { + "scope": { + "label": "Scope", + "options": ["user_id", "agent_id", "app_id", "run_id"] + }, + "search": { + "label": "Search", + "options": ["top_k", "threshold", "rerank", "keyword", "filter_json", "fields", "graph", "no_graph"] + }, + "pagination": { + "label": "Pagination", + "options": ["page", "page_size"] + }, + "filters": { + "label": "Filters", + "options": ["category", "after", "before", "graph", "no_graph"] + }, + "output": { + "label": "Output", + "options": ["output"] + }, + "connection": { + "label": "Connection", + "options": ["api_key", "base_url"] + } + }, + "globalOptions": [ + { + "name": "api_key", + "flags": ["--api-key"], + "type": "string", + "required": false, + "envVar": "MEM0_API_KEY", + "help": "Override API key.", + "panel": "Connection" + }, + { + "name": "base_url", + "flags": ["--base-url"], + "type": "string", + "required": false, + "help": "Override API base URL.", + "panel": "Connection" + }, + { + "name": "version", + "flags": ["--version"], + "type": "boolean", + "required": false, + "help": "Show version and exit." + } + ], + "commands": [ + { + "name": "add", + "description": "Add a memory from text, messages, file, or stdin.", + "usage": "mem0 add [OPTIONS]", + "needsBackend": true, + "needsConfig": true, + "resolveIds": true, + "resolveGraph": true, + "confirmDangerous": false, + "outputFormats": ["text", "json", "quiet"], + "defaultOutput": "text", + "arguments": [ + { + "name": "text", + "type": "string", + "required": false, + "help": "Text content to add as a memory." + } + ], + "options": [ + { "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Scope to user.", "panel": "Scope" }, + { "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Scope to agent.", "panel": "Scope" }, + { "name": "app_id", "flags": ["--app-id"], "type": "string", "help": "Scope to app.", "panel": "Scope" }, + { "name": "run_id", "flags": ["--run-id"], "type": "string", "help": "Scope to run.", "panel": "Scope" }, + { "name": "messages", "flags": ["--messages"], "type": "string", "help": "Conversation messages as JSON." }, + { "name": "file", "flags": ["--file", "-f"], "type": "path", "help": "Read messages from JSON file." }, + { "name": "metadata", "flags": ["--metadata", "-m"], "type": "string", "help": "Custom metadata as JSON." }, + { "name": "immutable", "flags": ["--immutable"], "type": "boolean", "default": false, "help": "Prevent future updates." }, + { "name": "no_infer", "flags": ["--no-infer"], "type": "boolean", "default": false, "help": "Skip inference, store raw." }, + { "name": "expires", "flags": ["--expires"], "type": "string", "help": "Expiration date (YYYY-MM-DD)." }, + { "name": "categories", "flags": ["--categories"], "type": "string", "help": "Categories (JSON array or comma-separated)." }, + { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph memory extraction.", "panel": "Scope" }, + { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph memory extraction.", "panel": "Scope" }, + { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output format: text, json, quiet.", "panel": "Output" } + ], + "apiEndpoint": "add" + }, + { + "name": "search", + "description": "Search memories by semantic query.", + "usage": "mem0 search [OPTIONS]", + "needsBackend": true, + "needsConfig": true, + "resolveIds": true, + "resolveGraph": true, + "confirmDangerous": false, + "outputFormats": ["text", "json", "table"], + "defaultOutput": "text", + "arguments": [ + { + "name": "query", + "type": "string", + "required": true, + "help": "Search query." + } + ], + "options": [ + { "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Filter by user.", "panel": "Scope" }, + { "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Filter by agent.", "panel": "Scope" }, + { "name": "app_id", "flags": ["--app-id"], "type": "string", "help": "Filter by app.", "panel": "Scope" }, + { "name": "run_id", "flags": ["--run-id"], "type": "string", "help": "Filter by run.", "panel": "Scope" }, + { "name": "top_k", "flags": ["--top-k", "-k", "--limit"], "type": "integer", "default": 10, "help": "Number of results.", "panel": "Search" }, + { "name": "threshold", "flags": ["--threshold"], "type": "float", "default": 0.3, "help": "Minimum similarity score.", "panel": "Search" }, + { "name": "rerank", "flags": ["--rerank"], "type": "boolean", "default": false, "help": "Enable reranking (Platform only).", "panel": "Search" }, + { "name": "keyword", "flags": ["--keyword"], "type": "boolean", "default": false, "help": "Use keyword search.", "panel": "Search" }, + { "name": "filter_json", "flags": ["--filter"], "type": "string", "help": "Advanced filter expression (JSON).", "panel": "Search" }, + { "name": "fields", "flags": ["--fields"], "type": "string", "help": "Specific fields to return (comma-separated).", "panel": "Search" }, + { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in search.", "panel": "Search" }, + { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in search.", "panel": "Search" }, + { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, table.", "panel": "Output" } + ], + "apiEndpoint": "search" + }, + { + "name": "get", + "description": "Get a specific memory by ID.", + "usage": "mem0 get [OPTIONS]", + "needsBackend": true, + "needsConfig": false, + "resolveIds": false, + "resolveGraph": false, + "confirmDangerous": false, + "outputFormats": ["text", "json"], + "defaultOutput": "text", + "arguments": [ + { + "name": "memory_id", + "type": "string", + "required": true, + "help": "Memory ID to retrieve." + } + ], + "options": [ + { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json.", "panel": "Output" } + ], + "apiEndpoint": "get" + }, + { + "name": "list", + "description": "List memories with optional filters.", + "usage": "mem0 list [OPTIONS]", + "needsBackend": true, + "needsConfig": true, + "resolveIds": true, + "resolveGraph": true, + "confirmDangerous": false, + "outputFormats": ["text", "json", "table"], + "defaultOutput": "table", + "arguments": [], + "options": [ + { "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Filter by user.", "panel": "Scope" }, + { "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Filter by agent.", "panel": "Scope" }, + { "name": "app_id", "flags": ["--app-id"], "type": "string", "help": "Filter by app.", "panel": "Scope" }, + { "name": "run_id", "flags": ["--run-id"], "type": "string", "help": "Filter by run.", "panel": "Scope" }, + { "name": "page", "flags": ["--page"], "type": "integer", "default": 1, "help": "Page number.", "panel": "Pagination" }, + { "name": "page_size", "flags": ["--page-size"], "type": "integer", "default": 100, "help": "Results per page.", "panel": "Pagination" }, + { "name": "category", "flags": ["--category"], "type": "string", "help": "Filter by category.", "panel": "Filters" }, + { "name": "after", "flags": ["--after"], "type": "string", "help": "Created after (YYYY-MM-DD).", "panel": "Filters" }, + { "name": "before", "flags": ["--before"], "type": "string", "help": "Created before (YYYY-MM-DD).", "panel": "Filters" }, + { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in listing.", "panel": "Filters" }, + { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in listing.", "panel": "Filters" }, + { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "table", "help": "Output: text, json, table.", "panel": "Output" } + ], + "apiEndpoint": "list" + }, + { + "name": "update", + "description": "Update a memory's text or metadata.", + "usage": "mem0 update [text] [OPTIONS]", + "needsBackend": true, + "needsConfig": false, + "resolveIds": false, + "resolveGraph": false, + "confirmDangerous": false, + "outputFormats": ["text", "json", "quiet"], + "defaultOutput": "text", + "arguments": [ + { + "name": "memory_id", + "type": "string", + "required": true, + "help": "Memory ID to update." + }, + { + "name": "text", + "type": "string", + "required": false, + "help": "New memory text." + } + ], + "options": [ + { "name": "metadata", "flags": ["--metadata", "-m"], "type": "string", "help": "Update metadata (JSON)." }, + { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" } + ], + "apiEndpoint": "update" + }, + { + "name": "delete", + "description": "Delete a memory, all memories matching a scope, or an entity.", + "usage": "mem0 delete [memory_id] [OPTIONS]", + "needsBackend": true, + "needsConfig": true, + "resolveIds": true, + "resolveGraph": false, + "confirmDangerous": true, + "outputFormats": ["text", "json", "quiet"], + "defaultOutput": "text", + "arguments": [ + { + "name": "memory_id", + "type": "string", + "required": false, + "help": "Memory ID to delete (omit when using --all or --entity)." + } + ], + "options": [ + { "name": "all", "flags": ["--all"], "type": "boolean", "default": false, "help": "Delete all memories matching scope filters." }, + { "name": "entity", "flags": ["--entity"], "type": "boolean", "default": false, "help": "Delete the entity itself and all its memories (cascade)." }, + { "name": "project", "flags": ["--project"], "type": "boolean", "default": false, "help": "With --all: delete ALL memories project-wide." }, + { "name": "dry_run", "flags": ["--dry-run"], "type": "boolean", "default": false, "help": "Show what would be deleted without deleting." }, + { "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Skip confirmation." }, + { "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Scope to user.", "panel": "Scope" }, + { "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Scope to agent.", "panel": "Scope" }, + { "name": "app_id", "flags": ["--app-id"], "type": "string", "help": "Scope to app.", "panel": "Scope" }, + { "name": "run_id", "flags": ["--run-id"], "type": "string", "help": "Scope to run.", "panel": "Scope" }, + { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" } + ], + "apiEndpoint": "delete", + "notes": "Mutually exclusive modes: (1) mem0 delete -- single memory, (2) mem0 delete --all [scope] -- bulk delete, (3) mem0 delete --entity [scope] -- entity cascade delete. Cannot combine with --all or --entity, and cannot combine --all with --entity." + }, + { + "name": "import", + "description": "Import memories from a JSON file.", + "usage": "mem0 import [OPTIONS]", + "needsBackend": true, + "needsConfig": true, + "resolveIds": true, + "resolveGraph": false, + "confirmDangerous": false, + "outputFormats": ["text"], + "defaultOutput": "text", + "arguments": [ + { + "name": "file_path", + "type": "string", + "required": true, + "help": "JSON file to import." + } + ], + "options": [ + { "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Override user ID.", "panel": "Scope" }, + { "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Override agent ID.", "panel": "Scope" } + ], + "apiEndpoint": "add" + }, + { + "name": "config", + "description": "Manage mem0 configuration.", + "isGroup": true, + "subcommands": [ + { + "name": "show", + "description": "Display current configuration (secrets redacted).", + "usage": "mem0 config show", + "needsBackend": false, + "needsConfig": false, + "arguments": [], + "options": [] + }, + { + "name": "get", + "description": "Get a configuration value.", + "usage": "mem0 config get ", + "needsBackend": false, + "needsConfig": false, + "arguments": [ + { + "name": "key", + "type": "string", + "required": true, + "help": "Config key (e.g. platform.api_key)." + } + ], + "options": [] + }, + { + "name": "set", + "description": "Set a configuration value.", + "usage": "mem0 config set ", + "needsBackend": false, + "needsConfig": false, + "arguments": [ + { + "name": "key", + "type": "string", + "required": true, + "help": "Config key (e.g. platform.api_key)." + }, + { + "name": "value", + "type": "string", + "required": true, + "help": "Value to set." + } + ], + "options": [] + } + ] + }, + { + "name": "entity", + "description": "Manage entities.", + "isGroup": true, + "subcommands": [ + { + "name": "list", + "description": "List all entities of a given type.", + "usage": "mem0 entity list ", + "needsBackend": true, + "needsConfig": false, + "resolveIds": false, + "resolveGraph": false, + "confirmDangerous": false, + "outputFormats": ["table", "json"], + "defaultOutput": "table", + "arguments": [ + { + "name": "entity_type", + "type": "string", + "required": true, + "help": "Entity type: users, agents, apps, runs.", + "choices": ["users", "agents", "apps", "runs"] + } + ], + "options": [ + { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "table", "help": "Output: table, json.", "panel": "Output" } + ], + "apiEndpoint": "entities" + }, + { + "name": "delete", + "description": "Delete an entity and ALL its memories (cascade).", + "usage": "mem0 entity delete [OPTIONS]", + "needsBackend": true, + "needsConfig": false, + "resolveIds": false, + "resolveGraph": false, + "confirmDangerous": true, + "outputFormats": ["text", "json", "quiet"], + "defaultOutput": "text", + "arguments": [], + "options": [ + { "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "User ID.", "panel": "Scope" }, + { "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Agent ID.", "panel": "Scope" }, + { "name": "app_id", "flags": ["--app-id"], "type": "string", "help": "App ID.", "panel": "Scope" }, + { "name": "run_id", "flags": ["--run-id"], "type": "string", "help": "Run ID.", "panel": "Scope" }, + { "name": "dry_run", "flags": ["--dry-run"], "type": "boolean", "default": false, "help": "Show what would be deleted without deleting." }, + { "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Skip confirmation." }, + { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" } + ], + "apiEndpoint": "deleteEntities" + } + ] + }, + { + "name": "init", + "description": "Interactive setup wizard for mem0 CLI.", + "usage": "mem0 init", + "needsBackend": false, + "needsConfig": false, + "resolveIds": false, + "resolveGraph": false, + "confirmDangerous": false, + "arguments": [], + "options": [] + }, + { + "name": "status", + "description": "Check connectivity and authentication.", + "usage": "mem0 status [OPTIONS]", + "needsBackend": true, + "needsConfig": true, + "resolveIds": false, + "resolveGraph": false, + "confirmDangerous": false, + "arguments": [], + "options": [] + }, + { + "name": "help", + "description": "Show help. Use --json for machine-readable output (for LLM agents).", + "usage": "mem0 help [OPTIONS]", + "needsBackend": false, + "needsConfig": false, + "resolveIds": false, + "resolveGraph": false, + "confirmDangerous": false, + "arguments": [], + "options": [ + { "name": "json", "flags": ["--json"], "type": "boolean", "default": false, "help": "Output machine-readable JSON for LLM agents." } + ] + } + ] +} diff --git a/cli/node/README.md b/cli/node/README.md new file mode 100644 index 000000000..917b430e6 --- /dev/null +++ b/cli/node/README.md @@ -0,0 +1,75 @@ +# mem0 CLI (Node.js) + +The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents. TypeScript implementation. + +## Prerequisites + +- Node.js **18+** +- pnpm (`npm install -g pnpm`) + +## Installation + +```bash +npm install -g @mem0/cli +``` + +Or from source: + +```bash +cd node +pnpm install +pnpm build +pnpm link --global + +# Now use it like a normal CLI +mem0 --help +``` + +## Running during development + +```bash +cd node +pnpm install + +# Development mode (runs TypeScript directly, no build needed) +pnpm dev --help +pnpm dev add "test memory" --user-id alice +pnpm dev search "test" --user-id alice + +# Or build first, then run the compiled JS +pnpm build +node dist/index.js --help +node dist/index.js add "test memory" --user-id alice +``` + +## Quick Start + +```bash +# Set up your configuration +mem0 init + +# 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 +mem0 list --user-id alice +``` + +## 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) | + +## License + +Apache-2.0 diff --git a/cli/node/development.md b/cli/node/development.md new file mode 100644 index 000000000..5b5d0b060 --- /dev/null +++ b/cli/node/development.md @@ -0,0 +1,91 @@ +# Development + +## Prerequisites + +- Node.js **18+** +- pnpm (`npm install -g pnpm`) + +## Setup + +From the `node/` directory: + +```bash +pnpm install +``` + +## Running the CLI + +There are two ways to run the CLI during development: + +### Option 1: Development mode (no build needed) + +Uses `tsx` to run TypeScript directly. Pass CLI arguments after `pnpm dev`: + +```bash +pnpm dev --help +pnpm dev version +pnpm dev add "test memory" --user-id alice +pnpm dev search "test" --user-id alice +pnpm dev config show +``` + +> **Note:** Do NOT use `pnpm dev -- --help`. With pnpm, arguments pass through directly — adding `--` inserts a literal `--` that breaks the CLI parser. + +### Option 2: Build and run compiled JS + +```bash +# Build first +pnpm build + +# Run the compiled CLI +node dist/index.js --help +node dist/index.js version +node dist/index.js add "test memory" --user-id alice +``` + +### Option 3: Link globally (makes `mem0` available system-wide) + +```bash +pnpm build +pnpm link --global + +# Now use it like a normal CLI +mem0 --help +mem0 version +``` + +> **Warning:** If you also have the Python CLI installed, both register the `mem0` command. The last one linked/installed wins. Unlink with `pnpm unlink --global`. + +## Build + +```bash +pnpm build +``` + +The compiled output is in `dist/`. + +## Run tests + +```bash +# Run all tests +pnpm test + +# Watch mode +pnpm test:watch +``` + +## Lint + +```bash +# Check +pnpm lint + +# Auto-fix +pnpm lint:fix +``` + +## Type checking + +```bash +pnpm typecheck +``` diff --git a/cli/node/package.json b/cli/node/package.json new file mode 100644 index 000000000..ec92cb7b0 --- /dev/null +++ b/cli/node/package.json @@ -0,0 +1,39 @@ +{ + "name": "@mem0/cli", + "version": "0.1.0", + "description": "The official CLI for mem0 — the memory layer for AI agents", + "type": "module", + "bin": { + "mem0": "./dist/index.js" + }, + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean", + "dev": "tsx src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "lint": "biome check src/", + "lint:fix": "biome check --write src/", + "typecheck": "tsc --noEmit" + }, + "engines": { + "node": ">=18.0.0" + }, + "license": "Apache-2.0", + "author": "mem0.ai ", + "keywords": ["mem0", "memory", "ai", "agents", "cli"], + "dependencies": { + "commander": "^12.0.0", + "chalk": "^5.3.0", + "cli-table3": "^0.6.4", + "ora": "^8.0.0", + "boxen": "^7.1.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "tsup": "^8.0.0", + "tsx": "^4.7.0", + "vitest": "^1.5.0", + "@biomejs/biome": "^1.7.0", + "@types/node": "^20.0.0" + } +} diff --git a/cli/node/pnpm-lock.yaml b/cli/node/pnpm-lock.yaml new file mode 100644 index 000000000..f0371cd2c --- /dev/null +++ b/cli/node/pnpm-lock.yaml @@ -0,0 +1,2066 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + boxen: + specifier: ^7.1.0 + version: 7.1.1 + chalk: + specifier: ^5.3.0 + version: 5.6.2 + cli-table3: + specifier: ^0.6.4 + version: 0.6.5 + commander: + specifier: ^12.0.0 + version: 12.1.0 + ora: + specifier: ^8.0.0 + version: 8.2.0 + devDependencies: + '@biomejs/biome': + specifier: ^1.7.0 + version: 1.9.4 + '@types/node': + specifier: ^20.0.0 + version: 20.19.37 + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3) + tsx: + specifier: ^4.7.0 + version: 4.21.0 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + vitest: + specifier: ^1.5.0 + version: 1.6.1(@types/node@20.19.37) + +packages: + + '@biomejs/biome@1.9.4': + resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@1.9.4': + resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@1.9.4': + resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@1.9.4': + resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@1.9.4': + resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@1.9.4': + resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@1.9.4': + resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@1.9.4': + resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@1.9.4': + resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@20.19.37': + resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + boxen@7.1.1: + resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} + engines: {node: '>=14.16'} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + +snapshots: + + '@biomejs/biome@1.9.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.9.4 + '@biomejs/cli-darwin-x64': 1.9.4 + '@biomejs/cli-linux-arm64': 1.9.4 + '@biomejs/cli-linux-arm64-musl': 1.9.4 + '@biomejs/cli-linux-x64': 1.9.4 + '@biomejs/cli-linux-x64-musl': 1.9.4 + '@biomejs/cli-win32-arm64': 1.9.4 + '@biomejs/cli-win32-x64': 1.9.4 + + '@biomejs/cli-darwin-arm64@1.9.4': + optional: true + + '@biomejs/cli-darwin-x64@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64@1.9.4': + optional: true + + '@biomejs/cli-linux-x64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-x64@1.9.4': + optional: true + + '@biomejs/cli-win32-arm64@1.9.4': + optional: true + + '@biomejs/cli-win32-x64@1.9.4': + optional: true + + '@colors/colors@1.5.0': + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@sinclair/typebox@0.27.10': {} + + '@types/estree@1.0.8': {} + + '@types/node@20.19.37': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + any-promise@1.3.0: {} + + assertion-error@1.1.0: {} + + boxen@7.1.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + bundle-require@5.1.0(esbuild@0.27.4): + dependencies: + esbuild: 0.27.4 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + camelcase@7.0.1: {} + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + chalk@5.6.2: {} + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + cli-boxes@3.0.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + commander@12.1.0: {} + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + diff-sequences@29.6.3: {} + + eastasianwidth@0.2.0: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.0 + + fsevents@2.3.3: + optional: true + + get-east-asian-width@1.5.0: {} + + get-func-name@2.0.2: {} + + get-stream@8.0.1: {} + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + human-signals@5.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-interactive@2.0.0: {} + + is-stream@3.0.0: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + isexe@2.0.0: {} + + joycon@3.1.1: {} + + js-tokens@9.0.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.2 + pkg-types: 1.3.1 + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + merge-stream@2.0.0: {} + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + object-assign@4.1.1: {} + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.8)(tsx@4.21.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.8 + tsx: 4.21.0 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + react-is@18.3.1: {} + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + stdin-discarder@0.2.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-final-newline@3.0.0: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.4) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.4 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.8)(tsx@4.21.0) + resolve-from: 5.0.0 + rollup: 4.60.0 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.8 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + + type-detect@4.1.0: {} + + type-fest@2.19.0: {} + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + undici-types@6.21.0: {} + + vite-node@1.6.1(@types/node@20.19.37): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.37) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.37): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.60.0 + optionalDependencies: + '@types/node': 20.19.37 + fsevents: 2.3.3 + + vitest@1.6.1(@types/node@20.19.37): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.37) + vite-node: 1.6.1(@types/node@20.19.37) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.37 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + yocto-queue@1.2.2: {} diff --git a/cli/node/src/backend/base.ts b/cli/node/src/backend/base.ts new file mode 100644 index 000000000..e2774f5db --- /dev/null +++ b/cli/node/src/backend/base.ts @@ -0,0 +1,115 @@ +/** + * Abstract backend interface and factory. + */ + +import type { Mem0Config } from "../config.js"; +import { PlatformBackend } from "./platform.js"; + +export interface AddOptions { + userId?: string; + agentId?: string; + appId?: string; + runId?: string; + metadata?: Record; + immutable?: boolean; + infer?: boolean; + expires?: string; + categories?: string[]; + enableGraph?: boolean; +} + +export interface SearchOptions { + userId?: string; + agentId?: string; + appId?: string; + runId?: string; + topK?: number; + threshold?: number; + rerank?: boolean; + keyword?: boolean; + filters?: Record; + fields?: string[]; + enableGraph?: boolean; +} + +export interface ListOptions { + userId?: string; + agentId?: string; + appId?: string; + runId?: string; + page?: number; + pageSize?: number; + category?: string; + after?: string; + before?: string; + enableGraph?: boolean; +} + +export interface DeleteOptions { + all?: boolean; + userId?: string; + agentId?: string; + appId?: string; + runId?: string; +} + +export interface EntityIds { + userId?: string; + agentId?: string; + appId?: string; + runId?: string; +} + +export interface Backend { + add( + content?: string, + messages?: Record[], + opts?: AddOptions, + ): Promise>; + + search(query: string, opts?: SearchOptions): Promise[]>; + + get(memoryId: string): Promise>; + + listMemories(opts?: ListOptions): Promise[]>; + + update( + memoryId: string, + content?: string, + metadata?: Record, + ): Promise>; + + delete(memoryId?: string, opts?: DeleteOptions): Promise>; + + deleteEntities(opts: EntityIds): Promise>; + + status(opts?: { userId?: string; agentId?: string }): Promise>; + + entities(entityType: string): Promise[]>; +} + +export class AuthError extends Error { + constructor(message = "Authentication failed. Your API key may be invalid or expired.") { + super(message); + this.name = "AuthError"; + } +} + +export class NotFoundError extends Error { + constructor(path: string) { + super(`Resource not found: ${path}`); + this.name = "NotFoundError"; + } +} + +export class APIError extends Error { + constructor(path: string, detail: string) { + super(`Bad request to ${path}: ${detail}`); + this.name = "APIError"; + } +} + +export function getBackend(config: Mem0Config): Backend { + return new PlatformBackend(config.platform); +} + diff --git a/cli/node/src/backend/index.ts b/cli/node/src/backend/index.ts new file mode 100644 index 000000000..74e896bc3 --- /dev/null +++ b/cli/node/src/backend/index.ts @@ -0,0 +1,7 @@ +/** + * Backend factory re-export. + */ + +export { getBackend } from "./base.js"; +export type { Backend, AddOptions, SearchOptions, ListOptions, DeleteOptions, EntityIds } from "./base.js"; +export { AuthError, NotFoundError, APIError } from "./base.js"; diff --git a/cli/node/src/backend/platform.ts b/cli/node/src/backend/platform.ts new file mode 100644 index 000000000..419fdf85e --- /dev/null +++ b/cli/node/src/backend/platform.ts @@ -0,0 +1,303 @@ +/** + * Platform (SaaS) backend — communicates with api.mem0.ai. + */ + +import type { PlatformConfig } from "../config.js"; +import { + type AddOptions, + APIError, + AuthError, + type Backend, + type DeleteOptions, + type EntityIds, + type ListOptions, + NotFoundError, + type SearchOptions, +} from "./base.js"; + +export class PlatformBackend implements Backend { + private baseUrl: string; + private headers: Record; + + constructor(config: PlatformConfig) { + this.baseUrl = config.baseUrl.replace(/\/+$/, ""); + this.headers = { + Authorization: `Token ${config.apiKey}`, + "Content-Type": "application/json", + }; + } + + private async _request( + method: string, + path: string, + opts?: { json?: unknown; params?: Record }, + ): Promise { + let url = `${this.baseUrl}${path}`; + if (opts?.params) { + const qs = new URLSearchParams(opts.params).toString(); + url += `?${qs}`; + } + + const fetchOpts: RequestInit = { + method, + headers: this.headers, + signal: AbortSignal.timeout(30_000), + }; + if (opts?.json) { + fetchOpts.body = JSON.stringify(opts.json); + } + + const resp = await fetch(url, fetchOpts); + + if (resp.status === 401) { + throw new AuthError(); + } + if (resp.status === 404) { + throw new NotFoundError(path); + } + if (resp.status === 400) { + let detail: string; + try { + const body = await resp.json(); + detail = (body as Record).detail ?? resp.statusText; + } catch { + detail = resp.statusText; + } + throw new APIError(path, detail); + } + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + if (resp.status === 204) { + return {}; + } + return resp.json(); + } + + async add( + content?: string, + messages?: Record[], + opts: AddOptions = {}, + ): Promise> { + const payload: Record = {}; + + if (messages) { + payload.messages = messages; + } else if (content) { + payload.messages = [{ role: "user", content }]; + } + + if (opts.userId) payload.user_id = opts.userId; + if (opts.agentId) payload.agent_id = opts.agentId; + if (opts.appId) payload.app_id = opts.appId; + if (opts.runId) payload.run_id = opts.runId; + if (opts.metadata) payload.metadata = opts.metadata; + if (opts.immutable) payload.immutable = true; + if (opts.infer === false) payload.infer = false; + if (opts.expires) payload.expiration_date = opts.expires; + if (opts.categories) payload.categories = opts.categories; + if (opts.enableGraph) payload.enable_graph = true; + + return (await this._request("POST", "/v1/memories/", { json: payload })) as Record< + string, + unknown + >; + } + + private _buildFilters(opts: { + userId?: string; + agentId?: string; + appId?: string; + runId?: string; + extraFilters?: Record; + }): Record | undefined { + // If caller passed a pre-built filter structure, use it directly + if (opts.extraFilters && ("AND" in opts.extraFilters || "OR" in opts.extraFilters)) { + return opts.extraFilters; + } + + const andConditions: Record[] = []; + if (opts.userId) andConditions.push({ user_id: opts.userId }); + if (opts.agentId) andConditions.push({ agent_id: opts.agentId }); + if (opts.appId) andConditions.push({ app_id: opts.appId }); + if (opts.runId) andConditions.push({ run_id: opts.runId }); + + if (opts.extraFilters) { + for (const [k, v] of Object.entries(opts.extraFilters)) { + andConditions.push({ [k]: v }); + } + } + + if (andConditions.length === 1) return andConditions[0]; + if (andConditions.length > 1) return { AND: andConditions }; + return undefined; + } + + async search(query: string, opts: SearchOptions = {}): Promise[]> { + const payload: Record = { + query, + top_k: opts.topK ?? 10, + threshold: opts.threshold ?? 0.3, + }; + + const apiFilters = this._buildFilters({ + userId: opts.userId, + agentId: opts.agentId, + appId: opts.appId, + runId: opts.runId, + extraFilters: opts.filters, + }); + if (apiFilters) payload.filters = apiFilters; + if (opts.rerank) payload.rerank = true; + if (opts.keyword) payload.keyword_search = true; + if (opts.fields) payload.fields = opts.fields; + if (opts.enableGraph) payload.enable_graph = true; + + const result = (await this._request("POST", "/v2/memories/search/", { + json: payload, + })) as unknown; + if (Array.isArray(result)) return result; + const obj = result as Record; + return (obj.results ?? obj.memories ?? []) as Record[]; + } + + async get(memoryId: string): Promise> { + return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record; + } + + async listMemories(opts: ListOptions = {}): Promise[]> { + const payload: Record = {}; + const params: Record = { + page: String(opts.page ?? 1), + page_size: String(opts.pageSize ?? 100), + }; + + const extra: Record = {}; + if (opts.category) { + extra.categories = { contains: opts.category }; + } + if (opts.after) { + extra.created_at = { ...(extra.created_at as Record | undefined), gte: opts.after }; + } + if (opts.before) { + extra.created_at = { ...(extra.created_at as Record | undefined), lte: opts.before }; + } + + const apiFilters = this._buildFilters({ + userId: opts.userId, + agentId: opts.agentId, + appId: opts.appId, + runId: opts.runId, + extraFilters: Object.keys(extra).length > 0 ? extra : undefined, + }); + if (apiFilters) payload.filters = apiFilters; + if (opts.enableGraph) payload.enable_graph = true; + + const result = (await this._request("POST", "/v2/memories/", { json: payload, params })) as unknown; + if (Array.isArray(result)) return result; + const obj = result as Record; + return (obj.results ?? obj.memories ?? []) as Record[]; + } + + async update( + memoryId: string, + content?: string, + metadata?: Record, + ): Promise> { + const payload: Record = {}; + if (content) payload.text = content; + if (metadata) payload.metadata = metadata; + return (await this._request("PUT", `/v1/memories/${memoryId}/`, { + json: payload, + })) as Record; + } + + async delete( + memoryId?: string, + opts: DeleteOptions = {}, + ): Promise> { + if (opts.all) { + const params: Record = {}; + if (opts.userId) params.user_id = opts.userId; + if (opts.agentId) params.agent_id = opts.agentId; + if (opts.appId) params.app_id = opts.appId; + if (opts.runId) params.run_id = opts.runId; + return (await this._request("DELETE", "/v1/memories/", { params })) as Record< + string, + unknown + >; + } + if (memoryId) { + return (await this._request("DELETE", `/v1/memories/${memoryId}/`)) as Record< + string, + unknown + >; + } + throw new Error("Either memoryId or --all is required"); + } + + async deleteEntities(opts: EntityIds): Promise> { + const params: Record = {}; + if (opts.userId) params.user_id = opts.userId; + if (opts.agentId) params.agent_id = opts.agentId; + if (opts.appId) params.app_id = opts.appId; + if (opts.runId) params.run_id = opts.runId; + if (Object.keys(params).length === 0) { + throw new Error("At least one entity ID is required for deleteEntities."); + } + return (await this._request("DELETE", "/v1/entities/", { params })) as Record< + string, + unknown + >; + } + + async status( + opts: { userId?: string; agentId?: string } = {}, + ): Promise> { + try { + if (opts.userId || opts.agentId) { + const payload: Record = {}; + const statusParams: Record = { page: "1", page_size: "1" }; + const apiFilters = this._buildFilters({ + userId: opts.userId, + agentId: opts.agentId, + }); + if (apiFilters) payload.filters = apiFilters; + await this._request("POST", "/v2/memories/", { json: payload, params: statusParams }); + } else { + await this._request("GET", "/v1/entities/"); + } + return { connected: true, backend: "platform", base_url: this.baseUrl }; + } catch (e) { + return { + connected: false, + backend: "platform", + error: e instanceof Error ? e.message : String(e), + }; + } + } + + async entities(entityType: string): Promise[]> { + const result = (await this._request("GET", "/v1/entities/")) as unknown; + let items: Record[]; + if (Array.isArray(result)) { + items = result; + } else { + items = ((result as Record).results ?? []) as Record[]; + } + + const typeMap: Record = { + users: "user", + agents: "agent", + apps: "app", + runs: "run", + }; + const targetType = typeMap[entityType]; + if (targetType) { + items = items.filter( + (e) => (e.type as string | undefined)?.toLowerCase() === targetType, + ); + } + return items; + } +} diff --git a/cli/node/src/branding.ts b/cli/node/src/branding.ts new file mode 100644 index 000000000..7eb54fae0 --- /dev/null +++ b/cli/node/src/branding.ts @@ -0,0 +1,145 @@ +/** + * Branding and ASCII art for mem0 CLI. + */ + +import chalk from "chalk"; +import ora, { type Ora } from "ora"; +import { createRequire } from "node:module"; + +const _require = createRequire(import.meta.url); +const PKG_VERSION: string = _require("../package.json").version; + +export const LOGO = ` +███╗ ███╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ ██╗ +████╗ ████║██╔════╝████╗ ████║██╔═████╗ ██╔════╝██║ ██║ +██╔████╔██║█████╗ ██╔████╔██║██║██╔██║ ██║ ██║ ██║ +██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║████╔╝██║ ██║ ██║ ██║ +██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝ ╚██████╗███████╗██║ +╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ +`; + +export const LOGO_MINI = "◆ mem0"; +export const TAGLINE = "The Memory Layer for AI Agents"; + +export const BRAND_COLOR = "#8b5cf6"; +export const ACCENT_COLOR = "#a78bfa"; +export const SUCCESS_COLOR = "#22c55e"; +export const ERROR_COLOR = "#ef4444"; +export const WARNING_COLOR = "#f59e0b"; +export const DIM_COLOR = "#6b7280"; + +const brand = chalk.hex(BRAND_COLOR); +const accent = chalk.hex(ACCENT_COLOR); +const success = chalk.hex(SUCCESS_COLOR); +const error = chalk.hex(ERROR_COLOR); +const warning = chalk.hex(WARNING_COLOR); +const dim = chalk.hex(DIM_COLOR); + +/** + * Choose a symbol based on TTY/NO_COLOR. Fancy for interactive terminals, + * plain-text for piped/non-TTY or NO_COLOR environments. + */ +export function sym(fancy: string, plain: string): string { + if (!process.stdout.isTTY || process.env.NO_COLOR) return plain; + return fancy; +} + +export function printBanner(): void { + const pad = 3; // horizontal padding each side (matches Rich's padding=(0, 2)) + const logoLines = LOGO.trimEnd().split("\n"); + const tagline = ` ${TAGLINE}`; + const subtitle = `Node.js SDK · v${PKG_VERSION}`; + const contentLines = ["", ...logoLines, "", tagline, ""]; + + // Compute inner width from longest content line + padding both sides + const maxContent = Math.max(...contentLines.map((l) => l.length)); + const innerWidth = maxContent + pad * 2; + const totalWidth = innerWidth + 2; // + 2 for │ borders + + const topBorder = brand(`╭${"─".repeat(totalWidth - 2)}╮`); + const subtitleFill = totalWidth - 2 - subtitle.length - 3; // 3 = "─ " before subtitle + "─" after + const bottomBorder = brand(`╰${"─".repeat(subtitleFill)} ${dim(subtitle)} ${"─"}╯`); + + const body = contentLines.map((line) => { + const rightPad = innerWidth - pad - line.length; + return `${brand("│")}${" ".repeat(pad)}${brand.bold(line)}${" ".repeat(Math.max(rightPad, 0))}${brand("│")}`; + }); + // Re-color tagline line with accent instead of brand.bold + const taglineIdx = body.length - 2; // second-to-last (before trailing empty line) + const taglineRightPad = innerWidth - pad - tagline.length; + body[taglineIdx] = `${brand("│")}${" ".repeat(pad)}${accent(tagline)}${" ".repeat(Math.max(taglineRightPad, 0))}${brand("│")}`; + + console.log(topBorder); + for (const line of body) console.log(line); + console.log(bottomBorder); +} + +export function printSuccess(message: string): void { + console.log(`${success(sym("✓", "[ok]"))} ${message}`); +} + +export function printError(message: string, hint?: string): void { + console.error(`${error(sym("✗", "[error]") + " Error:")} ${message}`); + if (hint) { + console.error(` ${dim(hint)}`); + } +} + +export function printWarning(message: string): void { + console.error(`${warning(sym("⚠", "[warn]"))} ${message}`); +} + +export function printInfo(message: string): void { + console.log(`${brand(sym("◆", "*"))} ${message}`); +} + +export function printScope(ids: Record): void { + const parts: string[] = []; + for (const [key, val] of Object.entries(ids)) { + if (val) { + const label = key.replace(/_/g, " ").replace("id", "ID").trim(); + parts.push(`${label}=${val}`); + } + } + if (parts.length > 0) { + console.log(` ${dim(`Scope: ${parts.join(", ")}`)}`); + } +} + +export interface TimedStatusContext { + successMsg: string; + errorMsg: string; +} + +/** + * Run an async function with a spinner, timing the operation. + * Equivalent to Python's timed_status context manager. + */ +export async function timedStatus( + message: string, + fn: (ctx: TimedStatusContext) => Promise, +): Promise { + const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" }; + const spinner = ora({ text: dim(message), color: "magenta", stream: process.stderr }).start(); + const start = performance.now(); + + try { + const result = await fn(ctx); + const elapsed = ((performance.now() - start) / 1000).toFixed(2); + spinner.stop(); + if (ctx.successMsg) { + console.error(`${success("✓")} ${ctx.successMsg} (${elapsed}s)`); + } + return result; + } catch (err) { + const elapsed = ((performance.now() - start) / 1000).toFixed(2); + spinner.stop(); + if (ctx.errorMsg) { + console.error(`${error("✗ Error:")} ${ctx.errorMsg} (${elapsed}s)`); + } + throw err; + } +} + +/** Format helpers using brand colors for external use. */ +export const colors = { brand, accent, success, error, warning, dim }; diff --git a/cli/node/src/commands/config.ts b/cli/node/src/commands/config.ts new file mode 100644 index 000000000..f45616bb9 --- /dev/null +++ b/cli/node/src/commands/config.ts @@ -0,0 +1,90 @@ +/** + * Config management commands: show, set, get. + */ + +import Table from "cli-table3"; +import { printError, printSuccess, colors } from "../branding.js"; +import { + getNestedValue, + loadConfig, + redactKey, + saveConfig, + setNestedValue, +} from "../config.js"; +import { formatJsonEnvelope } from "../output.js"; + +const { brand, accent, dim } = colors; + +export function cmdConfigShow(opts: { output?: string } = {}): void { + const config = loadConfig(); + + if (opts.output === "json") { + formatJsonEnvelope({ + command: "config show", + data: { + defaults: { + user_id: config.defaults.userId || null, + agent_id: config.defaults.agentId || null, + app_id: config.defaults.appId || null, + run_id: config.defaults.runId || null, + enable_graph: config.defaults.enableGraph, + }, + platform: { + api_key: redactKey(config.platform.apiKey), + base_url: config.platform.baseUrl, + }, + }, + }); + return; + } + + console.log(); + console.log(` ${brand("◆ mem0 Configuration")}\n`); + + const table = new Table({ + head: [accent("Key"), accent("Value")], + style: { head: [], border: [] }, + }); + + // Defaults + table.push(["defaults.user_id", config.defaults.userId || dim("(not set)")]); + table.push(["defaults.agent_id", config.defaults.agentId || dim("(not set)")]); + table.push(["defaults.app_id", config.defaults.appId || dim("(not set)")]); + table.push(["defaults.run_id", config.defaults.runId || dim("(not set)")]); + table.push(["defaults.enable_graph", String(config.defaults.enableGraph)]); + table.push(["", ""]); + + // Platform + table.push(["platform.api_key", redactKey(config.platform.apiKey)]); + table.push(["platform.base_url", config.platform.baseUrl]); + + console.log(table.toString()); + console.log(); +} + +export function cmdConfigGet(key: string): void { + const config = loadConfig(); + const value = getNestedValue(config, key); + + if (value === undefined) { + printError(`Unknown config key: ${key}`); + } else { + // Redact secrets + if (key.includes("api_key") || key.split(".").pop() === "key") { + console.log(redactKey(String(value))); + } else { + console.log(String(value)); + } + } +} + +export function cmdConfigSet(key: string, value: string): void { + const config = loadConfig(); + if (setNestedValue(config, key, value)) { + saveConfig(config); + const display = key.includes("key") ? redactKey(value) : value; + printSuccess(`${key} = ${display}`); + } else { + printError(`Unknown config key: ${key}`); + } +} diff --git a/cli/node/src/commands/entities.ts b/cli/node/src/commands/entities.ts new file mode 100644 index 000000000..612be8f2a --- /dev/null +++ b/cli/node/src/commands/entities.ts @@ -0,0 +1,139 @@ +/** + * Entity management commands. + */ + +import readline from "node:readline"; +import Table from "cli-table3"; +import { printError, printInfo, printSuccess, timedStatus, colors } from "../branding.js"; +import type { Backend } from "../backend/base.js"; +import { formatJson } from "../output.js"; + +const { brand, accent, dim } = colors; + +const VALID_TYPES = new Set(["users", "agents", "apps", "runs"]); + +export async function cmdEntitiesList( + backend: Backend, + entityType: string, + opts: { output: string }, +): Promise { + if (!VALID_TYPES.has(entityType)) { + printError(`Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`); + process.exit(1); + } + + const start = performance.now(); + let results: Record[]; + try { + results = await timedStatus(`Fetching ${entityType}...`, async () => { + return backend.entities(entityType); + }); + } catch (e) { + printError( + e instanceof Error ? e.message : String(e), + "This feature may require the mem0 Platform.", + ); + process.exit(1); + } + const elapsed = (performance.now() - start) / 1000; + + if (opts.output === "json") { + formatJson(results); + return; + } + + if (!results.length) { + printInfo(`No ${entityType} found.`); + return; + } + + const table = new Table({ + head: [accent("Name / ID"), accent("Created")], + style: { head: [], border: [] }, + }); + + for (const entity of results) { + const name = String(entity.name ?? entity.id ?? "—"); + const created = String(entity.created_at ?? "—").slice(0, 10); + table.push([name, created]); + } + + console.log(); + console.log(table.toString()); + console.log(` ${dim(`${results.length} ${entityType} (${elapsed.toFixed(2)}s)`)}`); + console.log(); +} + +export async function cmdEntitiesDelete( + backend: Backend, + opts: { + userId?: string; + agentId?: string; + appId?: string; + runId?: string; + dryRun?: boolean; + force: boolean; + output: string; + }, +): Promise { + if (!opts.userId && !opts.agentId && !opts.appId && !opts.runId) { + printError("Provide at least one of --user-id, --agent-id, --app-id, --run-id."); + process.exit(1); + } + + if (opts.dryRun) { + const scopeParts: string[] = []; + if (opts.userId) scopeParts.push(`user=${opts.userId}`); + if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`); + if (opts.appId) scopeParts.push(`app=${opts.appId}`); + if (opts.runId) scopeParts.push(`run=${opts.runId}`); + printInfo(`Would delete entity ${scopeParts.join(", ")} and all its memories.`); + printInfo("No changes made."); + return; + } + + if (!opts.force) { + const scopeParts: string[] = []; + if (opts.userId) scopeParts.push(`user=${opts.userId}`); + if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`); + if (opts.appId) scopeParts.push(`app=${opts.appId}`); + if (opts.runId) scopeParts.push(`run=${opts.runId}`); + const scope = scopeParts.join(", "); + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question( + `\n \u26a0 Delete entity ${scope} AND all its memories? This cannot be undone. [y/N] `, + resolve, + ); + }); + rl.close(); + if (answer.toLowerCase() !== "y") { + printInfo("Cancelled."); + process.exit(0); + } + } + + const start = performance.now(); + let result: Record; + try { + result = await timedStatus("Deleting entity...", async () => { + return backend.deleteEntities({ + userId: opts.userId, + agentId: opts.agentId, + appId: opts.appId, + runId: opts.runId, + }); + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const elapsed = (performance.now() - start) / 1000; + + if (opts.output === "json") { + formatJson(result); + } else if (opts.output !== "quiet") { + printSuccess(`Entity deleted with all memories (${elapsed.toFixed(2)}s)`); + } +} diff --git a/cli/node/src/commands/init.ts b/cli/node/src/commands/init.ts new file mode 100644 index 000000000..590fff041 --- /dev/null +++ b/cli/node/src/commands/init.ts @@ -0,0 +1,182 @@ +/** + * mem0 init — interactive setup wizard. + */ + +import readline from "node:readline"; +import { + printBanner, + printError, + printInfo, + printSuccess, + colors, +} from "../branding.js"; +import { type Mem0Config, createDefaultConfig, saveConfig } from "../config.js"; +import { PlatformBackend } from "../backend/platform.js"; + +const { brand, dim } = colors; + +function promptSecret(label: string): Promise { + return new Promise((resolve, reject) => { + process.stdout.write(label); + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.setEncoding("utf-8"); + + const chars: string[] = []; + + const onData = (key: string) => { + for (const ch of key) { + if (ch === "\r" || ch === "\n") { + cleanup(); + process.stdout.write("\n"); + resolve(chars.join("")); + return; + } + if (ch === "\x03") { + cleanup(); + reject(new Error("Interrupted")); + return; + } + if (ch === "\x7f" || ch === "\x08") { + // backspace + if (chars.length > 0) { + chars.pop(); + process.stdout.write("\b \b"); + } + } else if (ch === "\x15") { + // Ctrl+U — clear line + process.stdout.write("\b \b".repeat(chars.length)); + chars.length = 0; + } else if (ch >= " ") { + chars.push(ch); + process.stdout.write("*"); + } + } + }; + + const cleanup = () => { + process.stdin.removeListener("data", onData); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); + }; + + process.stdin.on("data", onData); + }); +} + +function promptLine(label: string, defaultValue?: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const prompt = defaultValue ? `${label} [${defaultValue}]: ` : `${label}: `; + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer.trim() || defaultValue || ""); + }); + }); +} + +async function setupPlatform(config: Mem0Config): Promise { + console.log(); + console.log(` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys")}`); + console.log(); + + process.stdout.write(` ${brand("API Key")}: `); + const apiKey = await promptSecret(""); + if (!apiKey) { + printError("API key is required."); + process.exit(1); + } + config.platform.apiKey = apiKey; +} + +async function setupDefaults(config: Mem0Config): Promise { + console.log(); + printInfo("Set default entity IDs (press Enter to skip).\n"); + + const userId = await promptLine(` ${brand("Default User ID")} ${dim("(recommended)")}`, "mem0-cli"); + if (userId) config.defaults.userId = userId; +} + +async function validatePlatform(config: Mem0Config): Promise { + console.log(); + printInfo("Validating connection..."); + try { + const backend = new PlatformBackend(config.platform); + const status = await backend.status({ + userId: config.defaults.userId || undefined, + agentId: config.defaults.agentId || undefined, + }); + if (status.connected) { + printSuccess("Connected to mem0 Platform!"); + } else { + printError( + `Could not connect: ${status.error ?? "Unknown error"}`, + "Check your API key and try again.", + ); + } + } catch (e) { + printError(`Connection test failed: ${e instanceof Error ? e.message : e}`); + } +} + +export async function runInit(opts: { apiKey?: string; userId?: string } = {}): Promise { + const config = createDefaultConfig(); + + // Non-interactive: both flags provided + if (opts.apiKey && opts.userId) { + config.platform.apiKey = opts.apiKey; + config.defaults.userId = opts.userId; + await validatePlatform(config); + saveConfig(config); + printSuccess("Configuration saved to ~/.mem0/config.json"); + return; + } + + // Non-TTY without full flags: error with usage hint + if (!process.stdin.isTTY && (!opts.apiKey || !opts.userId)) { + printError( + "Non-interactive terminal detected and missing required flags.", + "Usage: mem0 init --api-key --user-id ", + ); + process.exit(1); + } + + printBanner(); + console.log(); + printInfo("Welcome! Let's set up your mem0 CLI.\n"); + + // Use provided API key or prompt + if (opts.apiKey) { + config.platform.apiKey = opts.apiKey; + } else { + await setupPlatform(config); + } + + // Use provided user ID or prompt + if (opts.userId) { + config.defaults.userId = opts.userId; + } else { + await setupDefaults(config); + } + + await validatePlatform(config); + + saveConfig(config); + console.log(); + printSuccess("Configuration saved to ~/.mem0/config.json"); + console.log(); + console.log(` ${dim("Get started:")}`); + if (config.defaults.userId) { + console.log(` ${dim(' mem0 add "I prefer dark mode"')}`); + console.log(` ${dim(' mem0 search "preferences"')}`); + } else { + console.log(` ${dim(' mem0 add "I prefer dark mode" --user-id alice')}`); + console.log(` ${dim(' mem0 search "preferences" --user-id alice')}`); + } + console.log(); +} diff --git a/cli/node/src/commands/memory.ts b/cli/node/src/commands/memory.ts new file mode 100644 index 000000000..9c900afc7 --- /dev/null +++ b/cli/node/src/commands/memory.ts @@ -0,0 +1,487 @@ +/** + * Memory CRUD commands: add, search, get, list, update, delete. + */ + +import fs from "node:fs"; +import { printError, printInfo, printScope, printSuccess, timedStatus } from "../branding.js"; +import type { Backend } from "../backend/base.js"; +import { + formatAddResult, + formatJson, + formatMemoriesTable, + formatMemoriesText, + formatSingleMemory, + printResultSummary, +} from "../output.js"; + +export async function cmdAdd( + backend: Backend, + text: string | undefined, + opts: { + userId?: string; + agentId?: string; + appId?: string; + runId?: string; + messages?: string; + file?: string; + metadata?: string; + immutable: boolean; + noInfer: boolean; + expires?: string; + categories?: string; + enableGraph: boolean; + output: string; + }, +): Promise { + let msgs: Record[] | undefined; + let content = text; + + // Read from file + if (opts.file) { + try { + const raw = fs.readFileSync(opts.file, "utf-8"); + msgs = JSON.parse(raw); + } catch (e) { + printError(`Failed to read file: ${e instanceof Error ? e.message : e}`); + process.exit(1); + } + } + // Parse messages JSON + else if (opts.messages) { + try { + msgs = JSON.parse(opts.messages); + } catch (e) { + printError(`Invalid JSON in --messages: ${e instanceof Error ? e.message : e}`); + process.exit(1); + } + } + // Read from stdin if piped + else if (!content && !process.stdin.isTTY) { + content = fs.readFileSync(0, "utf-8").trim(); + } + + if (!content && !msgs) { + printError("No content provided. Pass text, --messages, --file, or pipe via stdin."); + process.exit(1); + } + + let meta: Record | undefined; + if (opts.metadata) { + try { + meta = JSON.parse(opts.metadata); + } catch { + printError("Invalid JSON in --metadata."); + process.exit(1); + } + } + + let cats: string[] | undefined; + if (opts.categories) { + try { + cats = JSON.parse(opts.categories); + } catch { + cats = opts.categories.split(",").map((c) => c.trim()); + } + } + + let result: Record; + try { + result = await timedStatus("Adding memory...", async () => { + return backend.add(content ?? undefined, msgs, { + userId: opts.userId, + agentId: opts.agentId, + appId: opts.appId, + runId: opts.runId, + metadata: meta, + immutable: opts.immutable, + infer: !opts.noInfer, + expires: opts.expires, + categories: cats, + enableGraph: opts.enableGraph, + }); + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + + if (opts.output === "quiet") return; + + if (opts.output === "json") { + formatAddResult(result, opts.output); + return; + } + + console.log(); + printScope({ user_id: opts.userId, agent_id: opts.agentId, app_id: opts.appId, run_id: opts.runId }); + const results = Array.isArray(result) ? result : ((result.results as unknown[]) ?? [result]); + const count = results.length; + printSuccess(`Memory processed — ${count} memor${count === 1 ? "y" : "ies"} extracted`); + formatAddResult(result, opts.output); +} + +export async function cmdSearch( + backend: Backend, + query: string | undefined, + opts: { + userId?: string; + agentId?: string; + appId?: string; + runId?: string; + topK: number; + threshold: number; + rerank: boolean; + keyword: boolean; + filterJson?: string; + fields?: string; + enableGraph: boolean; + output: string; + }, +): Promise { + if (!query) { + printError("No query provided. Pass a query argument or pipe via stdin."); + process.exit(1); + } + + let filters: Record | undefined; + if (opts.filterJson) { + try { + filters = JSON.parse(opts.filterJson); + } catch { + printError("Invalid JSON in --filter."); + process.exit(1); + } + } + + const fieldList = opts.fields ? opts.fields.split(",").map((f) => f.trim()) : undefined; + + const start = performance.now(); + let results: Record[]; + try { + results = await timedStatus("Searching memories...", async () => { + return backend.search(query!, { + userId: opts.userId, + agentId: opts.agentId, + appId: opts.appId, + runId: opts.runId, + topK: opts.topK, + threshold: opts.threshold, + rerank: opts.rerank, + keyword: opts.keyword, + filters, + fields: fieldList, + enableGraph: opts.enableGraph, + }); + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const elapsed = (performance.now() - start) / 1000; + + if (opts.output === "json") { + formatJson(results); + } else if (opts.output === "table") { + if (results.length > 0) { + formatMemoriesTable(results); + printResultSummary({ count: results.length, durationSecs: elapsed, scopeIds: { user_id: opts.userId, agent_id: opts.agentId } }); + } else { + console.log(); + printInfo("No memories found matching your query."); + console.log(); + } + } else { + if (results.length > 0) { + formatMemoriesText(results); + printResultSummary({ count: results.length, durationSecs: elapsed, scopeIds: { user_id: opts.userId, agent_id: opts.agentId } }); + } else { + console.log(); + printInfo("No memories found matching your query."); + console.log(); + } + } +} + +export async function cmdGet( + backend: Backend, + memoryId: string, + opts: { output: string }, +): Promise { + let result: Record; + try { + result = await timedStatus("Fetching memory...", async () => { + return backend.get(memoryId); + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + + formatSingleMemory(result, opts.output); +} + +export async function cmdList( + backend: Backend, + opts: { + userId?: string; + agentId?: string; + appId?: string; + runId?: string; + page: number; + pageSize: number; + category?: string; + after?: string; + before?: string; + enableGraph: boolean; + output: string; + }, +): Promise { + const start = performance.now(); + let results: Record[]; + try { + results = await timedStatus("Listing memories...", async () => { + return backend.listMemories({ + userId: opts.userId, + agentId: opts.agentId, + appId: opts.appId, + runId: opts.runId, + page: opts.page, + pageSize: opts.pageSize, + category: opts.category, + after: opts.after, + before: opts.before, + enableGraph: opts.enableGraph, + }); + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const elapsed = (performance.now() - start) / 1000; + + if (opts.output === "json") { + formatJson(results); + } else if (opts.output === "table") { + if (results.length > 0) { + formatMemoriesTable(results); + printResultSummary({ count: results.length, durationSecs: elapsed, page: opts.page, scopeIds: { user_id: opts.userId, agent_id: opts.agentId } }); + } else { + console.log(); + printInfo("No memories found."); + console.log(); + } + } else { + if (results.length > 0) { + formatMemoriesText(results, "memories"); + printResultSummary({ count: results.length, durationSecs: elapsed, page: opts.page, scopeIds: { user_id: opts.userId, agent_id: opts.agentId } }); + } else { + console.log(); + printInfo("No memories found."); + console.log(); + } + } +} + +export async function cmdUpdate( + backend: Backend, + memoryId: string, + text: string | undefined, + opts: { metadata?: string; output: string }, +): Promise { + let meta: Record | undefined; + if (opts.metadata) { + try { + meta = JSON.parse(opts.metadata); + } catch { + printError("Invalid JSON in --metadata."); + process.exit(1); + } + } + + const start = performance.now(); + let result: Record; + try { + result = await timedStatus("Updating memory...", async () => { + return backend.update(memoryId, text, meta); + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const elapsed = (performance.now() - start) / 1000; + + if (opts.output === "json") { + formatJson(result); + } else if (opts.output !== "quiet") { + printSuccess(`Memory ${memoryId.slice(0, 8)} updated (${elapsed.toFixed(2)}s)`); + } +} + +export async function cmdDelete( + backend: Backend, + memoryId: string, + opts: { output: string; dryRun?: boolean; force?: boolean }, +): Promise { + if (opts.dryRun) { + let mem: Record; + try { + mem = await backend.get(memoryId); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const text = (mem.memory ?? mem.text ?? "") as string; + printInfo(`Would delete memory ${memoryId.slice(0, 8)}: ${text}`); + printInfo("No changes made."); + return; + } + + const start = performance.now(); + let result: Record; + try { + result = await timedStatus("Deleting...", async () => { + return backend.delete(memoryId); + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const elapsed = (performance.now() - start) / 1000; + + if (opts.output === "json") { + formatJson(result); + } else if (opts.output !== "quiet") { + printSuccess(`Memory ${memoryId.slice(0, 8)} deleted (${elapsed.toFixed(2)}s)`); + } +} + +export async function cmdDeleteAll( + backend: Backend, + opts: { + force: boolean; + dryRun?: boolean; + all?: boolean; + userId?: string; + agentId?: string; + appId?: string; + runId?: string; + output: string; + }, +): Promise { + if (opts.all) { + // Project-wide wipe using wildcard entity IDs + if (opts.dryRun) { + printInfo("Would delete ALL memories project-wide."); + printInfo("No changes made."); + return; + } + + if (!opts.force) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question(`\n \u26a0 Delete ALL memories across the ENTIRE project? This cannot be undone. [y/N] `, resolve); + }); + rl.close(); + if (answer.toLowerCase() !== "y") { + printInfo("Cancelled."); + process.exit(0); + } + } + + const start = performance.now(); + let result: Record; + try { + result = await timedStatus("Deleting all memories project-wide...", async () => { + return backend.delete(undefined, { + all: true, + userId: "*", + agentId: "*", + appId: "*", + runId: "*", + }); + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const elapsed = (performance.now() - start) / 1000; + + if (opts.output === "json") { + formatJson(result); + } else if (opts.output !== "quiet") { + if (result.message) { + printInfo("Deletion started. Memories will be removed in the background."); + } else { + printSuccess(`All project memories deleted (${elapsed.toFixed(2)}s)`); + } + } + return; + } + + if (opts.dryRun) { + let memories: Record[]; + try { + memories = await backend.listMemories({ + userId: opts.userId, + agentId: opts.agentId, + appId: opts.appId, + runId: opts.runId, + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + printInfo(`Would delete ${memories.length} memories.`); + printInfo("No changes made."); + return; + } + + if (!opts.force) { + const scopeParts: string[] = []; + if (opts.userId) scopeParts.push(`user=${opts.userId}`); + if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`); + if (opts.appId) scopeParts.push(`app=${opts.appId}`); + if (opts.runId) scopeParts.push(`run=${opts.runId}`); + const scope = scopeParts.length > 0 ? scopeParts.join(", ") : "ALL entities"; + + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question(`\n \u26a0 Delete ALL memories for ${scope}? This cannot be undone. [y/N] `, resolve); + }); + rl.close(); + if (answer.toLowerCase() !== "y") { + printInfo("Cancelled."); + process.exit(0); + } + } + + const start = performance.now(); + let result: Record; + try { + result = await timedStatus("Deleting all memories...", async () => { + return backend.delete(undefined, { + all: true, + userId: opts.userId, + agentId: opts.agentId, + appId: opts.appId, + runId: opts.runId, + }); + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const elapsed = (performance.now() - start) / 1000; + + if (opts.output === "json") { + formatJson(result); + } else if (opts.output !== "quiet") { + if (result.message) { + printInfo("Deletion started. Memories will be removed in the background."); + } else { + printSuccess(`All matching memories deleted (${elapsed.toFixed(2)}s)`); + } + } +} diff --git a/cli/node/src/commands/utils.ts b/cli/node/src/commands/utils.ts new file mode 100644 index 000000000..b0ad611e7 --- /dev/null +++ b/cli/node/src/commands/utils.ts @@ -0,0 +1,139 @@ +/** + * Utility commands: status, version, import. + */ + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import { printError, printSuccess, timedStatus, colors } from "../branding.js"; +import type { Backend } from "../backend/base.js"; +import { formatJsonEnvelope } from "../output.js"; +import boxen from "boxen"; + +const { brand, dim, success, error: errorColor } = colors; + +const _require = createRequire(import.meta.url); +const VERSION: string = _require("../../package.json").version; + +export async function cmdStatus( + backend: Backend, + opts: { userId?: string; agentId?: string; output?: string } = {}, +): Promise { + const start = performance.now(); + let result: Record; + try { + result = await timedStatus("Checking connection...", async () => { + return backend.status({ userId: opts.userId, agentId: opts.agentId }); + }); + } catch (e) { + result = { connected: false, error: e instanceof Error ? e.message : String(e) }; + } + const elapsed = (performance.now() - start) / 1000; + + if (opts.output === "json") { + formatJsonEnvelope({ + command: "status", + data: { + connected: result.connected, + backend: result.backend ?? null, + base_url: result.base_url ?? null, + latency_ms: Math.round(elapsed * 1000), + }, + durationMs: Math.round(elapsed * 1000), + }); + return; + } + + const lines: string[] = []; + if (result.connected) { + lines.push(` ${success("\u25cf")} Connected`); + } else { + lines.push(` ${errorColor("\u25cf")} Disconnected`); + } + + lines.push(` ${dim("Backend:")} ${result.backend ?? "?"}`); + if (result.base_url) { + lines.push(` ${dim("API URL:")} ${result.base_url}`); + } + if (result.error) { + lines.push(` ${errorColor("Error:")} ${result.error}`); + } + lines.push(` ${dim("Latency:")} ${elapsed.toFixed(2)}s`); + + const content = lines.join("\n"); + console.log(); + console.log( + boxen(content, { + title: brand("Connection Status"), + titleAlignment: "left", + borderColor: "magenta", + padding: 1, + }), + ); + console.log(); +} + +export function cmdVersion(): void { + console.log(` ${brand("◆ Mem0")} CLI v${VERSION}`); +} + +export async function cmdImport( + backend: Backend, + filePath: string, + opts: { userId?: string; agentId?: string; output?: string }, +): Promise { + let data: Record[]; + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw); + data = Array.isArray(parsed) ? parsed : [parsed]; + } catch (e) { + printError(`Failed to read file: ${e instanceof Error ? e.message : e}`); + process.exit(1); + } + + let added = 0; + let failed = 0; + const start = performance.now(); + + for (let i = 0; i < data.length; i++) { + const item = data[i]; + const content = (item.memory ?? item.text ?? item.content ?? "") as string; + if (!content) { + failed++; + continue; + } + + try { + await backend.add(content, undefined, { + userId: opts.userId ?? (item.user_id as string | undefined), + agentId: opts.agentId ?? (item.agent_id as string | undefined), + metadata: item.metadata as Record | undefined, + }); + added++; + } catch { + failed++; + } + + // Simple progress indicator + if ((i + 1) % 10 === 0 || i === data.length - 1) { + process.stdout.write(`\r ${dim(`Importing memories... ${i + 1}/${data.length}`)}`); + } + } + + const elapsed = (performance.now() - start) / 1000; + console.log(); // Clear progress line + + if (opts.output === "json") { + formatJsonEnvelope({ + command: "import", + data: { added, failed, duration_s: parseFloat(elapsed.toFixed(2)) }, + durationMs: Math.round(elapsed * 1000), + }); + return; + } + + printSuccess(`Imported ${added} memories (${elapsed.toFixed(2)}s)`); + if (failed > 0) { + printError(`${failed} memories failed to import.`); + } +} diff --git a/cli/node/src/config.ts b/cli/node/src/config.ts new file mode 100644 index 000000000..53314f8a9 --- /dev/null +++ b/cli/node/src/config.ts @@ -0,0 +1,159 @@ +/** + * 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 + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const CONFIG_DIR = path.join(os.homedir(), ".mem0"); +export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); +export const DEFAULT_BASE_URL = "https://api.mem0.ai"; +export const CONFIG_VERSION = 1; + +export interface PlatformConfig { + apiKey: string; + baseUrl: string; +} + +export interface DefaultsConfig { + userId: string; + agentId: string; + appId: string; + runId: string; + enableGraph: boolean; +} + +export interface Mem0Config { + version: number; + defaults: DefaultsConfig; + platform: PlatformConfig; +} + +export function createDefaultConfig(): Mem0Config { + return { + version: CONFIG_VERSION, + defaults: { + userId: "", + agentId: "", + appId: "", + runId: "", + enableGraph: false, + }, + platform: { + apiKey: "", + baseUrl: DEFAULT_BASE_URL, + }, + }; +} + +export function ensureConfigDir(): string { + fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + return CONFIG_DIR; +} + +export function loadConfig(): Mem0Config { + const config = createDefaultConfig(); + + if (fs.existsSync(CONFIG_FILE)) { + const raw = fs.readFileSync(CONFIG_FILE, "utf-8"); + const data = JSON.parse(raw); + + config.version = data.version ?? CONFIG_VERSION; + + const plat = data.platform ?? {}; + config.platform.apiKey = plat.api_key ?? ""; + config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL; + + const defaults = data.defaults ?? {}; + config.defaults.userId = defaults.user_id ?? ""; + config.defaults.agentId = defaults.agent_id ?? ""; + config.defaults.appId = defaults.app_id ?? ""; + config.defaults.runId = defaults.run_id ?? ""; + config.defaults.enableGraph = defaults.enable_graph ?? false; + } + + // Environment variable overrides + if (process.env.MEM0_API_KEY) config.platform.apiKey = process.env.MEM0_API_KEY; + if (process.env.MEM0_BASE_URL) config.platform.baseUrl = process.env.MEM0_BASE_URL; + if (process.env.MEM0_USER_ID) config.defaults.userId = process.env.MEM0_USER_ID; + if (process.env.MEM0_AGENT_ID) config.defaults.agentId = process.env.MEM0_AGENT_ID; + if (process.env.MEM0_APP_ID) config.defaults.appId = process.env.MEM0_APP_ID; + if (process.env.MEM0_RUN_ID) config.defaults.runId = process.env.MEM0_RUN_ID; + if (process.env.MEM0_ENABLE_GRAPH) { + config.defaults.enableGraph = ["true", "1", "yes"].includes( + process.env.MEM0_ENABLE_GRAPH.toLowerCase(), + ); + } + + return config; +} + +export function saveConfig(config: Mem0Config): void { + ensureConfigDir(); + + const data = { + version: config.version, + defaults: { + user_id: config.defaults.userId, + agent_id: config.defaults.agentId, + app_id: config.defaults.appId, + run_id: config.defaults.runId, + enable_graph: config.defaults.enableGraph, + }, + platform: { + api_key: config.platform.apiKey, + base_url: config.platform.baseUrl, + }, + }; + + fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2)); + fs.chmodSync(CONFIG_FILE, 0o600); +} + +export function redactKey(key: string): string { + if (!key) return "(not set)"; + if (key.length <= 8) return key.slice(0, 2) + "***"; + return key.slice(0, 4) + "..." + key.slice(-4); +} + +/** Key map from dotted config path to the config object fields. */ +const KEY_MAP: Record = { + "platform.api_key": ["platform", "apiKey"], + "platform.base_url": ["platform", "baseUrl"], + "defaults.user_id": ["defaults", "userId"], + "defaults.agent_id": ["defaults", "agentId"], + "defaults.app_id": ["defaults", "appId"], + "defaults.run_id": ["defaults", "runId"], + "defaults.enable_graph": ["defaults", "enableGraph"], +}; + +export function getNestedValue(config: Mem0Config, dottedKey: string): unknown { + const mapping = KEY_MAP[dottedKey]; + if (!mapping) return undefined; + const [section, field] = mapping; + return (config[section] as unknown as Record)[field]; +} + +export function setNestedValue(config: Mem0Config, dottedKey: string, value: string): boolean { + const mapping = KEY_MAP[dottedKey]; + if (!mapping) return false; + const [section, field] = mapping; + const obj = config[section] as unknown as Record; + + const current = obj[field]; + if (typeof current === "boolean") { + obj[field] = ["true", "1", "yes"].includes(value.toLowerCase()); + } else if (typeof current === "number") { + obj[field] = parseInt(value, 10); + } else { + obj[field] = value; + } + return true; +} diff --git a/cli/node/src/help.ts b/cli/node/src/help.ts new file mode 100644 index 000000000..3dd3a491c --- /dev/null +++ b/cli/node/src/help.ts @@ -0,0 +1,374 @@ +/** + * Rich-style help formatter for Commander.js that matches the Python CLI's + * Typer + Rich output (rounded box panels, brand purple, grouped options). + */ + +import chalk from "chalk"; +import type { Command, Help, Option, Argument } from "commander"; +// Colors imported from chalk directly to match Typer/Rich defaults + +// ── Colors (matching Typer/Rich defaults) ──────────────────────────────── + +const cyanBold = chalk.cyan.bold; // option flags, command names +const greenBold = chalk.green.bold; // switch flags (boolean --force etc) +const yellowBold = chalk.yellow.bold; // metavar +const yellow = chalk.yellow; // "Usage:" label +const bold = chalk.bold; // command name in usage +const dim = chalk.dim; // defaults, descriptions +const dimBorder = chalk.dim; // panel borders + +// ── Strip ANSI ─────────────────────────────────────────────────────────── + +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function stripAnsi(str: string): number { + return str.replace(ANSI_RE, "").length; +} + +// ── Command display order (matches Python CLI) ────────────────────────── + +/** Commands grouped into panels, matching Python CLI's rich_help_panel. */ +const COMMAND_GROUPS: { panel: string; commands: string[] }[] = [ + { + panel: "Memory", + commands: ["add", "search", "get", "list", "update", "delete"], + }, + { + panel: "Management", + commands: ["init", "status", "import", "help", "entity", "config"], + }, +]; + +/** Flat order derived from COMMAND_GROUPS. */ +const COMMAND_ORDER: string[] = COMMAND_GROUPS.flatMap((g) => g.commands); + +// ── Option-to-panel mapping (derived from Python's rich_help_panel) ───── + +const OPTION_PANELS: Record> = { + add: { + "--user-id": "Scope", + "--agent-id": "Scope", + "--app-id": "Scope", + "--run-id": "Scope", + "--output": "Output", + "--api-key": "Connection", + "--base-url": "Connection", + }, + search: { + "--user-id": "Scope", + "--agent-id": "Scope", + "--app-id": "Scope", + "--run-id": "Scope", + "--top-k": "Search", + "--threshold": "Search", + "--rerank": "Search", + "--keyword": "Search", + "--filter": "Search", + "--fields": "Search", + "--graph": "Search", + "--no-graph": "Search", + "--output": "Output", + "--api-key": "Connection", + "--base-url": "Connection", + }, + get: { + "--output": "Output", + "--api-key": "Connection", + "--base-url": "Connection", + }, + list: { + "--user-id": "Scope", + "--agent-id": "Scope", + "--app-id": "Scope", + "--run-id": "Scope", + "--page": "Pagination", + "--page-size": "Pagination", + "--category": "Filters", + "--after": "Filters", + "--before": "Filters", + "--graph": "Filters", + "--no-graph": "Filters", + "--output": "Output", + "--api-key": "Connection", + "--base-url": "Connection", + }, + update: { + "--output": "Output", + "--api-key": "Connection", + "--base-url": "Connection", + }, + delete: { + "--user-id": "Scope", + "--agent-id": "Scope", + "--app-id": "Scope", + "--run-id": "Scope", + "--output": "Output", + "--api-key": "Connection", + "--base-url": "Connection", + }, + status: { + "--output": "Output", + "--api-key": "Connection", + "--base-url": "Connection", + }, + import: { + "--user-id": "Scope", + "--agent-id": "Scope", + "--output": "Output", + "--api-key": "Connection", + "--base-url": "Connection", + }, +}; + +const PANEL_ORDER: string[] = [ + "Scope", + "Search", + "Pagination", + "Filters", + "Output", + "Connection", +]; + +// ── Panel rendering ───────────────────────────────────────────────────── + +/** + * Render a Rich-style ROUNDED box panel. + * + * ``` + * ╭─ Title ────────────────────────╮ + * │ row content padded │ + * ╰────────────────────────────────╯ + * ``` + */ +function renderPanel( + title: string, + rows: string[], + width: number, +): string { + if (rows.length === 0) return ""; + + // Inner width is total width minus the two border chars + const inner = width - 2; + + // Top border: ╭─ Title ─...─╮ + const titleStr = ` ${title} `; + const fillLen = Math.max(0, inner - 1 - titleStr.length); + const topLine = + dimBorder("╭─") + + dimBorder(titleStr) + + dimBorder("─".repeat(fillLen)) + + dimBorder("╮"); + + // Bottom border: ╰─...─╯ + const bottomLine = dimBorder("╰") + dimBorder("─".repeat(inner)) + dimBorder("╯"); + + // Content rows + const contentLines = rows.map((row) => { + const visLen = stripAnsi(row); + const pad = Math.max(0, inner - 1 - visLen); + return dimBorder("│") + " " + row + " ".repeat(pad) + dimBorder("│"); + }); + + return [topLine, ...contentLines, bottomLine].join("\n"); +} + +// ── Format an option term (short + long) ──────────────────────────────── + +function formatOptionTerm(opt: Option): string { + const parts: string[] = []; + if (opt.short) parts.push(opt.short); + if (opt.long) parts.push(opt.long); + let term = parts.join(", "); + + // Append value placeholder for non-boolean options + if (opt.flags) { + const match = opt.flags.match(/<[^>]+>|\[[^\]]+\]/); + if (match) { + term += " " + match[0]; + } + } + return term; +} + +// ── Get the long flag name for panel lookup ───────────────────────────── + +function getLongFlag(opt: Option): string { + if (opt.long) return opt.long; + return opt.short || ""; +} + +// ── Format a default value ────────────────────────────────────────────── + +function formatDefault(opt: Option): string { + if (opt.defaultValue !== undefined && opt.defaultValue !== false) { + return dim(` [default: ${opt.defaultValue}]`); + } + return ""; +} + +// ── The main help formatter ───────────────────────────────────────────── + +export function richFormatHelp(cmd: Command, helper: Help): string { + const width = process.stdout.columns || 80; + const lines: string[] = []; + + const isRoot = !cmd.parent; + + // ── Usage line ── + const usage = helper.commandUsage(cmd); + lines.push(""); + if (isRoot) { + // Root: "Usage: mem0 [options]" — yellow, [options] bold + lines.push(` ${yellow("Usage:")} ${bold(cmd.name())} ${yellow("")} ${bold("[options]")}`); + } else { + // Subcommands: split into command path (bold) and args (yellow) + const usageParts = usage.split(" "); + const cmdPath: string[] = []; + const argParts: string[] = []; + let pastCmd = false; + for (const part of usageParts) { + if (!pastCmd && !part.startsWith("[") && !part.startsWith("<")) { + cmdPath.push(part); + } else { + pastCmd = true; + argParts.push(part); + } + } + lines.push(` ${yellow("Usage:")} ${bold(cmdPath.join(" "))} ${yellow(argParts.join(" "))}`); + } + lines.push(""); + + // ── Description ── + const desc = helper.commandDescription(cmd); + if (desc) { + // Split multi-line descriptions (e.g., title + tagline) + const descLines = desc.split("\n"); + for (let i = 0; i < descLines.length; i++) { + const dLine = descLines[i]; + // First line is the title, subsequent non-empty lines are tagline (dimmed) + if (i === 0 || dLine.trim() === "") { + lines.push(` ${dLine}`); + } else { + lines.push(` ${dim(dLine)}`); + } + } + lines.push(""); + } + + // ── Arguments panel (subcommands only) ── + if (!isRoot) { + const visibleArgs = helper.visibleArguments(cmd); + if (visibleArgs.length > 0) { + const maxLen = Math.max(...visibleArgs.map((a: Argument) => a.name().length)); + const argRows = visibleArgs.map((a: Argument) => { + const name = cyanBold(a.name().padEnd(maxLen)); + const description = helper.argumentDescription(a); + return ` ${name} ${description}`; + }); + const panel = renderPanel("Arguments", argRows, width); + if (panel) lines.push(panel); + } + } + + // ── Collect options (grouped into panels for subcommands) ── + const visibleOpts = helper.visibleOptions(cmd); + const cmdName = cmd.name(); + const panelMap = (!isRoot && OPTION_PANELS[cmdName]) ? OPTION_PANELS[cmdName] : {}; + + const grouped: Record = { Options: [] }; + for (const panelName of PANEL_ORDER) { + grouped[panelName] = []; + } + + for (const opt of visibleOpts) { + const flag = getLongFlag(opt); + const panel = panelMap[flag]; + if (panel && PANEL_ORDER.includes(panel)) { + grouped[panel].push(opt); + } else { + grouped["Options"].push(opt); + } + } + + // ── Collect commands ── + const visibleCmds = helper.visibleCommands(cmd); + + if (isRoot) { + // ROOT: Options first, then command groups (matches Python/Typer ordering) + if (grouped["Options"].length > 0) { + const optRows = formatOptionRows(grouped["Options"]); + const panel = renderPanel("Options", optRows, width); + if (panel) lines.push(panel); + } + if (visibleCmds.length > 0) { + const cmdMap = new Map(visibleCmds.map((c) => [c.name(), c])); + for (const group of COMMAND_GROUPS) { + const groupCmds = group.commands + .map((name) => cmdMap.get(name)) + .filter((c): c is Command => c !== undefined); + if (groupCmds.length === 0) continue; + const maxLen = Math.max(...groupCmds.map((c) => c.name().length)); + const cmdRows = groupCmds.map((c) => { + const name = cyanBold(c.name().padEnd(maxLen)); + const description = helper.subcommandDescription(c); + return ` ${name} ${description}`; + }); + const panel = renderPanel(group.panel, cmdRows, width); + if (panel) lines.push(panel); + } + } + } else { + // SUBCOMMANDS: Options/panels first, then sub-subcommands + const panelSequence = ["Options", ...PANEL_ORDER]; + for (const panelName of panelSequence) { + const opts = grouped[panelName]; + if (opts && opts.length > 0) { + const optRows = formatOptionRows(opts); + const panel = renderPanel(panelName, optRows, width); + if (panel) lines.push(panel); + } + } + // Sub-subcommands (e.g., config show/get/set, entity list/delete) + if (visibleCmds.length > 0) { + const maxLen = Math.max(...visibleCmds.map((c) => c.name().length)); + const cmdRows = visibleCmds.map((c) => { + const name = cyanBold(c.name().padEnd(maxLen)); + const description = helper.subcommandDescription(c); + return ` ${name} ${description}`; + }); + const panel = renderPanel("Commands", cmdRows, width); + if (panel) lines.push(panel); + } + } + + lines.push(""); + return lines.join("\n"); +} + +// ── Format option rows with aligned columns ───────────────────────────── + +function formatOptionRows(opts: Option[]): string[] { + const terms = opts.map((o) => formatOptionTerm(o)); + const maxTermLen = Math.max(...terms.map((t) => t.length)); + + return opts.map((opt, i) => { + const term = cyanBold(terms[i].padEnd(maxTermLen)); + const desc = opt.description || ""; + const def = formatDefault(opt); + return ` ${term} ${desc}${def}`; + }); +} + +// ── Sort commands by COMMAND_ORDER ────────────────────────────────────── + +function sortCommands(cmds: Command[]): Command[] { + return [...cmds].sort((a, b) => { + const ai = COMMAND_ORDER.indexOf(a.name()); + const bi = COMMAND_ORDER.indexOf(b.name()); + // Unknown commands go to end, preserving original order + const aIdx = ai === -1 ? COMMAND_ORDER.length : ai; + const bIdx = bi === -1 ? COMMAND_ORDER.length : bi; + return aIdx - bIdx; + }); +} diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts new file mode 100644 index 000000000..ea6103322 --- /dev/null +++ b/cli/node/src/index.ts @@ -0,0 +1,501 @@ +#!/usr/bin/env node + +/** + * Main CLI application — the entrypoint for `mem0`. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { Command } from "commander"; +import type { Mem0Config } from "./config.js"; +import { loadConfig } from "./config.js"; +import { getBackend, type Backend } from "./backend/index.js"; +import { printError, colors } from "./branding.js"; +import { richFormatHelp } from "./help.js"; + +const _require = createRequire(import.meta.url); +const VERSION: string = _require("../package.json").version; + +const program = new Command(); + +// ── Helpers ────────────────────────────────────────────────────────────── + +function getBackendAndConfig( + apiKey?: string, + baseUrl?: string, +): { backend: Backend; config: Mem0Config } { + const config = loadConfig(); + + if (apiKey) config.platform.apiKey = apiKey; + if (baseUrl) config.platform.baseUrl = baseUrl; + + if (!config.platform.apiKey) { + printError( + "No API key configured.", + "Run 'mem0 init' or set MEM0_API_KEY environment variable.", + ); + process.exit(1); + } + + return { backend: getBackend(config), config }; +} + +function getBackendOnly(apiKey?: string, baseUrl?: string): Backend { + return getBackendAndConfig(apiKey, baseUrl).backend; +} + +/** + * Resolve entity IDs: CLI flag > config default > undefined. + * + * If any explicit ID is provided, only use explicit IDs (don't mix + * in defaults for other entity types which would over-filter). + * If no explicit IDs, fall back to all configured defaults. + */ +function resolveIds( + config: Mem0Config, + opts: { + userId?: string; + agentId?: string; + appId?: string; + runId?: string; + }, +): { userId?: string; agentId?: string; appId?: string; runId?: string } { + const hasExplicit = !!(opts.userId || opts.agentId || opts.appId || opts.runId); + if (hasExplicit) { + return { + userId: opts.userId || undefined, + agentId: opts.agentId || undefined, + appId: opts.appId || undefined, + runId: opts.runId || undefined, + }; + } + return { + userId: config.defaults.userId || undefined, + agentId: config.defaults.agentId || undefined, + appId: config.defaults.appId || undefined, + runId: config.defaults.runId || undefined, + }; +} + +/** + * Resolve graph tri-state: --no-graph > --graph > config default. + */ +function resolveGraph( + config: Mem0Config, + opts: { graph?: boolean; noGraph?: boolean }, +): boolean { + if (opts.noGraph) return false; + if (opts.graph) return true; + return config.defaults.enableGraph; +} + +// ── Main program ────────────────────────────────────────────────────────── + +program + .name("mem0") + .description(`◆ Mem0 CLI v${VERSION} · Node.js SDK\n\nThe Memory Layer for AI Agents`) + .option("--version", "Show version and exit.") + .on("option:version", () => { + console.log(` ${colors.brand("◆ Mem0")} CLI v${VERSION}`); + process.exit(0); + }) + .usage(" [options]") + .helpOption("--help", "Show this message and exit.") + .addHelpCommand(false) + .configureHelp({ formatHelp: richFormatHelp }); + +// ── Init ────────────────────────────────────────────────────────────────── + +program + .command("init") + .description("Interactive setup wizard for mem0 CLI.") + .option("--api-key ", "API key (skip prompt).") + .option("-u, --user-id ", "Default user ID (skip prompt).") + .addHelpText("after", "\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice") + .action(async (opts) => { + const { runInit } = await import("./commands/init.js"); + await runInit({ apiKey: opts.apiKey, userId: opts.userId }); + }); + +// ── Memory: add ─────────────────────────────────────────────────────────── + +program + .command("add [text]") + .description("Add a memory from text, messages, file, or stdin.") + .option("-u, --user-id ", "Scope to user.") + .option("--agent-id ", "Scope to agent.") + .option("--app-id ", "Scope to app.") + .option("--run-id ", "Scope to run.") + .option("--messages ", "Conversation messages as JSON.") + .option("-f, --file ", "Read messages from JSON file.") + .option("-m, --metadata ", "Custom metadata as JSON.") + .option("--immutable", "Prevent future updates.", false) + .option("--no-infer", "Skip inference, store raw.") + .option("--expires ", "Expiration date (YYYY-MM-DD).") + .option("--categories ", "Categories (JSON array or comma-separated).") + .option("--graph", "Enable graph memory extraction.", false) + .option("--no-graph", "Disable graph memory extraction.") + .option("-o, --output ", "Output format: text, json, quiet.", "text") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText("after", '\nExamples:\n $ mem0 add "I prefer dark mode" --user-id alice\n $ echo "text" | mem0 add -u alice\n $ mem0 add --file msgs.json -u alice -o json') + .action(async (text, opts) => { + const { cmdAdd } = await import("./commands/memory.js"); + const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); + const ids = resolveIds(config, opts); + const enableGraph = resolveGraph(config, opts); + await cmdAdd(backend, text, { ...ids, ...opts, enableGraph }); + }); + +// ── Memory: search ──────────────────────────────────────────────────────── + +program + .command("search [query]") + .description("Search memories by semantic query.") + .option("-u, --user-id ", "Filter by user.") + .option("--agent-id ", "Filter by agent.") + .option("--app-id ", "Filter by app.") + .option("--run-id ", "Filter by run.") + .option("-k, --top-k ", "Number of results.", (v) => parseInt(v), 10) + .option("--threshold ", "Minimum similarity score.", (v) => parseFloat(v), 0.3) + .option("--rerank", "Enable reranking (Platform only).", false) + .option("--keyword", "Use keyword search.", false) + .option("--filter ", "Advanced filter expression (JSON).") + .option("--fields ", "Specific fields to return (comma-separated).") + .option("--graph", "Enable graph in search.", false) + .option("--no-graph", "Disable graph in search.") + .option("-o, --output ", "Output: text, json, table.", "text") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText("after", '\nExamples:\n $ mem0 search "preferences" --user-id alice\n $ mem0 search "tools" -u alice -o json -k 5\n $ echo "preferences" | mem0 search -u alice') + .action(async (query, opts) => { + let resolvedQuery = query; + if (!resolvedQuery && !process.stdin.isTTY) { + resolvedQuery = fs.readFileSync(0, "utf-8").trim(); + } + if (!resolvedQuery) { + printError("No query provided. Pass a query argument or pipe via stdin."); + process.exit(1); + } + const { cmdSearch } = await import("./commands/memory.js"); + const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); + const ids = resolveIds(config, opts); + const enableGraph = resolveGraph(config, opts); + await cmdSearch(backend, resolvedQuery, { + ...ids, + topK: opts.topK, + threshold: opts.threshold, + rerank: opts.rerank, + keyword: opts.keyword, + filterJson: opts.filter, + fields: opts.fields, + enableGraph, + output: opts.output, + }); + }); + +// ── Memory: get ─────────────────────────────────────────────────────────── + +program + .command("get ") + .description("Get a specific memory by ID.") + .option("-o, --output ", "Output: text, json.", "text") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText("after", "\nExamples:\n $ mem0 get abc-123-def-456\n $ mem0 get abc-123-def-456 -o json") + .action(async (memoryId, opts) => { + const { cmdGet } = await import("./commands/memory.js"); + const backend = getBackendOnly(opts.apiKey, opts.baseUrl); + await cmdGet(backend, memoryId, { output: opts.output }); + }); + +// ── Memory: list ────────────────────────────────────────────────────────── + +program + .command("list") + .description("List memories with optional filters.") + .option("-u, --user-id ", "Filter by user.") + .option("--agent-id ", "Filter by agent.") + .option("--app-id ", "Filter by app.") + .option("--run-id ", "Filter by run.") + .option("--page ", "Page number.", (v) => parseInt(v), 1) + .option("--page-size ", "Results per page.", (v) => parseInt(v), 100) + .option("--category ", "Filter by category.") + .option("--after ", "Created after (YYYY-MM-DD).") + .option("--before ", "Created before (YYYY-MM-DD).") + .option("--graph", "Enable graph in listing.", false) + .option("--no-graph", "Disable graph in listing.") + .option("-o, --output ", "Output: text, json, table.", "table") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText("after", "\nExamples:\n $ mem0 list -u alice\n $ mem0 list --category prefs --after 2024-01-01 -o json") + .action(async (opts) => { + const { cmdList } = await import("./commands/memory.js"); + const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); + const ids = resolveIds(config, opts); + const enableGraph = resolveGraph(config, opts); + await cmdList(backend, { + ...ids, + page: opts.page, + pageSize: opts.pageSize, + category: opts.category, + after: opts.after, + before: opts.before, + enableGraph, + output: opts.output, + }); + }); + +// ── Memory: update ──────────────────────────────────────────────────────── + +program + .command("update [text]") + .description("Update a memory's text or metadata.") + .option("-m, --metadata ", "Update metadata (JSON).") + .option("-o, --output ", "Output: text, json, quiet.", "text") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText("after", `\nExamples:\n $ mem0 update abc-123 "new text"\n $ mem0 update abc-123 --metadata '{"key":"val"}'\n $ echo "new text" | mem0 update abc-123`) + .action(async (memoryId, text, opts) => { + let resolvedText = text; + if (!resolvedText && !opts.metadata && !process.stdin.isTTY) { + resolvedText = fs.readFileSync(0, "utf-8").trim(); + } + const { cmdUpdate } = await import("./commands/memory.js"); + const backend = getBackendOnly(opts.apiKey, opts.baseUrl); + await cmdUpdate(backend, memoryId, resolvedText, { metadata: opts.metadata, output: opts.output }); + }); + +// ── Memory: delete (consolidated) ───────────────────────────────────────── + +program + .command("delete [memoryId]") + .description("Delete a memory, all memories matching a scope, or an entity.") + .option("--all", "Delete all memories matching scope filters.", false) + .option("--entity", "Delete the entity itself and all its memories (cascade).", false) + .option("--project", "With --all: delete ALL memories project-wide.", false) + .option("--dry-run", "Show what would be deleted without deleting.", false) + .option("--force", "Skip confirmation.", false) + .option("-u, --user-id ", "Scope to user.") + .option("--agent-id ", "Scope to agent.") + .option("--app-id ", "Scope to app.") + .option("--run-id ", "Scope to run.") + .option("-o, --output ", "Output: text, json, quiet.", "text") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText("after", [ + "\nExamples:", + " $ mem0 delete abc-123-def-456 # single memory", + " $ mem0 delete --all -u alice --force # all memories for user", + " $ mem0 delete --all --project --force # project-wide wipe", + " $ mem0 delete --entity -u alice --force # entity + all its memories", + ].join("\n")) + .action(async (memoryId, opts) => { + // ── Mutual-exclusion checks ── + if (memoryId && opts.all) { + printError("Cannot combine with --all. Use one or the other."); + process.exit(1); + } + if (memoryId && opts.entity) { + printError("Cannot combine with --entity. Use one or the other."); + process.exit(1); + } + if (opts.all && opts.entity) { + printError("Cannot combine --all with --entity. Use one or the other."); + process.exit(1); + } + if (!memoryId && !opts.all && !opts.entity) { + printError( + "Specify a memory ID, --all, or --entity.\n" + + " mem0 delete Delete a single memory\n" + + " mem0 delete --all [scope] Delete all memories matching scope\n" + + " mem0 delete --entity [scope] Delete an entity and all its memories", + ); + process.exit(1); + } + + // ── Dispatch: single memory ── + if (memoryId) { + const { cmdDelete } = await import("./commands/memory.js"); + const backend = getBackendOnly(opts.apiKey, opts.baseUrl); + await cmdDelete(backend, memoryId, { output: opts.output, dryRun: opts.dryRun, force: opts.force }); + return; + } + + // ── Dispatch: --all ── + if (opts.all) { + const { cmdDeleteAll } = await import("./commands/memory.js"); + const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); + const ids = opts.project + ? { userId: undefined, agentId: undefined, appId: undefined, runId: undefined } + : resolveIds(config, opts); + await cmdDeleteAll(backend, { force: opts.force, dryRun: opts.dryRun, all: opts.project, ...ids, output: opts.output }); + return; + } + + // ── Dispatch: --entity ── + if (opts.entity) { + const { cmdEntitiesDelete } = await import("./commands/entities.js"); + const backend = getBackendOnly(opts.apiKey, opts.baseUrl); + await cmdEntitiesDelete(backend, opts); + return; + } + }); + +// ── Config subcommands ──────────────────────────────────────────────────── + +const configCmd = program + .command("config") + .description("Manage mem0 configuration.") + .addHelpCommand(false); + +configCmd + .command("show") + .description("Display current configuration (secrets redacted).") + .option("-o, --output ", "Output: text, json.", "text") + .addHelpText("after", "\nExamples:\n $ mem0 config show\n $ mem0 config show -o json") + .action(async (opts) => { + const { cmdConfigShow } = await import("./commands/config.js"); + cmdConfigShow({ output: opts.output }); + }); + +configCmd + .command("get ") + .description("Get a configuration value.") + .addHelpText("after", "\nExamples:\n $ mem0 config get platform.api_key\n $ mem0 config get defaults.user_id") + .action(async (key) => { + const { cmdConfigGet } = await import("./commands/config.js"); + cmdConfigGet(key); + }); + +configCmd + .command("set ") + .description("Set a configuration value.") + .addHelpText("after", "\nExamples:\n $ mem0 config set defaults.user_id alice\n $ mem0 config set platform.base_url https://api.mem0.ai") + .action(async (key, value) => { + const { cmdConfigSet } = await import("./commands/config.js"); + cmdConfigSet(key, value); + }); + +// ── Entity subcommand group ─────────────────────────────────────────────── + +const entityCmd = program + .command("entity") + .description("Manage entities.") + .addHelpCommand(false) + .configureHelp({ formatHelp: richFormatHelp }); + +entityCmd + .command("list ") + .description("List all entities of a given type.") + .option("-o, --output ", "Output: table, json.", "table") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText("after", "\nExamples:\n $ mem0 entity list users\n $ mem0 entity list agents -o json") + .action(async (entityType, opts) => { + const { cmdEntitiesList } = await import("./commands/entities.js"); + const backend = getBackendOnly(opts.apiKey, opts.baseUrl); + await cmdEntitiesList(backend, entityType, { output: opts.output }); + }); + +entityCmd + .command("delete") + .description("Delete an entity and ALL its memories (cascade).") + .option("--dry-run", "Show what would be deleted without deleting.", false) + .option("-u, --user-id ", "Scope to user.") + .option("--agent-id ", "Scope to agent.") + .option("--app-id ", "Scope to app.") + .option("--run-id ", "Scope to run.") + .option("--force", "Skip confirmation.", false) + .option("-o, --output ", "Output: text, json, quiet.", "text") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText("after", "\nExamples:\n $ mem0 entity delete --user-id alice --force\n $ mem0 entity delete --user-id alice --dry-run") + .action(async (opts) => { + const { cmdEntitiesDelete } = await import("./commands/entities.js"); + const backend = getBackendOnly(opts.apiKey, opts.baseUrl); + await cmdEntitiesDelete(backend, opts); + }); + +// ── Utility commands ────────────────────────────────────────────────────── + +program + .command("status") + .description("Check connectivity and authentication.") + .option("-o, --output ", "Output: text, json.", "text") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText("after", "\nExamples:\n $ mem0 status\n $ mem0 status -o json") + .action(async (opts) => { + const { cmdStatus } = await import("./commands/utils.js"); + const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); + await cmdStatus(backend, { + userId: config.defaults.userId || undefined, + agentId: config.defaults.agentId || undefined, + output: opts.output, + }); + }); + + +program + .command("import ") + .description("Import memories from a JSON file.") + .option("-u, --user-id ", "Override user ID.") + .option("--agent-id ", "Override agent ID.") + .option("-o, --output ", "Output: text, json.", "text") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText("after", "\nExamples:\n $ mem0 import data.json --user-id alice\n $ mem0 import data.json -u alice -o json") + .action(async (filePath, opts) => { + const { cmdImport } = await import("./commands/utils.js"); + const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); + const ids = resolveIds(config, opts); + await cmdImport(backend, filePath, { userId: ids.userId, agentId: ids.agentId, output: opts.output }); + }); + +// ── Help (machine-readable) ────────────────────────────────────────────── + +program + .command("help") + .description("Show help. Use --json for machine-readable output (for LLM agents).") + .option("--json", "Output machine-readable JSON for LLM agents.", false) + .addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json") + .action((opts) => { + if (opts.json) { + // Load spec from parent directory + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const specPath = path.join(__dirname, "..", "..", "cli-spec.json"); + if (fs.existsSync(specPath)) { + const spec = JSON.parse(fs.readFileSync(specPath, "utf-8")); + console.log(JSON.stringify(spec, null, 2)); + } else { + console.log(JSON.stringify({ name: "mem0", version: VERSION, description: "The Memory Layer for AI Agents" }, null, 2)); + } + } else { + const { brand: b } = colors; + console.log(`${b("◆ Mem0 CLI")} v${VERSION} · Node.js SDK\n The Memory Layer for AI Agents\n`); + console.log("Usage: mem0 [OPTIONS]\n"); + console.log("Commands:"); + console.log(" add Add a memory from text, messages, file, or stdin"); + console.log(" search Search memories by semantic query"); + console.log(" get Get a specific memory by ID"); + console.log(" list List memories with optional filters"); + console.log(" update Update a memory's text or metadata"); + console.log(" delete Delete a memory, all memories, or an entity"); + console.log(" import Import memories from a JSON file"); + console.log(" config Manage configuration (show, get, set)"); + console.log(" entity Manage entities (list, delete)"); + console.log(" init Interactive setup wizard"); + console.log(" status Check connectivity and authentication"); + console.log(); + console.log(" mem0 --help Get help for a command"); + console.log(" mem0 help --json Machine-readable help (for LLM agents)"); + console.log(); + } + }); + +// ── Entrypoint ──────────────────────────────────────────────────────────── + +program.parse(); diff --git a/cli/node/src/output.ts b/cli/node/src/output.ts new file mode 100644 index 000000000..f5e5b3403 --- /dev/null +++ b/cli/node/src/output.ts @@ -0,0 +1,230 @@ +/** + * Output formatting for mem0 CLI — text, JSON, table, quiet modes. + */ + +import Table from "cli-table3"; +import boxen from "boxen"; +import { colors, sym } from "./branding.js"; + +const { brand, accent, success, error: errorColor, dim } = colors; + +function formatDate(dtStr?: string): string | undefined { + if (!dtStr) return undefined; + try { + const dt = new Date(dtStr.replace("Z", "+00:00")); + return dt.toISOString().slice(0, 10); + } catch { + return dtStr?.slice(0, 10); + } +} + +export function formatMemoriesText( + memories: Record[], + title = "memories", +): void { + const count = memories.length; + console.log(`\n${brand(`Found ${count} ${title}:`)}\n`); + + for (let i = 0; i < memories.length; i++) { + const mem = memories[i]; + const memoryText = (mem.memory ?? mem.text ?? "") as string; + const memId = ((mem.id as string) ?? "").slice(0, 8); + const score = mem.score as number | undefined; + const created = formatDate(mem.created_at as string | undefined); + let category: string | undefined; + const cats = mem.categories; + if (Array.isArray(cats)) { + category = cats[0] as string | undefined; + } + + console.log(` ${i + 1}. ${memoryText}`); + + const details: string[] = []; + if (score !== undefined) details.push(`Score: ${score.toFixed(2)}`); + if (memId) details.push(`ID: ${memId}`); + if (created) details.push(`Created: ${created}`); + if (category) details.push(`Category: ${category}`); + + if (details.length > 0) { + console.log(` ${dim(details.join(" · "))}`); + } + console.log(); + } +} + +export function formatMemoriesTable(memories: Record[]): void { + const table = new Table({ + head: [accent("ID"), accent("Memory"), accent("Category"), accent("Created")], + colWidths: [12, 52, 16, 14], + wordWrap: true, + style: { head: [], border: [] }, + }); + + for (const mem of memories) { + const memId = ((mem.id as string) ?? "").slice(0, 8); + let memoryText = (mem.memory ?? mem.text ?? "") as string; + if (memoryText.length > 60) { + memoryText = memoryText.slice(0, 57) + "..."; + } + const categories = mem.categories; + const cat = + Array.isArray(categories) && categories.length > 0 + ? (categories[0] as string) + : "—"; + const created = formatDate(mem.created_at as string | undefined) ?? "—"; + table.push([dim(memId), memoryText, cat, created]); + } + + console.log(); + console.log(table.toString()); + console.log(); +} + +export function formatJson(data: unknown): void { + console.log(JSON.stringify(data, null, 2)); +} + +export function formatSingleMemory( + mem: Record, + output = "text", +): void { + if (output === "json") { + formatJson(mem); + return; + } + + const memoryText = (mem.memory ?? mem.text ?? "") as string; + const memId = (mem.id ?? "") as string; + + const lines: string[] = []; + lines.push(` ${memoryText}`); + lines.push(""); + + if (memId) lines.push(` ${dim("ID:")} ${memId}`); + const created = formatDate(mem.created_at as string | undefined); + if (created) lines.push(` ${dim("Created:")} ${created}`); + const updated = formatDate(mem.updated_at as string | undefined); + if (updated) lines.push(` ${dim("Updated:")} ${updated}`); + const meta = mem.metadata; + if (meta) lines.push(` ${dim("Metadata:")} ${JSON.stringify(meta)}`); + const categories = mem.categories; + if (categories) { + const catStr = Array.isArray(categories) ? categories.join(", ") : String(categories); + lines.push(` ${dim("Categories:")} ${catStr}`); + } + + const content = lines.join("\n"); + console.log(); + console.log( + boxen(content, { + title: brand("Memory"), + titleAlignment: "left", + borderColor: "magenta", + padding: 1, + }), + ); + console.log(); +} + +export function formatAddResult( + result: Record | Record[], + output = "text", +): void { + if (output === "json") { + formatJson(result); + return; + } + if (output === "quiet") return; + + const results: Record[] = Array.isArray(result) + ? result + : ((result.results as Record[]) ?? [result]); + + if (!results.length) { + console.log(` ${dim("No memories extracted.")}`); + return; + } + + console.log(); + for (const r of results) { + // Detect async PENDING response + if (r.status === "PENDING") { + const eventId = ((r.event_id as string) ?? "").slice(0, 8); + const icon = accent(sym("⧗", "...")); + const parts = [` ${icon} ${dim("Queued".padEnd(10))}`, "Processing in background"]; + if (eventId) parts.push(dim(`(event ${eventId})`)); + console.log(parts.join(" ")); + continue; + } + + const event = (r.event ?? "ADD") as string; + const memory = (r.memory ?? r.text ?? r.content ?? r.data ?? "") as string; + const memId = ((r.id as string) ?? (r.memory_id as string) ?? "").slice(0, 8); + + let icon: string; + let label: string; + if (event === "ADD") { + icon = success("+"); + label = "Added"; + } else if (event === "UPDATE") { + icon = accent("~"); + label = "Updated"; + } else if (event === "DELETE") { + icon = errorColor("-"); + label = "Deleted"; + } else if (event === "NOOP") { + icon = dim("·"); + label = "No change"; + } else { + icon = dim("?"); + label = event; + } + + const parts = [` ${icon} ${dim(label.padEnd(10))}`]; + if (memory) parts.push(memory); + if (memId) parts.push(dim(`(${memId})`)); + console.log(parts.join(" ")); + } + console.log(); +} + +export function formatJsonEnvelope(opts: { + command: string; + data: unknown; + durationMs?: number; + scope?: Record; + count?: number; + status?: string; + error?: string; +}): void { + const envelope: Record = { + status: opts.status ?? "success", + command: opts.command, + }; + if (opts.durationMs !== undefined) envelope.duration_ms = opts.durationMs; + if (opts.scope !== undefined) envelope.scope = opts.scope; + if (opts.count !== undefined) envelope.count = opts.count; + if (opts.error) envelope.error = opts.error; + envelope.data = opts.data; + console.log(JSON.stringify(envelope, null, 2)); +} + +export function printResultSummary(opts: { + count: number; + durationSecs?: number; + page?: number; + scopeIds?: Record; +}): void { + const parts = [`${opts.count} result${opts.count !== 1 ? "s" : ""}`]; + if (opts.page !== undefined) parts.push(`page ${opts.page}`); + if (opts.scopeIds) { + const scopeParts = Object.entries(opts.scopeIds) + .filter(([, v]) => v) + .map(([k, v]) => `${k.replace(/_/g, " ")}=${v}`); + if (scopeParts.length > 0) parts.push(scopeParts.join(", ")); + } + if (opts.durationSecs !== undefined) parts.push(`${opts.durationSecs.toFixed(2)}s`); + + console.log(` ${dim(parts.join(" · "))}`); + console.log(); +} diff --git a/cli/node/tests/branding.test.ts b/cli/node/tests/branding.test.ts new file mode 100644 index 000000000..efa2f4759 --- /dev/null +++ b/cli/node/tests/branding.test.ts @@ -0,0 +1,98 @@ +/** + * Tests for branding utilities. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + BRAND_COLOR, + SUCCESS_COLOR, + ERROR_COLOR, + TAGLINE, + LOGO_MINI, + printSuccess, + printError, + printWarning, + printInfo, + printScope, +} from "../src/branding.js"; + +let output: string; +let errOutput: string; +const originalLog = console.log; +const originalError = console.error; + +beforeEach(() => { + output = ""; + errOutput = ""; + console.log = (...args: unknown[]) => { + output += args.map(String).join(" ") + "\n"; + }; + console.error = (...args: unknown[]) => { + errOutput += args.map(String).join(" ") + "\n"; + }; +}); + +afterEach(() => { + console.log = originalLog; + console.error = originalError; +}); + +describe("branding constants", () => { + it("has correct brand color", () => { + expect(BRAND_COLOR).toBe("#8b5cf6"); + }); + + it("has correct tagline", () => { + expect(TAGLINE).toBe("The Memory Layer for AI Agents"); + }); + + it("has correct logo mini", () => { + expect(LOGO_MINI).toBe("◆ mem0"); + }); +}); + +describe("printSuccess", () => { + it("prints success message", () => { + printSuccess("Operation completed"); + expect(output).toContain("Operation completed"); + }); +}); + +describe("printError", () => { + it("prints error message to stderr", () => { + printError("Something failed"); + expect(errOutput).toContain("Something failed"); + }); + + it("prints hint when provided to stderr", () => { + printError("Failed", "Try again"); + expect(errOutput).toContain("Try again"); + }); +}); + +describe("printWarning", () => { + it("prints warning message to stderr", () => { + printWarning("Be careful"); + expect(errOutput).toContain("Be careful"); + }); +}); + +describe("printInfo", () => { + it("prints info message", () => { + printInfo("Important note"); + expect(output).toContain("Important note"); + }); +}); + +describe("printScope", () => { + it("prints scope when IDs present", () => { + printScope({ user_id: "alice", agent_id: "bot" }); + expect(output).toContain("alice"); + expect(output).toContain("bot"); + }); + + it("prints nothing when no IDs", () => { + printScope({}); + expect(output).toBe(""); + }); +}); diff --git a/cli/node/tests/cli-integration.test.ts b/cli/node/tests/cli-integration.test.ts new file mode 100644 index 000000000..e89a259aa --- /dev/null +++ b/cli/node/tests/cli-integration.test.ts @@ -0,0 +1,156 @@ +/** + * Integration tests — invoke CLI as subprocess to test end-to-end. + */ + +import { describe, it, expect } from "vitest"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +function run( + args: string[], + opts: { home?: string; env?: Record } = {}, +): { stdout: string; stderr: string; exitCode: number } { + const env = { ...process.env }; + // Strip MEM0_ env vars + for (const key of Object.keys(env)) { + if (key.startsWith("MEM0_")) delete env[key]; + } + if (opts.home) env.HOME = opts.home; + if (opts.env) Object.assign(env, opts.env); + + try { + const stdout = execSync( + `npx tsx src/index.ts ${args.join(" ")}`, + { cwd: path.join(__dirname, ".."), env, encoding: "utf-8", timeout: 15000 }, + ); + return { stdout, stderr: "", exitCode: 0 }; + } catch (e: any) { + return { + stdout: e.stdout ?? "", + stderr: e.stderr ?? "", + exitCode: e.status ?? 1, + }; + } +} + +describe("CLI Integration — help and version", () => { + it("shows help with --help", () => { + const result = run(["--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("mem0"); + expect(result.stdout).toContain("add"); + expect(result.stdout).toContain("search"); + }); + + it("shows version with --version", () => { + const result = run(["--version"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("0.1.0"); + }); + + + it("help --json produces valid JSON", () => { + const result = run(["help", "--json"]); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + // spec may have cli.name or top-level name + const name = parsed.name ?? parsed.cli?.name; + expect(name).toBe("mem0"); + }); + + it("shows add help", () => { + const result = run(["add", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("user-id"); + expect(result.stdout).toContain("messages"); + }); + + it("shows search help", () => { + const result = run(["search", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("top-k"); + }); + + it("shows list help", () => { + const result = run(["list", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("page-size"); + }); + + it("shows delete help with --all, --entity, --project", () => { + const result = run(["delete", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--all"); + expect(result.stdout).toContain("--entity"); + expect(result.stdout).toContain("--project"); + expect(result.stdout).toContain("--force"); + expect(result.stdout.toLowerCase()).toContain("memory"); + }); + + it("delete with no args errors", () => { + const result = run(["delete"]); + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toContain("--all"); + }); + + it("shows entity list help", () => { + const result = run(["entity", "list", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout.toLowerCase()).toContain("entitytype"); + }); + + it("shows entity delete help", () => { + const result = run(["entity", "delete", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--user-id"); + expect(result.stdout).toContain("--force"); + }); + + it("shows import help", () => { + const result = run(["import", "--help"]); + expect(result.exitCode).toBe(0); + }); + + it("add help has --graph flag", () => { + const result = run(["add", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--graph"); + }); + + it("search help has --graph flag", () => { + const result = run(["search", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--graph"); + }); + + it("list help has --graph flag", () => { + const result = run(["list", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--graph"); + }); +}); + +describe("CLI Integration — isolated (clean home)", () => { + function cleanHome(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-")); + } + + it("add without API key errors", () => { + const home = cleanHome(); + const result = run(["add", "test", "--user-id", "alice"], { home }); + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined.toLowerCase()).toMatch(/api.key|error/i); + fs.rmSync(home, { recursive: true, force: true }); + }); + + it("config show works with clean home", () => { + const home = cleanHome(); + const result = run(["config", "show"], { home }); + expect(result.exitCode).toBe(0); + fs.rmSync(home, { recursive: true, force: true }); + }); +}); diff --git a/cli/node/tests/commands.test.ts b/cli/node/tests/commands.test.ts new file mode 100644 index 000000000..5432798d4 --- /dev/null +++ b/cli/node/tests/commands.test.ts @@ -0,0 +1,221 @@ +/** + * Tests for CLI commands using mock backend. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createMockBackend } from "./setup.js"; +import type { Backend } from "../src/backend/base.js"; + +let mockBackend: Backend; + +// Capture console.log and console.error output +let output: string; +let errOutput: string; +const originalLog = console.log; +const originalError = console.error; + +beforeEach(() => { + mockBackend = createMockBackend(); + output = ""; + errOutput = ""; + console.log = (...args: unknown[]) => { + output += args.map(String).join(" ") + "\n"; + }; + console.error = (...args: unknown[]) => { + errOutput += args.map(String).join(" ") + "\n"; + }; +}); + +// Restore after each test +import { afterEach } from "vitest"; +afterEach(() => { + console.log = originalLog; + console.error = originalError; +}); + +describe("cmdAdd", () => { + it("adds text memory", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "I prefer dark mode", { + userId: "alice", + immutable: false, + noInfer: false, + enableGraph: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledOnce(); + }); + + it("adds from messages JSON", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, undefined, { + userId: "alice", + messages: JSON.stringify([{ role: "user", content: "I love Python" }]), + immutable: false, + noInfer: false, + enableGraph: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledOnce(); + }); + + it("outputs json format", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + noInfer: false, + enableGraph: false, + output: "json", + }); + expect(output).toContain("results"); + }); + + it("quiet mode produces no memory content", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + noInfer: false, + enableGraph: false, + output: "quiet", + }); + expect(output).not.toContain("dark mode"); + }); +}); + +describe("cmdSearch", () => { + it("searches and shows results in text mode", async () => { + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, + enableGraph: false, + output: "text", + }); + expect(output).toContain("Found 2"); + }); + + it("outputs json format", async () => { + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, + enableGraph: false, + output: "json", + }); + expect(output).toContain("memory"); + }); + + it("shows no results message", async () => { + (mockBackend.search as ReturnType).mockResolvedValue([]); + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "nonexistent", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, + enableGraph: false, + output: "text", + }); + expect(output).toContain("No memories found"); + }); +}); + +describe("cmdGet", () => { + it("gets memory in text mode", async () => { + const { cmdGet } = await import("../src/commands/memory.js"); + await cmdGet(mockBackend, "abc-123-def-456", { output: "text" }); + expect(output).toContain("dark mode"); + }); + + it("gets memory in json mode", async () => { + const { cmdGet } = await import("../src/commands/memory.js"); + await cmdGet(mockBackend, "abc-123-def-456", { output: "json" }); + expect(output).toContain("memory"); + }); +}); + +describe("cmdList", () => { + it("lists in table mode", async () => { + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, + enableGraph: false, + output: "table", + }); + expect(output).toContain("dark mode"); + }); + + it("shows empty message", async () => { + (mockBackend.listMemories as ReturnType).mockResolvedValue([]); + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, + enableGraph: false, + output: "text", + }); + expect(output).toContain("No memories found"); + }); +}); + +describe("cmdUpdate", () => { + it("updates memory", async () => { + const { cmdUpdate } = await import("../src/commands/memory.js"); + await cmdUpdate(mockBackend, "abc-123", "New text", { output: "text" }); + expect(output.toLowerCase()).toContain("updated"); + }); +}); + +describe("cmdDelete", () => { + it("deletes memory", async () => { + const { cmdDelete } = await import("../src/commands/memory.js"); + await cmdDelete(mockBackend, "abc-123", { output: "text" }); + expect(output.toLowerCase()).toContain("deleted"); + }); +}); + +describe("cmdDeleteAll", () => { + it("deletes all with force", async () => { + const { cmdDeleteAll } = await import("../src/commands/memory.js"); + await cmdDeleteAll(mockBackend, { + force: true, + userId: "alice", + output: "text", + }); + expect(output.toLowerCase()).toContain("deleted"); + }); +}); + +describe("cmdVersion", () => { + it("shows version", async () => { + const { cmdVersion } = await import("../src/commands/utils.js"); + cmdVersion(); + expect(output).toContain("0.1.0"); + }); +}); + +describe("cmdEntitiesList", () => { + it("lists users in table mode", async () => { + const { cmdEntitiesList } = await import("../src/commands/entities.js"); + await cmdEntitiesList(mockBackend, "users", { output: "table" }); + expect(output).toContain("alice"); + }); + + it("lists in json mode", async () => { + const { cmdEntitiesList } = await import("../src/commands/entities.js"); + await cmdEntitiesList(mockBackend, "users", { output: "json" }); + expect(output).toContain("alice"); + }); +}); diff --git a/cli/node/tests/config.test.ts b/cli/node/tests/config.test.ts new file mode 100644 index 000000000..9c7fdf8a7 --- /dev/null +++ b/cli/node/tests/config.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for configuration management. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + createDefaultConfig, + loadConfig, + saveConfig, + redactKey, + getNestedValue, + setNestedValue, + CONFIG_DIR, + CONFIG_FILE, +} from "../src/config.js"; + +// Use a temp directory for config during tests +let origConfigDir: string; +let origConfigFile: string; +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-")); + // Monkey-patch the module-level constants + // We'll use env vars and direct file manipulation instead + // Clear MEM0_ env vars + for (const key of Object.keys(process.env)) { + if (key.startsWith("MEM0_")) { + delete process.env[key]; + } + } +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("redactKey", () => { + it("returns '(not set)' for empty key", () => { + expect(redactKey("")).toBe("(not set)"); + }); + + it("redacts short key", () => { + expect(redactKey("abc")).toBe("ab***"); + }); + + it("redacts normal key", () => { + const result = redactKey("m0-abcdefgh12345678"); + expect(result).toBe("m0-a...5678"); + expect(result).not.toContain("abcdefgh"); + }); + + it("redacts exactly 8-char key as short", () => { + expect(redactKey("12345678")).toBe("12***"); + }); +}); + +describe("createDefaultConfig", () => { + it("has correct defaults", () => { + const config = createDefaultConfig(); + expect(config.platform.baseUrl).toBe("https://api.mem0.ai"); + expect(config.platform.apiKey).toBe(""); + expect(config.defaults.userId).toBe(""); + expect(config.defaults.enableGraph).toBe(false); + }); +}); + +describe("getNestedValue", () => { + it("gets platform.api_key", () => { + const config = createDefaultConfig(); + config.platform.apiKey = "test-key"; + expect(getNestedValue(config, "platform.api_key")).toBe("test-key"); + }); + + it("returns undefined for nonexistent key", () => { + const config = createDefaultConfig(); + expect(getNestedValue(config, "nonexistent.key")).toBeUndefined(); + }); + + it("gets defaults.user_id", () => { + const config = createDefaultConfig(); + config.defaults.userId = "alice"; + expect(getNestedValue(config, "defaults.user_id")).toBe("alice"); + }); +}); + +describe("setNestedValue", () => { + it("sets platform.api_key", () => { + const config = createDefaultConfig(); + expect(setNestedValue(config, "platform.api_key", "new-key")).toBe(true); + expect(config.platform.apiKey).toBe("new-key"); + }); + + it("returns false for nonexistent key", () => { + const config = createDefaultConfig(); + expect(setNestedValue(config, "nonexistent.key", "val")).toBe(false); + }); + + it("sets defaults.user_id", () => { + const config = createDefaultConfig(); + expect(setNestedValue(config, "defaults.user_id", "bob")).toBe(true); + expect(config.defaults.userId).toBe("bob"); + }); + + it("coerces boolean for enable_graph", () => { + const config = createDefaultConfig(); + expect(setNestedValue(config, "defaults.enable_graph", "true")).toBe(true); + expect(config.defaults.enableGraph).toBe(true); + }); +}); diff --git a/cli/node/tests/output.test.ts b/cli/node/tests/output.test.ts new file mode 100644 index 000000000..71f43f247 --- /dev/null +++ b/cli/node/tests/output.test.ts @@ -0,0 +1,115 @@ +/** + * Tests for output formatting. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + formatMemoriesText, + formatMemoriesTable, + formatJson, + formatSingleMemory, + formatAddResult, + printResultSummary, +} from "../src/output.js"; + +let output: string; +const originalLog = console.log; + +beforeEach(() => { + output = ""; + console.log = (...args: unknown[]) => { + output += args.map(String).join(" ") + "\n"; + }; +}); + +afterEach(() => { + console.log = originalLog; +}); + +const sampleMemories = [ + { + 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"], + }, +]; + +describe("formatMemoriesText", () => { + it("shows count and memory content", () => { + formatMemoriesText(sampleMemories); + expect(output).toContain("Found 2"); + expect(output).toContain("dark mode"); + expect(output).toContain("vim keybindings"); + }); + + it("shows scores and IDs", () => { + formatMemoriesText(sampleMemories); + expect(output).toContain("0.92"); + expect(output).toContain("abc-123-"); + }); +}); + +describe("formatMemoriesTable", () => { + it("renders a table with memory content", () => { + formatMemoriesTable(sampleMemories); + expect(output).toContain("dark mode"); + }); +}); + +describe("formatJson", () => { + it("outputs valid JSON", () => { + formatJson({ key: "value" }); + expect(JSON.parse(output)).toEqual({ key: "value" }); + }); +}); + +describe("formatSingleMemory", () => { + it("shows memory text in text mode", () => { + formatSingleMemory(sampleMemories[0], "text"); + expect(output).toContain("dark mode"); + }); + + it("outputs JSON in json mode", () => { + formatSingleMemory(sampleMemories[0], "json"); + expect(output).toContain("memory"); + }); +}); + +describe("formatAddResult", () => { + it("shows ADD event", () => { + formatAddResult({ + results: [{ id: "abc-123", memory: "Test", event: "ADD" }], + }); + expect(output).toContain("Added"); + }); + + it("shows PENDING event", () => { + formatAddResult({ + results: [{ status: "PENDING", event_id: "evt-12345678" }], + }); + expect(output).toContain("Queued"); + }); +}); + +describe("printResultSummary", () => { + it("shows count and duration", () => { + printResultSummary({ count: 5, durationSecs: 1.23 }); + expect(output).toContain("5 results"); + expect(output).toContain("1.23s"); + }); + + it("handles singular", () => { + printResultSummary({ count: 1 }); + expect(output).toContain("1 result"); + expect(output).not.toContain("results"); + }); +}); diff --git a/cli/node/tests/setup.ts b/cli/node/tests/setup.ts new file mode 100644 index 000000000..7f7c19d7a --- /dev/null +++ b/cli/node/tests/setup.ts @@ -0,0 +1,75 @@ +/** + * Shared test helpers and mock factories for mem0 CLI tests. + */ + +import { vi } from "vitest"; +import type { Backend } from "../src/backend/base.js"; + +/** Create a mock backend with all methods stubbed with sensible defaults. */ +export function createMockBackend(): Backend { + return { + add: vi.fn().mockResolvedValue({ + results: [ + { + id: "abc-123-def-456", + memory: "User prefers dark mode", + event: "ADD", + }, + ], + }), + + search: vi.fn().mockResolvedValue([ + { + 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"], + }, + ]), + + get: vi.fn().mockResolvedValue({ + 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"], + }), + + listMemories: vi.fn().mockResolvedValue([ + { + 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"], + }, + ]), + + update: vi.fn().mockResolvedValue({ id: "abc-123-def-456", memory: "Updated memory" }), + delete: vi.fn().mockResolvedValue({ status: "deleted" }), + status: vi.fn().mockResolvedValue({ + connected: true, + backend: "platform", + base_url: "https://api.mem0.ai", + }), + deleteEntities: vi.fn().mockResolvedValue({ message: "Entity deleted" }), + entities: vi.fn().mockResolvedValue([ + { name: "alice", count: 5 }, + { name: "bob", count: 3 }, + ]), + }; +} diff --git a/cli/node/tsconfig.json b/cli/node/tsconfig.json new file mode 100644 index 000000000..5159ee698 --- /dev/null +++ b/cli/node/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/cli/python/Makefile b/cli/python/Makefile new file mode 100644 index 000000000..b60221887 --- /dev/null +++ b/cli/python/Makefile @@ -0,0 +1,30 @@ +.PHONY: install dev lint format test build clean publish publish-test + +install: + pip install -e . + +dev: + pip install -e ".[dev]" + +lint: + ruff check . + ruff format --check . + +format: + ruff check --fix . + ruff format . + +test: + pytest + +build: clean + hatch build + +clean: + rm -rf dist/ + +publish: build + hatch publish + +publish-test: build + hatch publish --repo test diff --git a/cli/python/README.md b/cli/python/README.md new file mode 100644 index 000000000..d4f6e2646 --- /dev/null +++ b/cli/python/README.md @@ -0,0 +1,29 @@ +# mem0 CLI + +The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents. + +## Installation + +```bash +pip install mem0-cli +``` + +## Quick Start + +```bash +# Set up your configuration +mem0 init + +# 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 +mem0 list --user-id alice +``` + +## License + +Apache-2.0 diff --git a/cli/python/development.md b/cli/python/development.md new file mode 100644 index 000000000..b38df6336 --- /dev/null +++ b/cli/python/development.md @@ -0,0 +1,76 @@ +# Development + +## Prerequisites + +- Python **3.10+** + +## Setup + +All commands below should be run from the `python/` directory: + +```bash +cd python +``` + +## Install local (editable) + run + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -U pip + +# Install in editable mode +pip install -e . + +# Run +mem0 --help +mem0 version +``` + +> **After moving to the new directory structure:** If you previously had the CLI installed from the old repo root, you need to re-run `pip install -e .` from inside the `python/` directory to pick up the new location. + +## Run without installing globally + +This still installs the package into your active virtualenv (editable), but you can invoke it via module execution: + +```bash +source .venv/bin/activate +pip install -e . +python -m mem0_cli --help +``` + +## Optional extras + +### OSS integration extras + +```bash +pip install -e ".[oss]" +``` + +### Dev tools (tests/lint) + +```bash +pip install -e ".[dev]" +``` + +## Run tests + +```bash +pip install -e ".[dev]" + +# Run all tests +pytest + +# Run a specific test file +pytest tests/test_cli_integration.py + +# Run a single test +pytest -k test_help +``` + +## Lint + +```bash +ruff check . +ruff format . +``` diff --git a/cli/python/pyproject.toml b/cli/python/pyproject.toml new file mode 100644 index 000000000..1c2b0fd8d --- /dev/null +++ b/cli/python/pyproject.toml @@ -0,0 +1,77 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mem0-cli" +version = "0.1.0" +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 diff --git a/cli/python/src/mem0_cli/__init__.py b/cli/python/src/mem0_cli/__init__.py new file mode 100644 index 000000000..b424ff7f6 --- /dev/null +++ b/cli/python/src/mem0_cli/__init__.py @@ -0,0 +1,3 @@ +"""mem0 CLI — the command-line interface for the mem0 memory layer.""" + +__version__ = "0.1.0" diff --git a/cli/python/src/mem0_cli/__main__.py b/cli/python/src/mem0_cli/__main__.py new file mode 100644 index 000000000..6788512dd --- /dev/null +++ b/cli/python/src/mem0_cli/__main__.py @@ -0,0 +1,5 @@ +"""Allow running with `python -m mem0_cli`.""" + +from mem0_cli.app import main + +main() diff --git a/cli/python/src/mem0_cli/app.py b/cli/python/src/mem0_cli/app.py new file mode 100644 index 000000000..c9b1ec076 --- /dev/null +++ b/cli/python/src/mem0_cli/app.py @@ -0,0 +1,1021 @@ +"""Main CLI application — the entrypoint for `mem0`.""" + +from __future__ import annotations + +import json as _json +import sys +from pathlib import Path + +import typer +from rich.console import Console + +from mem0_cli import __version__ +from mem0_cli.branding import BRAND_COLOR, print_error + +console = Console() +err_console = Console(stderr=True) + +# ── Main app ────────────────────────────────────────────────────────────── + +app = typer.Typer( + name="mem0", + help=f"◆ Mem0 CLI v{__version__} · Python SDK\n\n The Memory Layer for AI Agents", + no_args_is_help=True, + rich_markup_mode="rich", + pretty_exceptions_enable=False, + add_completion=False, + subcommand_metavar=" [options]", + options_metavar="", +) + +# ── Sub-groups (defined here, registered later to control help ordering) ── + +config_app = typer.Typer( + name="config", + help="Manage mem0 configuration.", + no_args_is_help=True, + rich_markup_mode="rich", +) + +entity_app = typer.Typer( + name="entity", + help="Manage entities.", + no_args_is_help=True, + rich_markup_mode="rich", +) +# entity_app registered after Memory commands to control panel ordering + + +# ── Helpers ─────────────────────────────────────────────────────────────── + + +def _get_backend_and_config( + api_key: str | None = None, + base_url: str | None = None, +): + """Build and return the Platform backend plus the loaded config.""" + from mem0_cli.backend import get_backend + from mem0_cli.config import load_config + + config = load_config() + + if api_key: + config.platform.api_key = api_key + if base_url: + config.platform.base_url = base_url + + if not config.platform.api_key: + print_error( + err_console, + "No API key configured.", + hint="Run 'mem0 init' or set MEM0_API_KEY environment variable.", + ) + raise typer.Exit(1) + + return get_backend(config), config + + +def _get_backend( + api_key: str | None = None, + base_url: str | None = None, +): + """Build and return the Platform backend.""" + backend, _config = _get_backend_and_config(api_key, base_url) + return backend + + +def _resolve_ids( + config, + *, + user_id: str | None = None, + agent_id: str | None = None, + app_id: str | None = None, + run_id: str | None = None, +): + """Resolve entity IDs: CLI flag > config default > None. + + If any explicit ID is provided, only use explicit IDs (don't mix + in defaults for other entity types which would over-filter). + If no explicit IDs, fall back to all configured defaults. + """ + has_explicit = any([user_id, agent_id, app_id, run_id]) + if has_explicit: + return { + "user_id": user_id or None, + "agent_id": agent_id or None, + "app_id": app_id or None, + "run_id": run_id or None, + } + return { + "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, + } + + +def _read_stdin() -> str | None: + """Read from stdin if it is piped (not a TTY).""" + if not sys.stdin.isatty(): + return sys.stdin.read().strip() or None + return None + + +# ── Global options (shared via callback) ────────────────────────────────── + + +@app.callback(invoke_without_command=True) +def main_callback( + ctx: typer.Context, + version: bool = typer.Option(False, "--version", help="Show version and exit."), +) -> None: + if version: + from mem0_cli.commands.utils import cmd_version + + cmd_version() + raise typer.Exit() + + +# ── Memory: add ─────────────────────────────────────────────────────────── + + +@app.command(rich_help_panel="Memory") +def add( + text: str | None = typer.Argument(None, help="Text content to add as a memory."), + user_id: str | None = typer.Option( + None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope" + ), + agent_id: str | None = typer.Option( + None, "--agent-id", help="Scope to agent.", rich_help_panel="Scope" + ), + app_id: str | None = typer.Option( + None, "--app-id", help="Scope to app.", rich_help_panel="Scope" + ), + run_id: str | None = typer.Option( + None, "--run-id", help="Scope to run.", rich_help_panel="Scope" + ), + messages: str | None = typer.Option(None, "--messages", help="Conversation messages as JSON."), + file: Path | None = typer.Option(None, "--file", "-f", help="Read messages from JSON file."), + metadata: str | None = typer.Option(None, "--metadata", "-m", help="Custom metadata as JSON."), + immutable: bool = typer.Option(False, "--immutable", help="Prevent future updates."), + no_infer: bool = typer.Option(False, "--no-infer", help="Skip inference, store raw."), + expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."), + categories: str | None = typer.Option( + None, "--categories", help="Categories (JSON array or comma-separated)." + ), + graph: bool = typer.Option(False, "--graph", help="Enable graph memory extraction."), + no_graph: bool = typer.Option(False, "--no-graph", help="Disable graph memory extraction."), + output: str = typer.Option( + "text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """Add a memory from text, messages, file, or stdin. + + Examples: + mem0 add "I prefer dark mode" --user-id alice + echo "text" | mem0 add -u alice + mem0 add --file msgs.json -u alice -o json + """ + from mem0_cli.commands.memory import cmd_add + + backend, config = _get_backend_and_config(api_key, base_url) + ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id) + + if no_graph: + graph_enabled = False + elif graph: + graph_enabled = True + else: + graph_enabled = config.defaults.enable_graph + + cmd_add( + backend, + text, + **ids, + messages=messages, + file=file, + metadata=metadata, + immutable=immutable, + no_infer=no_infer, + expires=expires, + categories=categories, + enable_graph=graph_enabled, + output=output, + ) + + +# ── Memory: search ──────────────────────────────────────────────────────── + + +@app.command(rich_help_panel="Memory") +def search( + query: str | None = typer.Argument(None, help="Search query."), + user_id: str | None = typer.Option( + None, "--user-id", "-u", help="Filter by user.", rich_help_panel="Scope" + ), + agent_id: str | None = typer.Option( + None, "--agent-id", help="Filter by agent.", rich_help_panel="Scope" + ), + app_id: str | None = typer.Option( + None, "--app-id", help="Filter by app.", rich_help_panel="Scope" + ), + run_id: str | None = typer.Option( + None, "--run-id", help="Filter by run.", rich_help_panel="Scope" + ), + top_k: int = typer.Option( + 10, "--top-k", "-k", "--limit", help="Number of results.", rich_help_panel="Search" + ), + threshold: float = typer.Option( + 0.3, "--threshold", help="Minimum similarity score.", rich_help_panel="Search" + ), + rerank: bool = typer.Option( + False, "--rerank", help="Enable reranking (Platform only).", rich_help_panel="Search" + ), + keyword: bool = typer.Option( + False, "--keyword", help="Use keyword search.", rich_help_panel="Search" + ), + filter_json: str | None = typer.Option( + None, "--filter", help="Advanced filter expression (JSON).", rich_help_panel="Search" + ), + fields: str | None = typer.Option( + None, + "--fields", + help="Specific fields to return (comma-separated).", + rich_help_panel="Search", + ), + graph: bool = typer.Option(False, "--graph", help="Enable graph in search.", rich_help_panel="Search"), + no_graph: bool = typer.Option(False, "--no-graph", help="Disable graph in search.", rich_help_panel="Search"), + output: str = typer.Option( + "text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """Search memories by semantic query. + + Examples: + mem0 search "preferences" --user-id alice + mem0 search "tools" -u alice -o json -k 5 + echo "preferences" | mem0 search -u alice + """ + from mem0_cli.commands.memory import cmd_search + + # STEP 7: stdin fallback for query + if query is None: + query = _read_stdin() + if query is None: + print_error(err_console, "No query provided. Pass a query argument or pipe via stdin.") + raise typer.Exit(1) + + backend, config = _get_backend_and_config(api_key, base_url) + ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id) + + if no_graph: + graph_enabled = False + elif graph: + graph_enabled = True + else: + graph_enabled = config.defaults.enable_graph + + cmd_search( + backend, + query, + **ids, + top_k=top_k, + threshold=threshold, + rerank=rerank, + keyword=keyword, + filter_json=filter_json, + fields=fields, + enable_graph=graph_enabled, + output=output, + ) + + +# ── Memory: get ─────────────────────────────────────────────────────────── + + +@app.command(rich_help_panel="Memory") +def get( + memory_id: str = typer.Argument(..., help="Memory ID to retrieve."), + output: str = typer.Option( + "text", "--output", "-o", help="Output: text, json.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """Get a specific memory by ID. + + Examples: + mem0 get abc-123-def-456 + mem0 get abc-123-def-456 -o json + """ + from mem0_cli.commands.memory import cmd_get + + backend = _get_backend(api_key, base_url) + cmd_get(backend, memory_id, output=output) + + +# ── Memory: list ────────────────────────────────────────────────────────── + + +@app.command(name="list", rich_help_panel="Memory") +def list_cmd( + user_id: str | None = typer.Option( + None, "--user-id", "-u", help="Filter by user.", rich_help_panel="Scope" + ), + agent_id: str | None = typer.Option( + None, "--agent-id", help="Filter by agent.", rich_help_panel="Scope" + ), + app_id: str | None = typer.Option( + None, "--app-id", help="Filter by app.", rich_help_panel="Scope" + ), + run_id: str | None = typer.Option( + None, "--run-id", help="Filter by run.", rich_help_panel="Scope" + ), + page: int = typer.Option(1, "--page", help="Page number.", rich_help_panel="Pagination"), + page_size: int = typer.Option( + 100, "--page-size", help="Results per page.", rich_help_panel="Pagination" + ), + category: str | None = typer.Option( + None, "--category", help="Filter by category.", rich_help_panel="Filters" + ), + after: str | None = typer.Option( + None, "--after", help="Created after (YYYY-MM-DD).", rich_help_panel="Filters" + ), + before: str | None = typer.Option( + None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters" + ), + graph: bool = typer.Option(False, "--graph", help="Enable graph in listing.", rich_help_panel="Filters"), + no_graph: bool = typer.Option(False, "--no-graph", help="Disable graph in listing.", rich_help_panel="Filters"), + output: str = typer.Option( + "table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """List memories with optional filters. + + Examples: + mem0 list -u alice + mem0 list --category prefs --after 2024-01-01 -o json + """ + from mem0_cli.commands.memory import cmd_list + + backend, config = _get_backend_and_config(api_key, base_url) + ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id) + + if no_graph: + graph_enabled = False + elif graph: + graph_enabled = True + else: + graph_enabled = config.defaults.enable_graph + + cmd_list( + backend, + **ids, + page=page, + page_size=page_size, + category=category, + after=after, + before=before, + enable_graph=graph_enabled, + output=output, + ) + + +# ── Memory: update ──────────────────────────────────────────────────────── + + +@app.command(rich_help_panel="Memory") +def update( + memory_id: str = typer.Argument(..., help="Memory ID to update."), + text: str | None = typer.Argument(None, help="New memory text."), + metadata: str | None = typer.Option(None, "--metadata", "-m", help="Update metadata (JSON)."), + output: str = typer.Option( + "text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """Update a memory's text or metadata. + + Examples: + mem0 update abc-123-def-456 "new text" + mem0 update abc-123 --metadata '{{"key":"val"}}' + echo "new text" | mem0 update abc-123 + """ + from mem0_cli.commands.memory import cmd_update + + # STEP 7: stdin fallback for text + if text is None: + text = _read_stdin() + + backend = _get_backend(api_key, base_url) + cmd_update(backend, memory_id, text, metadata=metadata, output=output) + + +# ── Memory: delete ──────────────────────────────────────────────────────── + + +@app.command(rich_help_panel="Memory") +def delete( + memory_id: str | None = typer.Argument(None, help="Memory ID to delete (omit when using --all or --entity)."), + all_: bool = typer.Option(False, "--all", help="Delete all memories matching scope filters."), + entity: bool = typer.Option(False, "--entity", help="Delete the entity itself and all its memories (cascade)."), + project: bool = typer.Option(False, "--project", help="With --all: delete ALL memories project-wide."), + dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting."), + force: bool = typer.Option(False, "--force", help="Skip confirmation."), + user_id: str | None = typer.Option( + None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope" + ), + agent_id: str | None = typer.Option( + None, "--agent-id", help="Scope to agent.", rich_help_panel="Scope" + ), + app_id: str | None = typer.Option( + None, "--app-id", help="Scope to app.", rich_help_panel="Scope" + ), + run_id: str | None = typer.Option( + None, "--run-id", help="Scope to run.", rich_help_panel="Scope" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """Delete a memory, all memories, or an entity. + + Examples: + mem0 delete abc-123-def-456 + mem0 delete abc-123 --dry-run + mem0 delete --all -u alice --force + mem0 delete --all --project --force + mem0 delete --entity -u alice --force + """ + # ── Validate mutual exclusion ──────────────────────────────────── + modes = sum([memory_id is not None, all_, entity]) + if modes > 1: + print_error( + err_console, + "Only one of memory ID, --all, or --entity may be used at a time.", + ) + raise typer.Exit(1) + if modes == 0: + print_error( + err_console, + "Provide a memory ID, --all, or --entity.", + hint="Run 'mem0 delete --help' for usage.", + ) + raise typer.Exit(1) + + # ── Dispatch ───────────────────────────────────────────────────── + if memory_id is not None: + from mem0_cli.commands.memory import cmd_delete + + backend = _get_backend(api_key, base_url) + cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output) + + elif all_: + from mem0_cli.commands.memory import cmd_delete_all + + backend, config = _get_backend_and_config(api_key, base_url) + ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id) + cmd_delete_all(backend, force=force, dry_run=dry_run, all_=project, **ids, output=output) + + else: # --entity + from mem0_cli.commands.entities import cmd_entities_delete + + backend = _get_backend(api_key, base_url) + cmd_entities_delete( + backend, + user_id=user_id, + agent_id=agent_id, + app_id=app_id, + run_id=run_id, + force=force, + dry_run=dry_run, + output=output, + ) + + +# ── Config subcommands ──────────────────────────────────────────────────── + + +@config_app.command("show") +def config_show( + output: str = typer.Option( + "text", "--output", "-o", help="Output: text, json.", rich_help_panel="Output" + ), +) -> None: + """Display current configuration (secrets redacted). + + Examples: + mem0 config show + mem0 config show -o json + """ + from mem0_cli.commands.config_cmd import cmd_config_show + + cmd_config_show(output=output) + + +@config_app.command("get") +def config_get( + key: str = typer.Argument(..., help="Config key (e.g. platform.api_key)."), +) -> None: + """Get a configuration value. + + Examples: + mem0 config get platform.api_key + mem0 config get defaults.user_id + """ + from mem0_cli.commands.config_cmd import cmd_config_get + + cmd_config_get(key) + + +@config_app.command("set") +def config_set( + key: str = typer.Argument(..., help="Config key (e.g. platform.api_key)."), + value: str = typer.Argument(..., help="Value to set."), +) -> None: + """Set a configuration value. + + Examples: + mem0 config set defaults.user_id alice + mem0 config set platform.base_url https://custom.api.mem0.ai + """ + from mem0_cli.commands.config_cmd import cmd_config_set + + cmd_config_set(key, value) + + +# ── Entity subcommands ──────────────────────────────────────────────────── + + +@entity_app.command("list") +def entity_list( + entity_type: str = typer.Argument(..., help="Entity type: users, agents, apps, runs."), + output: str = typer.Option( + "table", "--output", "-o", help="Output: table, json.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """List all entities of a given type. + + Examples: + mem0 entity list users + mem0 entity list agents -o json + """ + from mem0_cli.commands.entities import cmd_entities_list + + backend = _get_backend(api_key, base_url) + cmd_entities_list(backend, entity_type, output=output) + + +@entity_app.command("delete") +def entity_delete( + user_id: str | None = typer.Option( + None, "--user-id", "-u", help="User ID.", rich_help_panel="Scope" + ), + agent_id: str | None = typer.Option( + None, "--agent-id", help="Agent ID.", rich_help_panel="Scope" + ), + app_id: str | None = typer.Option( + None, "--app-id", help="App ID.", rich_help_panel="Scope" + ), + run_id: str | None = typer.Option( + None, "--run-id", help="Run ID.", rich_help_panel="Scope" + ), + force: bool = typer.Option(False, "--force", help="Skip confirmation."), + dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting."), + output: str = typer.Option( + "text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """Delete an entity and ALL its memories (cascade). + + Examples: + mem0 entity delete --user-id alice --force + mem0 entity delete -u alice --dry-run + """ + from mem0_cli.commands.entities import cmd_entities_delete + + backend = _get_backend(api_key, base_url) + cmd_entities_delete( + backend, + user_id=user_id, + agent_id=agent_id, + app_id=app_id, + run_id=run_id, + force=force, + dry_run=dry_run, + output=output, + ) + + +# ── Entity subgroup ── +app.add_typer(entity_app, name="entity", rich_help_panel="Management") + + +# ── Management commands ─────────────────────────────────────────────────── + + +@app.command(rich_help_panel="Management") +def init( + api_key: str | None = typer.Option(None, "--api-key", help="API key (skip prompt)."), + user_id: str | None = typer.Option(None, "--user-id", "-u", help="Default user ID (skip prompt)."), +) -> None: + """Interactive setup wizard for mem0 CLI. + + Examples: + mem0 init + mem0 init --api-key m0-xxx --user-id alice + """ + from mem0_cli.commands.init_cmd import run_init + + run_init(api_key=api_key, user_id=user_id) + + +# (entity_app registered at module level, below sub-group definitions) + + +@app.command(rich_help_panel="Management") +def status( + output: str = typer.Option( + "text", "--output", "-o", help="Output: text, json.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """Check connectivity and authentication. + + Examples: + mem0 status + mem0 status -o json + """ + from mem0_cli.commands.utils import cmd_status + + backend, config = _get_backend_and_config(api_key, base_url) + cmd_status( + backend, + user_id=config.defaults.user_id or None, + agent_id=config.defaults.agent_id or None, + output=output, + ) + + + +@app.command("import", rich_help_panel="Management") +def import_cmd( + file_path: str = typer.Argument(..., help="JSON file to import."), + user_id: str | None = typer.Option( + None, "--user-id", "-u", help="Override user ID.", rich_help_panel="Scope" + ), + agent_id: str | None = typer.Option( + None, "--agent-id", help="Override agent ID.", rich_help_panel="Scope" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output: text, json.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """Import memories from a JSON file. + + Examples: + mem0 import data.json --user-id alice + mem0 import data.json -u alice -o json + """ + from mem0_cli.commands.utils import cmd_import + + backend, config = _get_backend_and_config(api_key, base_url) + ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id) + cmd_import(backend, file_path, user_id=ids["user_id"], agent_id=ids["agent_id"], output=output) + + +# ── Help (machine-readable) ────────────────────────────────────────────── + + +def _build_help_json() -> dict: + """Build machine-readable JSON describing all CLI commands.""" + commands = { + "add": { + "description": "Add a memory from text, messages, file, or stdin.", + "usage": "mem0 add [OPTIONS]", + "arguments": { + "text": {"description": "Text content to add as a memory.", "required": False} + }, + "options": { + "--user-id, -u": "Scope to user.", + "--agent-id": "Scope to agent.", + "--app-id": "Scope to app.", + "--run-id": "Scope to run.", + "--messages": "Conversation messages as JSON.", + "--file, -f": "Read messages from JSON file.", + "--metadata, -m": "Custom metadata as JSON.", + "--immutable": "Prevent future updates.", + "--no-infer": "Skip inference, store raw.", + "--expires": "Expiration date (YYYY-MM-DD).", + "--categories": "Categories (JSON array or comma-separated).", + "--graph": "Enable graph memory extraction.", + "--no-graph": "Disable graph memory extraction.", + "--output, -o": "Output format: text, json, quiet.", + }, + }, + "search": { + "description": "Search memories by semantic query.", + "usage": "mem0 search [OPTIONS]", + "arguments": {"query": {"description": "Search query.", "required": False}}, + "options": { + "--user-id, -u": "Filter by user.", + "--agent-id": "Filter by agent.", + "--top-k, -k, --limit": "Number of results (default: 10).", + "--threshold": "Minimum similarity score (default: 0.3).", + "--rerank": "Enable reranking (Platform only).", + "--keyword": "Use keyword search instead of semantic.", + "--filter": "Advanced filter expression (JSON).", + "--fields": "Specific fields to return (comma-separated).", + "--graph": "Enable graph in search.", + "--no-graph": "Disable graph in search.", + "--output, -o": "Output format: text, json, table.", + }, + }, + "get": { + "description": "Get a specific memory by ID.", + "usage": "mem0 get [OPTIONS]", + "arguments": {"memory_id": {"description": "Memory ID to retrieve.", "required": True}}, + "options": {"--output, -o": "Output format: text, json."}, + }, + "list": { + "description": "List memories with optional filters.", + "usage": "mem0 list [OPTIONS]", + "arguments": {}, + "options": { + "--user-id, -u": "Filter by user.", + "--agent-id": "Filter by agent.", + "--page": "Page number (default: 1).", + "--page-size": "Results per page (default: 100).", + "--category": "Filter by category.", + "--after": "Created after (YYYY-MM-DD).", + "--before": "Created before (YYYY-MM-DD).", + "--graph": "Enable graph in listing.", + "--no-graph": "Disable graph in listing.", + "--output, -o": "Output format: text, json, table.", + }, + }, + "update": { + "description": "Update a memory's text or metadata.", + "usage": "mem0 update [text] [OPTIONS]", + "arguments": { + "memory_id": {"description": "Memory ID to update.", "required": True}, + "text": {"description": "New memory text.", "required": False}, + }, + "options": { + "--metadata, -m": "Update metadata (JSON).", + "--output, -o": "Output format: text, json, quiet.", + }, + }, + "delete": { + "description": "Delete a memory, all memories, or an entity.", + "usage": "mem0 delete [memory_id] [OPTIONS]", + "arguments": { + "memory_id": { + "description": "Memory ID to delete (omit when using --all or --entity).", + "required": False, + } + }, + "options": { + "--all": "Delete all memories matching scope filters.", + "--entity": "Delete the entity itself and all its memories (cascade).", + "--project": "With --all: delete ALL memories project-wide.", + "--dry-run": "Show what would be deleted without deleting.", + "--force": "Skip confirmation.", + "--user-id, -u": "Scope to user.", + "--agent-id": "Scope to agent.", + "--app-id": "Scope to app.", + "--run-id": "Scope to run.", + "--output, -o": "Output format: text, json, quiet.", + }, + }, + "import": { + "description": "Import memories from a JSON file.", + "usage": "mem0 import [OPTIONS]", + "arguments": {"file_path": {"description": "JSON file to import.", "required": True}}, + "options": { + "--user-id, -u": "Override user ID.", + "--agent-id": "Override agent ID.", + "--output, -o": "Output format: text, json.", + }, + }, + "config show": { + "description": "Display current configuration (secrets redacted).", + "usage": "mem0 config show", + "options": {"--output, -o": "Output format: text, json."}, + }, + "config get": { + "description": "Get a configuration value.", + "usage": "mem0 config get ", + "arguments": { + "key": {"description": "Config key (e.g. platform.api_key).", "required": True} + }, + }, + "config set": { + "description": "Set a configuration value.", + "usage": "mem0 config set ", + "arguments": { + "key": {"description": "Config key (e.g. platform.api_key).", "required": True}, + "value": {"description": "Value to set.", "required": True}, + }, + }, + "entity": { + "description": "Manage entities.", + "subcommands": { + "list": { + "description": "List all entities of a given type.", + "usage": "mem0 entity list [OPTIONS]", + "arguments": { + "entity_type": { + "description": "Entity type: users, agents, apps, runs.", + "required": True, + } + }, + "options": {"--output, -o": "Output format: table, json."}, + }, + "delete": { + "description": "Delete an entity and ALL its memories (cascade).", + "usage": "mem0 entity delete [OPTIONS]", + "options": { + "--user-id, -u": "User ID.", + "--agent-id": "Agent ID.", + "--app-id": "App ID.", + "--run-id": "Run ID.", + "--force": "Skip confirmation.", + "--dry-run": "Show what would be deleted without deleting.", + "--output, -o": "Output format: text, json, quiet.", + }, + }, + }, + }, + "init": { + "description": "Interactive setup wizard for mem0 CLI.", + "usage": "mem0 init", + "options": { + "--api-key": "API key (skip prompt).", + "--user-id, -u": "Default user ID (skip prompt).", + }, + }, + "status": { + "description": "Check connectivity and authentication.", + "usage": "mem0 status [OPTIONS]", + "options": {"--output, -o": "Output format: text, json."}, + }, + } + return { + "name": "mem0", + "version": __version__, + "description": "The Memory Layer for AI Agents", + "commands": commands, + "global_options": { + "--api-key": "Override API key (env: MEM0_API_KEY).", + "--base-url": "Override API base URL.", + "--help": "Show help for a command.", + "--version": "Show version and exit.", + }, + "help": { + "human": "mem0 --help Get help for a command", + "machine": "mem0 help --json Machine-readable help (for LLM agents)", + }, + } + + +@app.command(rich_help_panel="Management") +def help( + json: bool = typer.Option(False, "--json", help="Output machine-readable JSON for LLM agents."), +) -> None: + """Show help. Use --json for machine-readable output (for LLM agents). + + Examples: + mem0 help + mem0 help --json + """ + if json: + console.print(_json.dumps(_build_help_json(), indent=2)) + else: + console.print( + f"[{BRAND_COLOR}]◆ mem0 CLI[/] v{__version__} — The Memory Layer for AI Agents\n" + ) + console.print("Usage: mem0 [OPTIONS]\n") + console.print("[bold]Commands:[/]") + console.print(" add Add a memory from text, messages, file, or stdin") + console.print(" search Search memories by semantic query") + console.print(" get Get a specific memory by ID") + console.print(" list List memories with optional filters") + console.print(" update Update a memory's text or metadata") + console.print(" delete Delete a memory, all memories, or an entity") + console.print(" import Import memories from a JSON file") + console.print(" config Manage configuration (show, get, set)") + console.print(" entity Manage entities (list, delete)") + console.print(" init Interactive setup wizard") + console.print(" status Check connectivity and authentication") + console.print() + console.print(" mem0 --help Get help for a command") + console.print(" mem0 help --json Machine-readable help (for LLM agents)") + console.print() + + +# Register config subgroup here so it appears after help in Management panel +app.add_typer(config_app, name="config", rich_help_panel="Management") + + +# ── Entrypoint ──────────────────────────────────────────────────────────── + + +def main() -> None: + app() diff --git a/cli/python/src/mem0_cli/backend/__init__.py b/cli/python/src/mem0_cli/backend/__init__.py new file mode 100644 index 000000000..bf732657a --- /dev/null +++ b/cli/python/src/mem0_cli/backend/__init__.py @@ -0,0 +1,5 @@ +"""Backend abstraction layer for mem0 CLI.""" + +from mem0_cli.backend.base import Backend, get_backend + +__all__ = ["Backend", "get_backend"] diff --git a/cli/python/src/mem0_cli/backend/base.py b/cli/python/src/mem0_cli/backend/base.py new file mode 100644 index 000000000..29394f9e5 --- /dev/null +++ b/cli/python/src/mem0_cli/backend/base.py @@ -0,0 +1,113 @@ +"""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, + enable_graph: bool = False, + ) -> 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, + enable_graph: bool = False, + ) -> 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, + enable_graph: bool = False, + ) -> 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]: ... + + + +def get_backend(config: Mem0Config) -> Backend: + """Return the Platform backend.""" + from mem0_cli.backend.platform import PlatformBackend + + return PlatformBackend(config.platform) diff --git a/cli/python/src/mem0_cli/backend/platform.py b/cli/python/src/mem0_cli/backend/platform.py new file mode 100644 index 000000000..737d0483a --- /dev/null +++ b/cli/python/src/mem0_cli/backend/platform.py @@ -0,0 +1,325 @@ +"""Platform (SaaS) backend — communicates with api.mem0.ai.""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from mem0_cli.backend.base import Backend +from mem0_cli.config import PlatformConfig + + +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", + }, + timeout=30.0, + ) + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + 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 {} + return resp.json() + + 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, + enable_graph: bool = False, + ) -> 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 + if enable_graph: + payload["enable_graph"] = True + + return self._request("POST", "/v1/memories/", 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 v2 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, + enable_graph: bool = False, + ) -> 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 + if enable_graph: + payload["enable_graph"] = True + + result = self._request("POST", "/v2/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/{memory_id}/") + + 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, + enable_graph: bool = False, + ) -> list[dict]: + payload: dict[str, Any] = {} + params = {"page": str(page), "page_size": str(page_size)} + + # Build filters for v2 API — 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 + if enable_graph: + payload["enable_graph"] = True + + result = self._request("POST", "/v2/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 + return self._request("PUT", f"/v1/memories/{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] = {} + 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/{memory_id}/") + 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: + params: dict[str, str] = {} + 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 + if not params: + raise ValueError("At least one entity ID is required for delete_entities.") + return self._request("DELETE", "/v1/entities/", params=params) + + def status( + self, + *, + user_id: str | None = None, + agent_id: str | None = None, + ) -> dict[str, Any]: + """Check connectivity by making a lightweight API call.""" + try: + # If entity IDs are available, validate with a minimal memories list + if user_id or agent_id: + payload: dict[str, Any] = {} + params = {"page": "1", "page_size": "1"} + api_filters = self._build_filters(user_id=user_id, agent_id=agent_id) + if api_filters: + payload["filters"] = api_filters + self._request("POST", "/v2/memories/", json=payload, params=params) + else: + # No entity IDs — use entities endpoint to validate API key + self._request("GET", "/v1/entities/") + 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 + + + +class AuthError(Exception): + pass + + +class NotFoundError(Exception): + pass + + +class APIError(Exception): + pass diff --git a/cli/python/src/mem0_cli/branding.py b/cli/python/src/mem0_cli/branding.py new file mode 100644 index 000000000..d6a89d87b --- /dev/null +++ b/cli/python/src/mem0_cli/branding.py @@ -0,0 +1,130 @@ +"""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.""" + 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: + sym = _sym("✓", "[ok]") + console.print(f"[{SUCCESS_COLOR}]{sym}[/] {message}") + + +def print_error(console: Console, message: str, hint: str | None = None) -> None: + sym = _sym("✗", "[error]") + console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {message}") + if hint: + console.print(f" [{DIM_COLOR}]{hint}[/]") + + +def print_warning(console: Console, message: str) -> None: + sym = _sym("⚠", "[warn]") + console.print(f"[{WARNING_COLOR}]{sym}[/] {message}") + + +def print_info(console: Console, message: str) -> None: + 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. + """ + + class _Ctx: + def __init__(self): + self.success_msg = "" + self.error_msg = "" + + ctx = _Ctx() + 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)") + 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.""" + parts = [] + for key, val in ids.items(): + if val: + label = key.replace("_", " ").replace("id", "ID").strip() + parts.append(f"{label}={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__ diff --git a/cli/python/src/mem0_cli/commands/__init__.py b/cli/python/src/mem0_cli/commands/__init__.py new file mode 100644 index 000000000..9937ab920 --- /dev/null +++ b/cli/python/src/mem0_cli/commands/__init__.py @@ -0,0 +1 @@ +"""CLI command modules.""" diff --git a/cli/python/src/mem0_cli/commands/config_cmd.py b/cli/python/src/mem0_cli/commands/config_cmd.py new file mode 100644 index 000000000..fc8653042 --- /dev/null +++ b/cli/python/src/mem0_cli/commands/config_cmd.py @@ -0,0 +1,108 @@ +"""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_json_envelope + + config = load_config() + + if output == "json": + format_json_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, + "enable_graph": config.defaults.enable_graph, + }, + "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( + "defaults.enable_graph", + str(config.defaults.enable_graph).lower(), + ) + 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.""" + config = load_config() + value = get_nested_value(config, key) + + if value is None: + print_error(err_console, f"Unknown config key: {key}") + else: + # Redact secrets + if "api_key" in key or "key" in key.split(".")[-1:]: + console.print(redact_key(str(value))) + else: + console.print(str(value)) + + +def cmd_config_set(key: str, value: str) -> None: + """Set a config value.""" + config = load_config() + if set_nested_value(config, key, value): + save_config(config) + display = redact_key(value) if "key" in key else value + print_success(console, f"{key} = {display}") + else: + print_error(err_console, f"Unknown config key: {key}") diff --git a/cli/python/src/mem0_cli/commands/entities.py b/cli/python/src/mem0_cli/commands/entities.py new file mode 100644 index 000000000..83caf3471 --- /dev/null +++ b/cli/python/src/mem0_cli/commands/entities.py @@ -0,0 +1,133 @@ +"""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_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.""" + 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 == "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).""" + 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) + + if dry_run: + 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) + print_info(console, f"Would delete entity {scope} and all its memories.") + 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) + + confirm = typer.confirm( + f"\n \u26a0 Delete entity {scope} 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 + + if output == "json": + format_json(console, result) + elif output != "quiet": + print_success(console, f"Entity deleted with all memories ({_elapsed:.2f}s)") diff --git a/cli/python/src/mem0_cli/commands/init_cmd.py b/cli/python/src/mem0_cli/commands/init_cmd.py new file mode 100644 index 000000000..4168ccf3a --- /dev/null +++ b/cli/python/src/mem0_cli/commands/init_cmd.py @@ -0,0 +1,195 @@ +"""mem0 init — interactive setup wizard.""" + +from __future__ import annotations + +import sys + +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 Mem0Config, 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) + + +def run_init(*, api_key: str | None = None, user_id: 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. + """ + config = Mem0Config() + + # Fully non-interactive when both flags provided + if api_key and user_id: + config.platform.api_key = api_key + config.defaults.user_id = user_id + _validate_platform(config) + save_config(config) + print_success(console, "Configuration saved to ~/.mem0/config.json") + return + + # Non-TTY without full flags -> error + if not sys.stdin.isatty(): + if not api_key or not user_id: + print_error( + err_console, + "Non-interactive terminal detected and required flags missing.", + hint="Run: mem0 init --api-key --user-id ", + ) + raise typer.Exit(1) + + print_banner(console) + console.print() + print_info(console, "Welcome! Let's set up your mem0 CLI.\n") + + # Use provided flags or prompt + if api_key: + config.platform.api_key = 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[/]") + 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 + + +def _setup_defaults(config: Mem0Config) -> None: + """Collect default entity IDs.""" + console.print() + print_info(console, "Set default entity IDs (press Enter to skip).\n") + + user_id = Prompt.ask( + f" [{BRAND_COLOR}]Default User ID[/] [{DIM_COLOR}](recommended)[/]", + default="mem0-cli", + ) + 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!") + else: + print_error( + err_console, + f"Could not connect: {status.get('error', 'Unknown error')}", + hint="Check your API key and try again.", + ) + except Exception as e: + print_error(err_console, f"Connection test failed: {e}") diff --git a/cli/python/src/mem0_cli/commands/memory.py b/cli/python/src/mem0_cli/commands/memory.py new file mode 100644 index 000000000..6cc24837a --- /dev/null +++ b/cli/python/src/mem0_cli/commands/memory.py @@ -0,0 +1,469 @@ +"""Memory CRUD commands: add, search, get, list, update, delete.""" + +from __future__ import annotations + +import json +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_json, + format_memories_table, + format_memories_text, + format_single_memory, + print_result_summary, +) + +console = Console() +err_console = Console(stderr=True) + + +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, + enable_graph: bool = False, + output: str = "text", +) -> None: + """Add a memory.""" + 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 if no text and stdin is piped + elif not content and not sys.stdin.isatty(): + 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(",")] + + 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, + enable_graph=enable_graph, + ) + except Exception as e: + ts.error_msg = str(e) + print_error(err_console, str(e)) + raise typer.Exit(1) from None + + if output == "quiet": + 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 results + results = result if isinstance(result, list) else result.get("results", [result]) + count = len(results) if results else 0 + 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, + enable_graph: bool = False, + output: str = "text", +) -> None: + """Search memories.""" + 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(",")] + + _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, + enable_graph=enable_graph, + ) + except Exception as e: + print_error(err_console, str(e)) + raise typer.Exit(1) from None + _elapsed = _time.perf_counter() - _start + + if output == "json": + format_json(console, results) + elif output == "table": + if results: + format_memories_table(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() + 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.""" + 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 + + 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, + enable_graph: bool = False, + output: str = "table", +) -> None: + """List memories.""" + _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, + enable_graph=enable_graph, + ) + except Exception as e: + print_error(err_console, str(e)) + raise typer.Exit(1) from None + _elapsed = _time.perf_counter() - _start + + if output == "json": + format_json(console, results) + 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.""" + 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 == "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.""" + 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 == "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.""" + if all_: + # Project-wide wipe using wildcard entity IDs + if dry_run: + print_info(console, "Would delete ALL memories project-wide.") + print_info(console, "No changes made (dry run).") + return + + 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 == "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 + + if 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)") diff --git a/cli/python/src/mem0_cli/commands/utils.py b/cli/python/src/mem0_cli/commands/utils.py new file mode 100644 index 000000000..a80f2bd99 --- /dev/null +++ b/cli/python/src/mem0_cli/commands/utils.py @@ -0,0 +1,142 @@ +"""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, +) +from mem0_cli.config import load_config + +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_json_envelope + + _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 == "json": + format_json_envelope( + console, + command="status", + data={ + "connected": result.get("connected", False), + "backend": result.get("backend", "?"), + "base_url": result.get("base_url", ""), + "latency_ms": int(_elapsed * 1000), + }, + 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']}") + 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_json_envelope + + 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 == "json": + format_json_envelope( + console, + command="import", + data={"added": added, "failed": failed, "duration_s": round(_elapsed, 2)}, + 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.") diff --git a/cli/python/src/mem0_cli/config.py b/cli/python/src/mem0_cli/config.py new file mode 100644 index 000000000..780c30ec1 --- /dev/null +++ b/cli/python/src/mem0_cli/config.py @@ -0,0 +1,176 @@ +"""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 + + +@dataclass +class DefaultsConfig: + user_id: str = "" + agent_id: str = "" + app_id: str = "" + run_id: str = "" + enable_graph: bool = False + + +@dataclass +class Mem0Config: + version: int = CONFIG_VERSION + defaults: DefaultsConfig = field(default_factory=DefaultsConfig) + platform: PlatformConfig = field(default_factory=PlatformConfig) + + +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) + + 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", "") + config.defaults.enable_graph = defaults.get("enable_graph", False) + + # 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 + + env_graph = os.environ.get("MEM0_ENABLE_GRAPH") + if env_graph: + config.defaults.enable_graph = env_graph.lower() in ("true", "1", "yes") + + 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, + "enable_graph": config.defaults.enable_graph, + }, + "platform": { + "api_key": config.platform.api_key, + "base_url": config.platform.base_url, + }, + } + + with open(CONFIG_FILE, "w") as f: + json.dump(data, f, indent=2) + + os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600 + + +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'.""" + 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.""" + 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): + value = int(value) # type: ignore[assignment] + + setattr(obj, final_key, value) + return True diff --git a/cli/python/src/mem0_cli/output.py b/cli/python/src/mem0_cli/output.py new file mode 100644 index 000000000..3d33003d9 --- /dev/null +++ b/cli/python/src/mem0_cli/output.py @@ -0,0 +1,242 @@ +"""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", mem.get("text", "")) + mem_id = mem.get("id", "")[: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]) -> 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=10) + 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", "")[:8] + memory_text = mem.get("memory", mem.get("text", "")) + if len(memory_text) > 60: + memory_text = memory_text[:57] + "..." + categories = mem.get("categories", []) + cat = categories[0] if isinstance(categories, list) and categories else "—" + created = _format_date(mem.get("created_at")) or "—" + 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", mem.get("text", "")) + mem_id = mem.get("id", "") + + 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() + for r in results: + # Detect async PENDING response from Platform API + if r.get("status") == "PENDING": + event_id = r.get("event_id", "")[:8] + icon = f"[{ACCENT_COLOR}]{_sym('⧗', '...')}[/]" + parts = [f" {icon} [{DIM_COLOR}]{'Queued':<10}[/]"] + parts.append("[white]Processing in background[/]") + if event_id: + parts.append(f"[{DIM_COLOR}](event {event_id})[/]") + console.print(" ".join(parts)) + 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 + 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.replace('_', ' ')}={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 diff --git a/cli/python/tests/__init__.py b/cli/python/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cli/python/tests/conftest.py b/cli/python/tests/conftest.py new file mode 100644 index 000000000..7eb36ed3e --- /dev/null +++ b/cli/python/tests/conftest.py @@ -0,0 +1,109 @@ +"""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}, + ] + + 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 diff --git a/cli/python/tests/test_branding.py b/cli/python/tests/test_branding.py new file mode 100644 index 000000000..3a202b636 --- /dev/null +++ b/cli/python/tests/test_branding.py @@ -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 diff --git a/cli/python/tests/test_cli_integration.py b/cli/python/tests/test_cli_integration.py new file mode 100644 index 000000000..79bda00df --- /dev/null +++ b/cli/python/tests/test_cli_integration.py @@ -0,0 +1,238 @@ +"""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 subprocess +import sys + +import pytest + + +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 ``/.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. + """ + 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] + if home_dir: + env["HOME"] = home_dir + if env_override: + env.update(env_override) + return subprocess.run( + [sys.executable, "-m", "mem0_cli", *args], + capture_output=True, + text=True, + env=env, + ) + + +@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_version_flag(self): + result = _run(["--version"]) + assert result.returncode == 0 + assert "0.1.0" 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: --graph, --limit, entities delete.""" + + def test_add_help_has_graph(self): + result = _run(["add", "--help"]) + assert result.returncode == 0 + assert "--graph" in result.stdout + + def test_search_help_has_graph_and_limit(self): + result = _run(["search", "--help"]) + assert result.returncode == 0 + assert "--graph" in result.stdout + assert "--limit" in result.stdout + + def test_list_help_has_graph(self): + result = _run(["list", "--help"]) + assert result.returncode == 0 + assert "--graph" 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 diff --git a/cli/python/tests/test_commands.py b/cli/python/tests/test_commands.py new file mode 100644 index 000000000..02e1544c2 --- /dev/null +++ b/cli/python/tests/test_commands.py @@ -0,0 +1,1018 @@ +"""Tests for CLI commands using mock backend.""" + +from __future__ import annotations + +import json +from io import StringIO +from unittest.mock import patch + +import pytest +from click.exceptions import Exit as ClickExit +from rich.console import Console + +from mem0_cli.commands.config_cmd import ( + cmd_config_get, + cmd_config_set, + cmd_config_show, +) +from mem0_cli.commands.entities import cmd_entities_delete, cmd_entities_list +from mem0_cli.commands.memory import cmd_add, cmd_delete, cmd_delete_all, cmd_get, cmd_list, cmd_search, cmd_update +from mem0_cli.commands.utils import ( + cmd_import, + cmd_status, + cmd_version, +) + + +def _make_console(): + buf = StringIO() + return Console(file=buf, force_terminal=False, no_color=True, width=120), buf + + +def _make_err_console(): + buf = StringIO() + return Console(file=buf, force_terminal=False, no_color=True, width=120), buf + + +class TestAddCommand: + def test_add_text(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "I prefer dark mode", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="text", + ) + mock_backend.add.assert_called_once() + + def test_add_with_messages(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + msgs = json.dumps([{"role": "user", "content": "I love Python"}]) + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + None, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=msgs, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="text", + ) + mock_backend.add.assert_called_once() + + def test_add_with_metadata(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "test memory", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata='{"source": "test"}', + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="text", + ) + call_kwargs = mock_backend.add.call_args + assert "metadata" in str(call_kwargs) + + def test_add_json_output(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="json", + ) + output = buf.getvalue() + assert '"results"' in output or '"memory"' in output + + def test_add_quiet_output(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="quiet", + ) + output = buf.getvalue() + # In quiet mode, no memory content should be printed (spinner may appear) + assert "dark mode" not in output + + def test_add_no_content_exits(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + # Patch stdin.isatty to return True so it doesn't try to read stdin + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + patch("mem0_cli.commands.memory.sys") as mock_sys, + ): + mock_sys.stdin.isatty.return_value = True + with pytest.raises((SystemExit, ClickExit)): + cmd_add( + mock_backend, + None, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="text", + ) + + def test_add_invalid_metadata_json(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + pytest.raises((SystemExit, ClickExit)), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata="not-json", + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="text", + ) + + def test_add_from_file(self, mock_backend, tmp_path): + file_path = tmp_path / "messages.json" + file_path.write_text(json.dumps([{"role": "user", "content": "hello"}])) + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + None, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=file_path, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="text", + ) + mock_backend.add.assert_called_once() + + def test_add_categories_csv(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories="health,prefs", + output="text", + ) + mock_backend.add.assert_called_once() + + +class TestSearchCommand: + def test_search_text(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_search( + mock_backend, + "preferences", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + top_k=10, + threshold=0.3, + rerank=False, + keyword=False, + filter_json=None, + fields=None, + output="text", + ) + output = buf.getvalue() + assert "Found 2" in output + + def test_search_json(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_search( + mock_backend, + "preferences", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + top_k=10, + threshold=0.3, + rerank=False, + keyword=False, + filter_json=None, + fields=None, + output="json", + ) + output = buf.getvalue() + assert '"memory"' in output + + def test_search_table(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_search( + mock_backend, + "preferences", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + top_k=10, + threshold=0.3, + rerank=False, + keyword=False, + filter_json=None, + fields=None, + output="table", + ) + output = buf.getvalue() + assert "dark mode" in output + + def test_search_no_results(self, mock_backend): + mock_backend.search.return_value = [] + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_search( + mock_backend, + "nonexistent", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + top_k=10, + threshold=0.3, + rerank=False, + keyword=False, + filter_json=None, + fields=None, + output="text", + ) + output = buf.getvalue() + assert "No memories found" in output + + def test_search_with_filter(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_search( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + top_k=5, + threshold=0.5, + rerank=False, + keyword=False, + filter_json='{"category": "prefs"}', + fields="memory,score", + output="text", + ) + mock_backend.search.assert_called_once() + + +class TestGetCommand: + def test_get_text(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_get(mock_backend, "abc-123-def-456", output="text") + output = buf.getvalue() + assert "dark mode" in output + assert "abc-123-def-456" in output + + def test_get_json(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_get(mock_backend, "abc-123-def-456", output="json") + output = buf.getvalue() + assert '"memory"' in output + + +class TestListCommand: + def test_list_table(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_list( + mock_backend, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + page=1, + page_size=100, + category=None, + after=None, + before=None, + output="table", + ) + output = buf.getvalue() + assert "dark mode" in output + + def test_list_json(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_list( + mock_backend, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + page=1, + page_size=100, + category=None, + after=None, + before=None, + output="json", + ) + output = buf.getvalue() + assert '"memory"' in output + + def test_list_empty(self, mock_backend): + mock_backend.list_memories.return_value = [] + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_list( + mock_backend, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + page=1, + page_size=100, + category=None, + after=None, + before=None, + output="text", + ) + output = buf.getvalue() + assert "No memories found" in output + + +class TestUpdateCommand: + def test_update(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_update(mock_backend, "abc-123", "New text", metadata=None, output="text") + output = buf.getvalue() + assert "updated" in output.lower() + + def test_update_json(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_update(mock_backend, "abc-123", "New text", metadata=None, output="json") + output = buf.getvalue() + assert '"memory"' in output + + +class TestDeleteCommand: + def test_delete_single(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_delete(mock_backend, "abc-123", output="text") + output = buf.getvalue() + assert "deleted" in output.lower() + + def test_delete_dry_run(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_delete(mock_backend, "abc-123-def-456", dry_run=True, output="text") + output = buf.getvalue() + assert "dry run" in output.lower() + mock_backend.delete.assert_not_called() + + +class TestDeleteAllCommand: + def test_delete_all_force(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_delete_all( + mock_backend, + force=True, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + output="text", + ) + output = buf.getvalue() + assert "deleted" in output.lower() + + def test_delete_all_dry_run(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_delete_all( + mock_backend, + force=True, + dry_run=True, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + output="text", + ) + output = buf.getvalue() + assert "dry run" in output.lower() + mock_backend.delete.assert_not_called() + + def test_delete_all_project_wide(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_delete_all( + mock_backend, + force=True, + all_=True, + user_id=None, + agent_id=None, + app_id=None, + run_id=None, + output="text", + ) + mock_backend.delete.assert_called_once_with( + all=True, + user_id="*", + agent_id="*", + app_id="*", + run_id="*", + ) + + def test_delete_all_project_wide_dry_run(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_delete_all( + mock_backend, + force=True, + all_=True, + dry_run=True, + user_id=None, + agent_id=None, + app_id=None, + run_id=None, + output="text", + ) + output = buf.getvalue() + assert "project-wide" in output.lower() + mock_backend.delete.assert_not_called() + + def test_delete_all_project_wide_async_response(self, mock_backend): + mock_backend.delete.return_value = {"message": "Memories deletion started..."} + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_delete_all( + mock_backend, + force=True, + all_=True, + user_id=None, + agent_id=None, + app_id=None, + run_id=None, + output="text", + ) + output = buf.getvalue() + assert "background" in output.lower() + + +class TestStatusCommand: + def test_status_connected(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.utils.console", console), + patch("mem0_cli.commands.utils.err_console", err_console), + ): + cmd_status(mock_backend) + output = buf.getvalue() + assert "Connected" in output + + def test_status_disconnected(self, mock_backend): + mock_backend.status.return_value = { + "connected": False, + "backend": "platform", + "error": "Connection refused", + } + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.utils.console", console), + patch("mem0_cli.commands.utils.err_console", err_console), + ): + cmd_status(mock_backend) + output = buf.getvalue() + assert "Disconnected" in output + assert "Connection refused" in output + + def test_status_json(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.utils.console", console), + patch("mem0_cli.commands.utils.err_console", err_console), + ): + cmd_status(mock_backend, output="json") + output = buf.getvalue() + assert '"connected"' in output + assert '"status"' in output + + +class TestVersionCommand: + def test_version(self): + console, buf = _make_console() + with patch("mem0_cli.commands.utils.console", console): + cmd_version() + output = buf.getvalue() + assert "0.1.0" in output + + +class TestImportCommand: + def test_import_json(self, mock_backend, tmp_path): + file_path = tmp_path / "import.json" + data = [ + {"memory": "Test memory 1"}, + {"memory": "Test memory 2"}, + ] + file_path.write_text(json.dumps(data)) + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.utils.console", console), + patch("mem0_cli.commands.utils.err_console", err_console), + ): + cmd_import(mock_backend, str(file_path), user_id="alice", agent_id=None) + assert mock_backend.add.call_count == 2 + + def test_import_invalid_file(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.utils.console", console), + patch("mem0_cli.commands.utils.err_console", err_console), + pytest.raises((SystemExit, ClickExit)), + ): + cmd_import(mock_backend, "/nonexistent/file.json", user_id=None, agent_id=None) + + def test_import_json_output(self, mock_backend, tmp_path): + file_path = tmp_path / "import.json" + data = [{"memory": "Test memory 1"}] + file_path.write_text(json.dumps(data)) + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.utils.console", console), + patch("mem0_cli.commands.utils.err_console", err_console), + ): + cmd_import(mock_backend, str(file_path), user_id="alice", agent_id=None, output="json") + output = buf.getvalue() + assert '"added"' in output + + +class TestEntitiesListCommand: + def test_list_users(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.entities.console", console), + patch("mem0_cli.commands.entities.err_console", err_console), + ): + cmd_entities_list(mock_backend, "users", output="table") + output = buf.getvalue() + assert "alice" in output + + def test_list_invalid_type(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.entities.console", console), + patch("mem0_cli.commands.entities.err_console", err_console), + pytest.raises((SystemExit, ClickExit)), + ): + cmd_entities_list(mock_backend, "invalid", output="table") + + def test_list_json(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.entities.console", console), + patch("mem0_cli.commands.entities.err_console", err_console), + ): + cmd_entities_list(mock_backend, "users", output="json") + output = buf.getvalue() + assert '"alice"' in output + + +class TestConfigCommands: + def test_config_show(self, isolate_config): + from mem0_cli.config import Mem0Config, save_config + + config = Mem0Config() + config.platform.api_key = "m0-test12345678" + save_config(config) + + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.config_cmd.console", console), + patch("mem0_cli.commands.config_cmd.err_console", err_console), + ): + cmd_config_show() + output = buf.getvalue() + assert "Configuration" in output + assert "m0-test12345678" not in output + + def test_config_show_json(self, isolate_config): + from mem0_cli.config import Mem0Config, save_config + + config = Mem0Config() + config.platform.api_key = "m0-test12345678" + config.defaults.user_id = "alice" + save_config(config) + + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.config_cmd.console", console), + patch("mem0_cli.commands.config_cmd.err_console", err_console), + ): + cmd_config_show(output="json") + output = buf.getvalue() + assert '"status"' in output + assert '"config show"' in output + + def test_config_set_and_get(self, isolate_config): + console1, _buf1 = _make_console() + err_console1, _err_buf1 = _make_err_console() + with ( + patch("mem0_cli.commands.config_cmd.console", console1), + patch("mem0_cli.commands.config_cmd.err_console", err_console1), + ): + cmd_config_set("platform.base_url", "https://custom.api.mem0.ai") + + console2, buf2 = _make_console() + err_console2, _err_buf2 = _make_err_console() + with ( + patch("mem0_cli.commands.config_cmd.console", console2), + patch("mem0_cli.commands.config_cmd.err_console", err_console2), + ): + cmd_config_get("platform.base_url") + output = buf2.getvalue() + assert "custom.api.mem0.ai" in output + + def test_config_show_displays_defaults(self, isolate_config): + from mem0_cli.config import Mem0Config, save_config + + config = Mem0Config() + config.defaults.user_id = "alice" + save_config(config) + + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.config_cmd.console", console), + patch("mem0_cli.commands.config_cmd.err_console", err_console), + ): + cmd_config_show() + output = buf.getvalue() + assert "defaults.user_id" in output + assert "alice" in output + + +class TestEntitiesDeleteCommand: + def test_delete_entity_with_force(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.entities.console", console), + patch("mem0_cli.commands.entities.err_console", err_console), + ): + cmd_entities_delete( + mock_backend, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + force=True, + output="text", + ) + mock_backend.delete_entities.assert_called_once_with( + user_id="alice", agent_id=None, app_id=None, run_id=None + ) + output = buf.getvalue() + assert "deleted" in output.lower() + + def test_delete_entity_no_id_exits(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.entities.console", console), + patch("mem0_cli.commands.entities.err_console", err_console), + pytest.raises((SystemExit, ClickExit)), + ): + cmd_entities_delete( + mock_backend, + user_id=None, + agent_id=None, + app_id=None, + run_id=None, + force=True, + output="text", + ) + + def test_delete_entity_json_output(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.entities.console", console), + patch("mem0_cli.commands.entities.err_console", err_console), + ): + cmd_entities_delete( + mock_backend, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + force=True, + output="json", + ) + output = buf.getvalue() + assert '"message"' in output + + def test_delete_entity_dry_run(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.entities.console", console), + patch("mem0_cli.commands.entities.err_console", err_console), + ): + cmd_entities_delete( + mock_backend, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + force=True, + dry_run=True, + output="text", + ) + output = buf.getvalue() + assert "dry run" in output.lower() + mock_backend.delete_entities.assert_not_called() + + +class TestEnableGraph: + def test_add_with_graph(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + enable_graph=True, + output="text", + ) + call_kwargs = mock_backend.add.call_args + assert call_kwargs.kwargs.get("enable_graph") is True + + def test_search_with_graph(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_search( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + top_k=10, + threshold=0.3, + rerank=False, + keyword=False, + filter_json=None, + fields=None, + enable_graph=True, + output="text", + ) + call_kwargs = mock_backend.search.call_args + assert call_kwargs.kwargs.get("enable_graph") is True + + def test_list_with_graph(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_list( + mock_backend, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + page=1, + page_size=100, + category=None, + after=None, + before=None, + enable_graph=True, + output="table", + ) + call_kwargs = mock_backend.list_memories.call_args + assert call_kwargs.kwargs.get("enable_graph") is True diff --git a/cli/python/tests/test_config.py b/cli/python/tests/test_config.py new file mode 100644 index 000000000..1de991ac6 --- /dev/null +++ b/cli/python/tests/test_config.py @@ -0,0 +1,240 @@ +"""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 + 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 == "" + assert config.defaults.enable_graph is False + + def test_enable_graph_save_and_load(self, isolate_config): + config = Mem0Config() + config.defaults.enable_graph = True + save_config(config) + loaded = load_config() + assert loaded.defaults.enable_graph is True + + def test_enable_graph_env_var_true(self, isolate_config, monkeypatch): + monkeypatch.setenv("MEM0_ENABLE_GRAPH", "true") + loaded = load_config() + assert loaded.defaults.enable_graph is True + + def test_enable_graph_env_var_false(self, isolate_config, monkeypatch): + config = Mem0Config() + config.defaults.enable_graph = True + save_config(config) + monkeypatch.setenv("MEM0_ENABLE_GRAPH", "false") + loaded = load_config() + assert loaded.defaults.enable_graph is False + + def test_backward_compat_no_enable_graph_key(self, isolate_config): + """Old config files without 'enable_graph' key should default to False.""" + import json + + from mem0_cli.config import CONFIG_FILE, ensure_config_dir + + ensure_config_dir() + data = { + "version": 1, + "defaults": {"user_id": "alice"}, + "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.defaults.enable_graph is False + assert loaded.defaults.user_id == "alice" + + +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_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" + + def test_set_defaults_enable_graph(self): + config = Mem0Config() + assert set_nested_value(config, "defaults.enable_graph", "true") + assert config.defaults.enable_graph is True + + +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 diff --git a/cli/python/tests/test_output.py b/cli/python/tests/test_output.py new file mode 100644 index 000000000..e9efa0f2f --- /dev/null +++ b/cli/python/tests/test_output.py @@ -0,0 +1,136 @@ +"""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, +) + + +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 + + +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 + + +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 + + +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_empty(self): + console, buf = _make_console() + format_add_result(console, {"results": []}, "text") + output = buf.getvalue() + assert "No memories extracted" in output diff --git a/docs/docs.json b/docs/docs.json index 1f5ce47c2..4d753d559 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -42,6 +42,7 @@ "platform/overview", "vibecoding", "platform/mem0-mcp", + "platform/cli", "platform/platform-vs-oss", "platform/quickstart" ] diff --git a/docs/introduction.mdx b/docs/introduction.mdx index d92192a00..fd1c90b8d 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -172,5 +172,19 @@ mode: "custom"

+ + +
+

+ CLI +

+

+ Manage memories directly from your terminal. Built for developers and AI agents. +

+
+
diff --git a/docs/platform/cli.mdx b/docs/platform/cli.mdx new file mode 100644 index 000000000..93c3aad77 --- /dev/null +++ b/docs/platform/cli.mdx @@ -0,0 +1,303 @@ +--- +title: CLI +description: "Manage memories from your terminal. Available for Python and Node.js." +icon: "terminal" +iconType: "solid" +--- + +The mem0 CLI lets you add, search, list, update, and delete memories directly from the terminal. It works with the Mem0 Platform API and is available as both a Python package and an npm package. + +Both implementations provide identical behavior — same commands, same options, same output formats. + +## Installation + + +```bash pip +pip install mem0-cli +``` + +```bash npm +npm install -g @mem0/cli +``` + + +## Authentication + +Run the interactive setup wizard to configure your API key: + +```bash +mem0 init +``` + +This prompts for your API key and a default user ID, validates the connection, and saves the configuration locally. + +For CI/CD or non-interactive environments, pass both values as flags: + +```bash +mem0 init --api-key m0-xxx --user-id alice +``` + +You can also set your API key via environment variable: + +```bash +export MEM0_API_KEY="m0-xxx" +``` + +## Quick start + +```bash +# 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 + +# Update a memory +mem0 update "I prefer light mode now" + +# Delete a memory +mem0 delete +``` + +## 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 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 "Updated preference text" +mem0 update --metadata '{"priority": "high"}' +echo "new text" | mem0 update +``` + +### `mem0 delete` + +Delete a single memory, all memories for a scope, or an entire entity. + +```bash +# Delete a single memory +mem0 delete + +# 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 entities` + +List or delete entities (users, agents, apps). + +```bash +mem0 entities list +mem0 entities list --type agent --output json +mem0 entities delete --user-id alice --force +``` + +### `mem0 status` + +Verify your API connection and display the current project. + +```bash +mem0 status +``` + +### `mem0 version` + +Print the CLI version. + +```bash +mem0 version +``` + +## Output formats + +All commands support the `--output` flag to control how results are displayed: + +| Format | Description | +|--------|-------------| +| `text` | Human-readable output with colors and formatting (default for most commands) | +| `json` | Structured JSON, suitable for piping to `jq` or consumption by AI agents | +| `table` | Tabular format (default for `list`) | +| `quiet` | Minimal output — just IDs or status codes | + +Example with JSON output: + +```bash +mem0 search "user preferences" --user-id alice --output json | jq '.data.results[].memory' +``` + +## Use with AI agents + +The CLI is designed to be used by AI agents and automation tools. Two features make this straightforward: + +- **`--output json`** returns structured data that agents can parse directly +- **`mem0 help --json`** returns the full command tree as JSON, so agents can discover available commands programmatically + +```bash +# Agent adds a memory +mem0 add "User prefers concise responses" --user-id user-42 --output quiet + +# Agent searches memories and parses results +mem0 search "response preferences" --user-id user-42 --output json +``` + +For non-interactive environments (CI, agent runtimes), use `mem0 init --api-key --user-id` or set the `MEM0_API_KEY` environment variable to skip interactive prompts. + +## 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. + +## Global flags + +These flags are available on all commands that interact with the API: + +| Flag | Description | +|------|-------------| +| `--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 | + +## What's next + + + +Store your first memory in under five minutes using the SDK or CLI + + + +Learn about add, search, update, and delete operations in depth + + + +See the complete REST API documentation + + diff --git a/docs/platform/quickstart.mdx b/docs/platform/quickstart.mdx index 5adeebf71..bab3d932c 100644 --- a/docs/platform/quickstart.mdx +++ b/docs/platform/quickstart.mdx @@ -46,6 +46,10 @@ const client = new MemoryClient({ apiKey: 'your-api-key' }); export MEM0_API_KEY="your-api-key" ``` +```bash CLI +mem0 init --api-key "your-api-key" +``` + @@ -80,6 +84,10 @@ curl -X POST https://api.mem0.ai/v1/memories/add \ }' ``` +```bash CLI +mem0 add "I'm a vegetarian and allergic to nuts." --user-id user123 +``` + @@ -105,6 +113,10 @@ curl -X POST https://api.mem0.ai/v1/memories/search \ }' ``` +```bash CLI +mem0 search "What are my dietary restrictions?" --user-id user123 +``` + **Output:**