diff --git a/cli/CLI_SPECIFICATION.md b/cli/CLI_SPECIFICATION.md index 255c8b0b4..171fd280e 100644 --- a/cli/CLI_SPECIFICATION.md +++ b/cli/CLI_SPECIFICATION.md @@ -153,8 +153,19 @@ Interactive setup wizard for mem0 CLI. |-----------------|--------|----------|---------|------| | `--api-key` | string | No | - | API key (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:** + +*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 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. @@ -164,6 +175,8 @@ Interactive setup wizard for mem0 CLI. ```bash mem0 init mem0 init --api-key m0-xxx --user-id alice +mem0 init --email alice@company.com +mem0 init --email alice@company.com --code 482901 ``` --- diff --git a/cli/README.md b/cli/README.md index 82335dbe1..fbcadd0f2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -17,9 +17,15 @@ Both packages install a `mem0` binary with identical behavior. ## Quick start ```bash -# Authenticate and save config +# Interactive setup wizard 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 mem0 add "I prefer dark mode and use vim keybindings" --user-id alice @@ -40,7 +46,7 @@ mem0 delete | 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 search` | Search memories using natural language | | `mem0 list` | List memories with optional filters and pagination | diff --git a/cli/cli-spec.json b/cli/cli-spec.json index dc97e2f64..4b6594093 100644 --- a/cli/cli-spec.json +++ b/cli/cli-spec.json @@ -503,15 +503,20 @@ }, { "name": "init", - "description": "Interactive setup wizard for mem0 CLI.", - "usage": "mem0 init", + "description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).", + "usage": "mem0 init [OPTIONS]", "needsBackend": false, "needsConfig": false, "resolveIds": false, "resolveGraph": false, "confirmDangerous": false, "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", diff --git a/cli/node/src/commands/init.ts b/cli/node/src/commands/init.ts index 590fff041..1ace32d77 100644 --- a/cli/node/src/commands/init.ts +++ b/cli/node/src/commands/init.ts @@ -10,11 +10,94 @@ import { printSuccess, colors, } 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"; 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> { + 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; + 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 --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; + 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>; +} + function promptSecret(label: string): Promise { return new Promise((resolve, reject) => { process.stdout.write(label); @@ -124,8 +207,57 @@ async function validatePlatform(config: Mem0Config): Promise { } } -export async function runInit(opts: { apiKey?: string; userId?: string } = {}): Promise { +export async function runInit( + opts: { apiKey?: string; userId?: string; email?: string; code?: string } = {}, +): Promise { 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 if (opts.apiKey && opts.userId) { @@ -154,6 +286,49 @@ export async function runInit(opts: { apiKey?: string; userId?: string } = {}): if (opts.apiKey) { config.platform.apiKey = opts.apiKey; } 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); } diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts index ea6103322..d4969f5f6 100644 --- a/cli/node/src/index.ts +++ b/cli/node/src/index.ts @@ -110,13 +110,18 @@ program program .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 ", "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") + .option("--email ", "Login via email verification code.") + .option("--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) => { 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 ─────────────────────────────────────────────────────────── diff --git a/cli/python/src/mem0_cli/app.py b/cli/python/src/mem0_cli/app.py index c9b1ec076..c329118a3 100644 --- a/cli/python/src/mem0_cli/app.py +++ b/cli/python/src/mem0_cli/app.py @@ -695,16 +695,20 @@ app.add_typer(entity_app, name="entity", 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)."), + 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: """Interactive setup wizard for mem0 CLI. Examples: mem0 init 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 - 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) diff --git a/cli/python/src/mem0_cli/commands/init_cmd.py b/cli/python/src/mem0_cli/commands/init_cmd.py index c400f3225..03ec3fa2f 100644 --- a/cli/python/src/mem0_cli/commands/init_cmd.py +++ b/cli/python/src/mem0_cli/commands/init_cmd.py @@ -2,8 +2,11 @@ from __future__ import annotations +import os +import re import sys +import httpx import typer from rich.console import Console from rich.prompt import Prompt @@ -16,7 +19,7 @@ from mem0_cli.branding import ( print_info, print_success, ) -from mem0_cli.config import Mem0Config, save_config +from mem0_cli.config import DEFAULT_BASE_URL, Mem0Config, save_config console = Console() err_console = Console(stderr=True) @@ -84,7 +87,89 @@ def _prompt_secret(label: str) -> str: 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 --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. 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() + 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 if api_key and user_id: 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() 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: config.platform.api_key = api_key else: