feat(cli): add email verification code login to mem0 init (#4623)

Co-authored-by: Saket Aryan <saketaryan2002@gmail.com>
This commit is contained in:
Kabir Kohli
2026-03-31 16:57:02 +05:30
committed by GitHub
co-authored by Saket Aryan
parent dbac83218f
commit af19495f66
7 changed files with 389 additions and 14 deletions
+13
View File
@@ -153,8 +153,19 @@ Interactive setup wizard for mem0 CLI.
|-----------------|--------|----------|---------|------| |-----------------|--------|----------|---------|------|
| `--api-key` | string | No | - | API key (skip prompt). | | `--api-key` | string | No | - | API key (skip prompt). |
| `-u, --user-id` | string | No | - | Default user ID (skip prompt). | | `-u, --user-id` | string | No | - | Default user ID (skip prompt). |
| `--email` | string | No | - | Login via email verification code. |
| `--code` | string | No | - | Verification code (use with --email for non-interactive login). |
**Behavior:** **Behavior:**
*Email login flow (when `--email` is provided):*
- Sends a 6-digit verification code to the email via `POST /api/v1/auth/email_code/`.
- If `--code` is also provided, verifies immediately (fully non-interactive).
- If `--code` is not provided, prompts for the code interactively.
- On success: receives API key, org_id, project_id. Saves to config. Creates account if email is new.
- Cannot be combined with `--api-key`.
*API key flow (existing behavior):*
- If both `--api-key` and `--user-id` are provided, runs non-interactively (no prompts). - 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. - 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. - Interactive mode: prints banner, prompts for API key (masked with `*`), prompts for default user ID (default: `mem0-cli`), validates connection, saves config.
@@ -164,6 +175,8 @@ Interactive setup wizard for mem0 CLI.
```bash ```bash
mem0 init mem0 init
mem0 init --api-key m0-xxx --user-id alice mem0 init --api-key m0-xxx --user-id alice
mem0 init --email alice@company.com
mem0 init --email alice@company.com --code 482901
``` ```
--- ---
+8 -2
View File
@@ -17,9 +17,15 @@ Both packages install a `mem0` binary with identical behavior.
## Quick start ## Quick start
```bash ```bash
# Authenticate and save config # Interactive setup wizard
mem0 init mem0 init
# Or login via email (get a new API key)
mem0 init --email alice@company.com
# Or authenticate with an existing API key
mem0 init --api-key m0-xxx
# Add a memory # Add a memory
mem0 add "I prefer dark mode and use vim keybindings" --user-id alice mem0 add "I prefer dark mode and use vim keybindings" --user-id alice
@@ -40,7 +46,7 @@ mem0 delete <memory-id>
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `mem0 init` | Interactive setup wizard — configures API key and default user ID | | `mem0 init` | Setup wizard — login via email or configure API key manually |
| `mem0 add` | Add a memory from text, JSON messages, a file, or stdin | | `mem0 add` | Add a memory from text, JSON messages, a file, or stdin |
| `mem0 search` | Search memories using natural language | | `mem0 search` | Search memories using natural language |
| `mem0 list` | List memories with optional filters and pagination | | `mem0 list` | List memories with optional filters and pagination |
+8 -3
View File
@@ -503,15 +503,20 @@
}, },
{ {
"name": "init", "name": "init",
"description": "Interactive setup wizard for mem0 CLI.", "description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).",
"usage": "mem0 init", "usage": "mem0 init [OPTIONS]",
"needsBackend": false, "needsBackend": false,
"needsConfig": false, "needsConfig": false,
"resolveIds": false, "resolveIds": false,
"resolveGraph": false, "resolveGraph": false,
"confirmDangerous": false, "confirmDangerous": false,
"arguments": [], "arguments": [],
"options": [] "options": [
{ "name": "api-key", "flags": ["--api-key"], "type": "string", "default": null, "help": "API key (skip prompt)." },
{ "name": "user-id", "flags": ["-u", "--user-id"], "type": "string", "default": null, "help": "Default user ID (skip prompt)." },
{ "name": "email", "flags": ["--email"], "type": "string", "default": null, "help": "Login via email verification code." },
{ "name": "code", "flags": ["--code"], "type": "string", "default": null, "help": "Verification code (use with --email for non-interactive login)." }
]
}, },
{ {
"name": "status", "name": "status",
+177 -2
View File
@@ -10,11 +10,94 @@ import {
printSuccess, printSuccess,
colors, colors,
} from "../branding.js"; } from "../branding.js";
import { type Mem0Config, createDefaultConfig, saveConfig } from "../config.js"; import { type Mem0Config, createDefaultConfig, loadConfig, saveConfig, DEFAULT_BASE_URL } from "../config.js";
import { PlatformBackend } from "../backend/platform.js"; import { PlatformBackend } from "../backend/platform.js";
const { brand, dim } = colors; const { brand, dim } = colors;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function validateEmail(email: string): void {
if (!EMAIL_RE.test(email)) {
printError(`Invalid email address: ${JSON.stringify(email)}`);
process.exit(1);
}
}
async function emailLogin(
email: string,
code: string | undefined,
baseUrl: string,
): Promise<Record<string, unknown>> {
const url = baseUrl.replace(/\/+$/, "");
if (!code) {
const resp = await fetch(`${url}/api/v1/auth/email_code/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(30_000),
});
if (resp.status === 429) {
printError("Too many attempts. Try again in a few minutes.");
process.exit(1);
}
if (!resp.ok) {
let detail: string;
try {
const body = (await resp.json()) as Record<string, unknown>;
detail = (body["error"] ?? body["detail"] ?? resp.statusText) as string;
} catch {
detail = resp.statusText;
}
printError(`Failed to send code: ${detail}`);
process.exit(1);
}
printSuccess("Verification code sent! Check your email.");
if (!process.stdin.isTTY) {
printError(
"No --code provided and terminal is non-interactive.",
"Run: mem0 init --email <email> --code <code>",
);
process.exit(1);
}
console.log();
const entered = await promptLine(` ${brand("Verification Code")}`);
if (!entered) {
printError("Code is required.");
process.exit(1);
}
code = entered;
}
const verifyResp = await fetch(`${url}/api/v1/auth/email_code/verify/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code: code.trim() }),
signal: AbortSignal.timeout(30_000),
});
if (verifyResp.status === 429) {
printError("Too many attempts. Try again in a few minutes.");
process.exit(1);
}
if (!verifyResp.ok) {
let detail: string;
try {
const body = (await verifyResp.json()) as Record<string, unknown>;
detail = (body["error"] ?? body["detail"] ?? verifyResp.statusText) as string;
} catch {
detail = verifyResp.statusText;
}
printError(`Verification failed: ${detail}`);
process.exit(1);
}
return verifyResp.json() as Promise<Record<string, unknown>>;
}
function promptSecret(label: string): Promise<string> { function promptSecret(label: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
process.stdout.write(label); process.stdout.write(label);
@@ -124,8 +207,57 @@ async function validatePlatform(config: Mem0Config): Promise<void> {
} }
} }
export async function runInit(opts: { apiKey?: string; userId?: string } = {}): Promise<void> { export async function runInit(
opts: { apiKey?: string; userId?: string; email?: string; code?: string } = {},
): Promise<void> {
const config = createDefaultConfig(); const config = createDefaultConfig();
const savedConfig = loadConfig();
const baseUrl =
process.env["MEM0_BASE_URL"] || savedConfig.platform.baseUrl || DEFAULT_BASE_URL;
// Guards
if (opts.code && !opts.email) {
printError("--code requires --email.");
process.exit(1);
}
if (opts.email && opts.apiKey) {
printError("Cannot use both --api-key and --email.");
process.exit(1);
}
// ── Email login flow ──────────────────────────────────────────────────────
if (opts.email) {
const email = opts.email.trim().toLowerCase();
validateEmail(email);
printBanner();
console.log();
printInfo(`Logging in as ${email}...\n`);
const result = await emailLogin(email, opts.code, baseUrl);
const apiKeyVal = result["api_key"] as string | undefined;
if (!apiKeyVal) {
printError("Auth succeeded but no API key was returned. Contact support.");
process.exit(1);
}
config.platform.apiKey = apiKeyVal;
config.platform.baseUrl = baseUrl;
config.defaults.userId = opts.userId || "mem0-cli";
saveConfig(config);
console.log();
printSuccess("Authenticated! Configuration saved to ~/.mem0/config.json");
console.log();
console.log(` ${dim("Get started:")}`);
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
console.log(` ${dim(' mem0 search "preferences"')}`);
console.log();
return;
}
// ── API key flow ──────────────────────────────────────────────────────────
// Non-interactive: both flags provided // Non-interactive: both flags provided
if (opts.apiKey && opts.userId) { if (opts.apiKey && opts.userId) {
@@ -154,6 +286,49 @@ export async function runInit(opts: { apiKey?: string; userId?: string } = {}):
if (opts.apiKey) { if (opts.apiKey) {
config.platform.apiKey = opts.apiKey; config.platform.apiKey = opts.apiKey;
} else { } else {
console.log(` ${brand("How would you like to authenticate?")}`);
console.log(` ${dim("1.")} Login with email ${dim("(recommended)")}`);
console.log(` ${dim("2.")} Enter API key manually`);
console.log();
const choice = await promptLine(` ${brand("Choose")} [1/2]`, "1");
if (choice === "1") {
console.log();
const emailAddr = await promptLine(` ${brand("Email")}`);
if (!emailAddr) {
printError("Email is required.");
process.exit(1);
}
const email = emailAddr.trim().toLowerCase();
validateEmail(email);
printInfo(`Logging in as ${email}...\n`);
const result = await emailLogin(email, undefined, baseUrl);
const apiKeyVal = result["api_key"] as string | undefined;
if (!apiKeyVal) {
printError("Auth succeeded but no API key was returned. Contact support.");
process.exit(1);
}
config.platform.apiKey = apiKeyVal;
config.platform.baseUrl = baseUrl;
config.defaults.userId = opts.userId || "mem0-cli";
saveConfig(config);
console.log();
printSuccess("Authenticated! Configuration saved to ~/.mem0/config.json");
console.log();
console.log(` ${dim("Get started:")}`);
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
console.log(` ${dim(' mem0 search "preferences"')}`);
console.log();
return;
}
// choice === "2": fall through to API key prompt
await setupPlatform(config); await setupPlatform(config);
} }
+8 -3
View File
@@ -110,13 +110,18 @@ program
program program
.command("init") .command("init")
.description("Interactive setup wizard for mem0 CLI.") .description("Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).")
.option("--api-key <key>", "API key (skip prompt).") .option("--api-key <key>", "API key (skip prompt).")
.option("-u, --user-id <id>", "Default user ID (skip prompt).") .option("-u, --user-id <id>", "Default user ID (skip prompt).")
.addHelpText("after", "\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice") .option("--email <email>", "Login via email verification code.")
.option("--code <code>", "Verification code (use with --email for non-interactive login).")
.addHelpText(
"after",
"\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456",
)
.action(async (opts) => { .action(async (opts) => {
const { runInit } = await import("./commands/init.js"); const { runInit } = await import("./commands/init.js");
await runInit({ apiKey: opts.apiKey, userId: opts.userId }); await runInit({ apiKey: opts.apiKey, userId: opts.userId, email: opts.email, code: opts.code });
}); });
// ── Memory: add ─────────────────────────────────────────────────────────── // ── Memory: add ───────────────────────────────────────────────────────────
+5 -1
View File
@@ -695,16 +695,20 @@ app.add_typer(entity_app, name="entity", rich_help_panel="Management")
def init( def init(
api_key: str | None = typer.Option(None, "--api-key", help="API key (skip prompt)."), 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)."), user_id: str | None = typer.Option(None, "--user-id", "-u", help="Default user ID (skip prompt)."),
email: str | None = typer.Option(None, "--email", help="Login via email verification code."),
code: str | None = typer.Option(None, "--code", help="Verification code (use with --email for non-interactive login)."),
) -> None: ) -> None:
"""Interactive setup wizard for mem0 CLI. """Interactive setup wizard for mem0 CLI.
Examples: Examples:
mem0 init mem0 init
mem0 init --api-key m0-xxx --user-id alice mem0 init --api-key m0-xxx --user-id alice
mem0 init --email alice@company.com
mem0 init --email alice@company.com --code 482901
""" """
from mem0_cli.commands.init_cmd import run_init from mem0_cli.commands.init_cmd import run_init
run_init(api_key=api_key, user_id=user_id) run_init(api_key=api_key, user_id=user_id, email=email, code=code)
# (entity_app registered at module level, below sub-group definitions) # (entity_app registered at module level, below sub-group definitions)
+170 -3
View File
@@ -2,8 +2,11 @@
from __future__ import annotations from __future__ import annotations
import os
import re
import sys import sys
import httpx
import typer import typer
from rich.console import Console from rich.console import Console
from rich.prompt import Prompt from rich.prompt import Prompt
@@ -16,7 +19,7 @@ from mem0_cli.branding import (
print_info, print_info,
print_success, print_success,
) )
from mem0_cli.config import Mem0Config, save_config from mem0_cli.config import DEFAULT_BASE_URL, Mem0Config, save_config
console = Console() console = Console()
err_console = Console(stderr=True) err_console = Console(stderr=True)
@@ -84,7 +87,89 @@ def _prompt_secret(label: str) -> str:
return "".join(chars) return "".join(chars)
def run_init(*, api_key: str | None = None, user_id: str | None = None) -> None: _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _validate_email(email: str) -> None:
"""Exit with an error if *email* doesn't look like a valid address."""
if not _EMAIL_RE.match(email):
print_error(err_console, f"Invalid email address: {email!r}")
raise typer.Exit(1)
def _email_login(
email: str,
code: str | None,
base_url: str,
) -> dict:
"""Run the email verification code login flow.
Returns the parsed JSON response from the verify endpoint.
The caller expects at minimum an ``api_key`` field.
"""
url = base_url.rstrip("/")
with httpx.Client(timeout=30.0) as client:
# If code is already provided, skip sending — user already has a code
if not code:
# Step 1: Request verification code
resp = client.post(
f"{url}/api/v1/auth/email_code/",
json={"email": email},
)
if resp.status_code == 429:
print_error(err_console, "Too many attempts. Try again in a few minutes.")
raise typer.Exit(1)
if resp.status_code != 200:
try:
detail = resp.json().get("error", resp.text)
except Exception:
detail = resp.text
print_error(err_console, f"Failed to send code: {detail}")
raise typer.Exit(1)
print_success(console, "Verification code sent! Check your email.")
# Step 2: Get code from user
if not sys.stdin.isatty():
print_error(
err_console,
"No --code provided and terminal is non-interactive.",
hint="Run: mem0 init --email <email> --code <code>",
)
raise typer.Exit(1)
console.print()
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
if not code:
print_error(err_console, "Code is required.")
raise typer.Exit(1)
# Step 3: Verify code
resp = client.post(
f"{url}/api/v1/auth/email_code/verify/",
json={"email": email, "code": code.strip()},
)
if resp.status_code == 429:
print_error(err_console, "Too many attempts. Try again in a few minutes.")
raise typer.Exit(1)
if resp.status_code != 200:
try:
detail = resp.json().get("error", resp.text)
except Exception:
detail = resp.text
print_error(err_console, f"Verification failed: {detail}")
raise typer.Exit(1)
return resp.json()
def run_init(
*,
api_key: str | None = None,
user_id: str | None = None,
email: str | None = None,
code: str | None = None,
) -> None:
"""Interactive setup wizard for mem0 CLI. """Interactive setup wizard for mem0 CLI.
When both *api_key* and *user_id* are supplied, all prompts are skipped When both *api_key* and *user_id* are supplied, all prompts are skipped
@@ -93,6 +178,48 @@ def run_init(*, api_key: str | None = None, user_id: str | None = None) -> None:
""" """
config = Mem0Config() config = Mem0Config()
base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL)
if code and not email:
print_error(err_console, "--code requires --email.")
raise typer.Exit(1)
# ── Email login flow ──────────────────────────────────────────────
if email:
if api_key:
print_error(err_console, "Cannot use both --api-key and --email.")
raise typer.Exit(1)
email = email.strip().lower()
_validate_email(email)
print_banner(console)
console.print()
print_info(console, f"Logging in as {email}...\n")
result = _email_login(email, code, base_url)
api_key_val = result.get("api_key")
if not api_key_val:
print_error(err_console, "Auth succeeded but no API key was returned. Contact support.")
raise typer.Exit(1)
config.platform.api_key = api_key_val
config.platform.base_url = base_url
config.defaults.user_id = user_id or "mem0-cli"
save_config(config)
console.print()
print_success(console, "Authenticated! Configuration saved to ~/.mem0/config.json")
console.print()
console.print(f" [{DIM_COLOR}]Get started:[/]")
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
console.print()
return
# ── API key flow (existing) ───────────────────────────────────────
# Fully non-interactive when both flags provided # Fully non-interactive when both flags provided
if api_key and user_id: if api_key and user_id:
config.platform.api_key = api_key config.platform.api_key = api_key
@@ -115,7 +242,47 @@ def run_init(*, api_key: str | None = None, user_id: str | None = None) -> None:
console.print() console.print()
print_info(console, "Welcome! Let's set up your mem0 CLI.\n") print_info(console, "Welcome! Let's set up your mem0 CLI.\n")
# Use provided flags or prompt # If no flags at all, ask user how they want to authenticate
if not api_key:
console.print(f" [{BRAND_COLOR}]How would you like to authenticate?[/]")
console.print(f" [{DIM_COLOR}]1.[/] Login with email [{DIM_COLOR}](recommended)[/]")
console.print(f" [{DIM_COLOR}]2.[/] Enter API key manually")
console.print()
choice = Prompt.ask(f" [{BRAND_COLOR}]Choose[/]", choices=["1", "2"], default="1")
if choice == "1":
console.print()
email_addr = Prompt.ask(f" [{BRAND_COLOR}]Email[/]")
if not email_addr:
print_error(err_console, "Email is required.")
raise typer.Exit(1)
email_addr = email_addr.strip().lower()
_validate_email(email_addr)
print_info(console, f"Logging in as {email_addr}...\n")
result = _email_login(email_addr, None, base_url)
api_key_val = result.get("api_key")
if not api_key_val:
print_error(err_console, "Auth succeeded but no API key was returned. Contact support.")
raise typer.Exit(1)
config.platform.api_key = api_key_val
config.platform.base_url = base_url
config.defaults.user_id = user_id or "mem0-cli"
save_config(config)
console.print()
print_success(console, "Authenticated! Configuration saved to ~/.mem0/config.json")
console.print()
console.print(f" [{DIM_COLOR}]Get started:[/]")
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
console.print()
return
# API key flow
if api_key: if api_key:
config.platform.api_key = api_key config.platform.api_key = api_key
else: else: