ci(cli): add CI pipelines for Node and Python CLI SDKs (#4640)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Saket Aryan
2026-03-31 22:30:31 +05:30
committed by GitHub
parent 9bebcf45f7
commit 32c1ccba5b
30 changed files with 3042 additions and 2457 deletions
+100
View File
@@ -0,0 +1,100 @@
name: CLI Node CI
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'cli/node/**'
- '.github/workflows/cli-node-ci.yml'
pull_request:
paths:
- 'cli/node/**'
- '.github/workflows/cli-node-ci.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: cli/node/pnpm-lock.yaml
- name: Install dependencies
working-directory: cli/node
run: pnpm install --frozen-lockfile
- name: Lint
working-directory: cli/node
run: pnpm run lint
- name: Type check
working-directory: cli/node
run: pnpm run typecheck
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
cache-dependency-path: cli/node/pnpm-lock.yaml
- name: Install dependencies
working-directory: cli/node
run: pnpm install --frozen-lockfile
- name: Run tests
working-directory: cli/node
run: pnpm run test
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: cli/node/pnpm-lock.yaml
- name: Install dependencies
working-directory: cli/node
run: pnpm install --frozen-lockfile
- name: Build
working-directory: cli/node
run: pnpm run build
- name: Verify dist output
run: |
test -f cli/node/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
+79
View File
@@ -0,0 +1,79 @@
name: CLI Python CI
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'cli/python/**'
- '.github/workflows/cli-python-ci.yml'
pull_request:
paths:
- 'cli/python/**'
- '.github/workflows/cli-python-ci.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dev dependencies
working-directory: cli/python
run: pip install -e ".[dev]"
- name: Lint with ruff
working-directory: cli/python
run: ruff check .
- name: Check formatting
working-directory: cli/python
run: ruff format --check .
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dev dependencies
working-directory: cli/python
run: pip install -e ".[dev]"
- name: Run tests
working-directory: cli/python
run: pytest
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Hatch
run: pip install hatch
- name: Build
working-directory: cli/python
run: hatch build --clean
- name: Verify dist output
run: |
ls cli/python/dist/*.whl || (echo "Wheel file missing" && exit 1)
ls cli/python/dist/*.tar.gz || (echo "Source dist missing" && exit 1)
+14 -5
View File
@@ -67,7 +67,10 @@ export interface Backend {
opts?: AddOptions,
): Promise<Record<string, unknown>>;
search(query: string, opts?: SearchOptions): Promise<Record<string, unknown>[]>;
search(
query: string,
opts?: SearchOptions,
): Promise<Record<string, unknown>[]>;
get(memoryId: string): Promise<Record<string, unknown>>;
@@ -79,17 +82,24 @@ export interface Backend {
metadata?: Record<string, unknown>,
): Promise<Record<string, unknown>>;
delete(memoryId?: string, opts?: DeleteOptions): Promise<Record<string, unknown>>;
delete(
memoryId?: string,
opts?: DeleteOptions,
): Promise<Record<string, unknown>>;
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
status(opts?: { userId?: string; agentId?: string }): Promise<Record<string, unknown>>;
status(opts?: { userId?: string; agentId?: string }): Promise<
Record<string, unknown>
>;
entities(entityType: string): Promise<Record<string, unknown>[]>;
}
export class AuthError extends Error {
constructor(message = "Authentication failed. Your API key may be invalid or expired.") {
constructor(
message = "Authentication failed. Your API key may be invalid or expired.",
) {
super(message);
this.name = "AuthError";
}
@@ -112,4 +122,3 @@ export class APIError extends Error {
export function getBackend(config: Mem0Config): Backend {
return new PlatformBackend(config.platform);
}
+8 -1
View File
@@ -3,5 +3,12 @@
*/
export { getBackend } from "./base.js";
export type { Backend, AddOptions, SearchOptions, ListOptions, DeleteOptions, EntityIds } from "./base.js";
export type {
Backend,
AddOptions,
SearchOptions,
ListOptions,
DeleteOptions,
EntityIds,
} from "./base.js";
export { AuthError, NotFoundError, APIError } from "./base.js";
+61 -31
View File
@@ -4,8 +4,8 @@
import type { PlatformConfig } from "../config.js";
import {
type AddOptions,
APIError,
type AddOptions,
AuthError,
type Backend,
type DeleteOptions,
@@ -58,8 +58,10 @@ export class PlatformBackend implements Backend {
if (resp.status === 400) {
let detail: string;
try {
const body = await resp.json() as Record<string, unknown>;
detail = (body.detail ?? body.message ?? JSON.stringify(body)) as string ?? resp.statusText;
const body = (await resp.json()) as Record<string, unknown>;
detail =
((body.detail ?? body.message ?? JSON.stringify(body)) as string) ??
resp.statusText;
} catch {
detail = resp.statusText;
}
@@ -68,9 +70,11 @@ export class PlatformBackend implements Backend {
if (!resp.ok) {
let detail: string = resp.statusText;
try {
const body = await resp.json() as Record<string, unknown>;
const body = (await resp.json()) as Record<string, unknown>;
detail = (body.detail ?? body.message ?? resp.statusText) as string;
} catch { /* ignore */ }
} catch {
/* ignore */
}
throw new Error(`HTTP ${resp.status}: ${detail}`);
}
if (resp.status === 204) {
@@ -103,10 +107,9 @@ export class PlatformBackend implements Backend {
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
>;
return (await this._request("POST", "/v1/memories/", {
json: payload,
})) as Record<string, unknown>;
}
private _buildFilters(opts: {
@@ -117,7 +120,10 @@ export class PlatformBackend implements Backend {
extraFilters?: Record<string, unknown>;
}): Record<string, unknown> | undefined {
// If caller passed a pre-built filter structure, use it directly
if (opts.extraFilters && ("AND" in opts.extraFilters || "OR" in opts.extraFilters)) {
if (
opts.extraFilters &&
("AND" in opts.extraFilters || "OR" in opts.extraFilters)
) {
return opts.extraFilters;
}
@@ -138,7 +144,10 @@ export class PlatformBackend implements Backend {
return undefined;
}
async search(query: string, opts: SearchOptions = {}): Promise<Record<string, unknown>[]> {
async search(
query: string,
opts: SearchOptions = {},
): Promise<Record<string, unknown>[]> {
const payload: Record<string, unknown> = {
query,
top_k: opts.topK ?? 10,
@@ -167,10 +176,15 @@ export class PlatformBackend implements Backend {
}
async get(memoryId: string): Promise<Record<string, unknown>> {
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<string, unknown>;
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<
string,
unknown
>;
}
async listMemories(opts: ListOptions = {}): Promise<Record<string, unknown>[]> {
async listMemories(
opts: ListOptions = {},
): Promise<Record<string, unknown>[]> {
const payload: Record<string, unknown> = {};
const params: Record<string, string> = {
page: String(opts.page ?? 1),
@@ -182,10 +196,16 @@ export class PlatformBackend implements Backend {
extra.categories = { contains: opts.category };
}
if (opts.after) {
extra.created_at = { ...(extra.created_at as Record<string, unknown> | undefined), gte: opts.after };
extra.created_at = {
...(extra.created_at as Record<string, unknown> | undefined),
gte: opts.after,
};
}
if (opts.before) {
extra.created_at = { ...(extra.created_at as Record<string, unknown> | undefined), lte: opts.before };
extra.created_at = {
...(extra.created_at as Record<string, unknown> | undefined),
lte: opts.before,
};
}
const apiFilters = this._buildFilters({
@@ -198,7 +218,10 @@ export class PlatformBackend implements Backend {
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;
const result = (await this._request("POST", "/v2/memories/", {
json: payload,
params,
})) as unknown;
if (Array.isArray(result)) return result;
const obj = result as Record<string, unknown>;
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
@@ -227,16 +250,15 @@ export class PlatformBackend implements Backend {
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
>;
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
>;
return (await this._request(
"DELETE",
`/v1/memories/${memoryId}/`,
)) as Record<string, unknown>;
}
throw new Error("Either memoryId or --all is required");
}
@@ -250,10 +272,9 @@ export class PlatformBackend implements Backend {
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
>;
return (await this._request("DELETE", "/v1/entities/", {
params,
})) as Record<string, unknown>;
}
async status(
@@ -262,13 +283,19 @@ export class PlatformBackend implements Backend {
try {
if (opts.userId || opts.agentId) {
const payload: Record<string, unknown> = {};
const statusParams: Record<string, string> = { page: "1", page_size: "1" };
const statusParams: Record<string, string> = {
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 });
await this._request("POST", "/v2/memories/", {
json: payload,
params: statusParams,
});
} else {
await this._request("GET", "/v1/entities/");
}
@@ -288,7 +315,10 @@ export class PlatformBackend implements Backend {
if (Array.isArray(result)) {
items = result;
} else {
items = ((result as Record<string, unknown>).results ?? []) as Record<string, unknown>[];
items = ((result as Record<string, unknown>).results ?? []) as Record<
string,
unknown
>[];
}
const typeMap: Record<string, string> = {
+13 -5
View File
@@ -4,6 +4,7 @@
import chalk from "chalk";
import ora, { type Ora } from "ora";
import { CLI_VERSION } from "./version.js";
export const LOGO = `
███╗ ███╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ ██╗
@@ -44,7 +45,7 @@ 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${__CLI_VERSION__}`;
const subtitle = `Node.js SDK · v${CLI_VERSION}`;
const contentLines = ["", ...logoLines, "", tagline, ""];
// Compute inner width from longest content line + padding both sides
@@ -54,7 +55,9 @@ export function printBanner(): void {
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 bottomBorder = brand(
`╰${"─".repeat(subtitleFill)} ${dim(subtitle)} ${"─"}╯`,
);
const body = contentLines.map((line) => {
const rightPad = innerWidth - pad - line.length;
@@ -63,7 +66,8 @@ export function printBanner(): void {
// 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("│")}`;
body[taglineIdx] =
`${brand("│")}${" ".repeat(pad)}${accent(tagline)}${" ".repeat(Math.max(taglineRightPad, 0))}${brand("│")}`;
console.log(topBorder);
for (const line of body) console.log(line);
@@ -75,7 +79,7 @@ export function printSuccess(message: string): void {
}
export function printError(message: string, hint?: string): void {
console.error(`${error(sym("✗", "[error]") + " Error:")} ${message}`);
console.error(`${error(`${sym("✗", "[error]")} Error:`)} ${message}`);
if (hint) {
console.error(` ${dim(hint)}`);
}
@@ -116,7 +120,11 @@ export async function timedStatus<T>(
fn: (ctx: TimedStatusContext) => Promise<T>,
): Promise<T> {
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
const spinner = ora({ text: dim(message), color: "magenta", stream: process.stderr }).start();
const spinner = ora({
text: dim(message),
color: "magenta",
stream: process.stderr,
}).start();
const start = performance.now();
try {
+5 -2
View File
@@ -3,7 +3,7 @@
*/
import Table from "cli-table3";
import { printError, printSuccess, colors } from "../branding.js";
import { colors, printError, printSuccess } from "../branding.js";
import {
getNestedValue,
loadConfig,
@@ -48,7 +48,10 @@ export function cmdConfigShow(opts: { output?: string } = {}): void {
// 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.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)]);
+23 -6
View File
@@ -4,8 +4,14 @@
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 {
colors,
printError,
printInfo,
printSuccess,
timedStatus,
} from "../branding.js";
import { formatJson } from "../output.js";
const { brand, accent, dim } = colors;
@@ -18,7 +24,9 @@ export async function cmdEntitiesList(
opts: { output: string },
): Promise<void> {
if (!VALID_TYPES.has(entityType)) {
printError(`Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`);
printError(
`Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`,
);
process.exit(1);
}
@@ -60,7 +68,9 @@ export async function cmdEntitiesList(
console.log();
console.log(table.toString());
console.log(` ${dim(`${results.length} ${entityType} (${elapsed.toFixed(2)}s)`)}`);
console.log(
` ${dim(`${results.length} ${entityType} (${elapsed.toFixed(2)}s)`)}`,
);
console.log();
}
@@ -77,7 +87,9 @@ export async function cmdEntitiesDelete(
},
): Promise<void> {
if (!opts.userId && !opts.agentId && !opts.appId && !opts.runId) {
printError("Provide at least one of --user-id, --agent-id, --app-id, --run-id.");
printError(
"Provide at least one of --user-id, --agent-id, --app-id, --run-id.",
);
process.exit(1);
}
@@ -87,7 +99,9 @@ export async function cmdEntitiesDelete(
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(
`Would delete entity ${scopeParts.join(", ")} and all its memories.`,
);
printInfo("No changes made.");
return;
}
@@ -100,7 +114,10 @@ export async function cmdEntitiesDelete(
if (opts.runId) scopeParts.push(`run=${opts.runId}`);
const scope = scopeParts.join(", ");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise<string>((resolve) => {
rl.question(
`\n \u26a0 Delete entity ${scope} AND all its memories? This cannot be undone. [y/N] `,
+43 -17
View File
@@ -3,15 +3,21 @@
*/
import readline from "node:readline";
import { PlatformBackend } from "../backend/platform.js";
import {
colors,
printBanner,
printError,
printInfo,
printSuccess,
colors,
} from "../branding.js";
import { type Mem0Config, createDefaultConfig, loadConfig, saveConfig, DEFAULT_BASE_URL } from "../config.js";
import { PlatformBackend } from "../backend/platform.js";
import {
DEFAULT_BASE_URL,
type Mem0Config,
createDefaultConfig,
loadConfig,
saveConfig,
} from "../config.js";
const { brand, dim } = colors;
@@ -30,8 +36,9 @@ async function emailLogin(
baseUrl: string,
): Promise<Record<string, unknown>> {
const url = baseUrl.replace(/\/+$/, "");
let codeValue = code;
if (!code) {
if (!codeValue) {
const resp = await fetch(`${url}/api/v1/auth/email_code/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -46,7 +53,7 @@ async function emailLogin(
let detail: string;
try {
const body = (await resp.json()) as Record<string, unknown>;
detail = (body["error"] ?? body["detail"] ?? resp.statusText) as string;
detail = (body.error ?? body.detail ?? resp.statusText) as string;
} catch {
detail = resp.statusText;
}
@@ -70,13 +77,13 @@ async function emailLogin(
printError("Code is required.");
process.exit(1);
}
code = entered;
codeValue = 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() }),
body: JSON.stringify({ email, code: codeValue.trim() }),
signal: AbortSignal.timeout(30_000),
});
if (verifyResp.status === 429) {
@@ -87,7 +94,7 @@ async function emailLogin(
let detail: string;
try {
const body = (await verifyResp.json()) as Record<string, unknown>;
detail = (body["error"] ?? body["detail"] ?? verifyResp.statusText) as string;
detail = (body.error ?? body.detail ?? verifyResp.statusText) as string;
} catch {
detail = verifyResp.statusText;
}
@@ -153,7 +160,10 @@ function promptSecret(label: string): Promise<string> {
}
function promptLine(label: string, defaultValue?: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = defaultValue ? `${label} [${defaultValue}]: ` : `${label}: `;
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
@@ -165,7 +175,9 @@ function promptLine(label: string, defaultValue?: string): Promise<string> {
async function setupPlatform(config: Mem0Config): Promise<void> {
console.log();
console.log(` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys")}`);
console.log(
` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys")}`,
);
console.log();
process.stdout.write(` ${brand("API Key")}: `);
@@ -181,7 +193,10 @@ async function setupDefaults(config: Mem0Config): Promise<void> {
console.log();
printInfo("Set default entity IDs (press Enter to skip).\n");
const userId = await promptLine(` ${brand("Default User ID")} ${dim("(recommended)")}`, "mem0-cli");
const userId = await promptLine(
` ${brand("Default User ID")} ${dim("(recommended)")}`,
"mem0-cli",
);
if (userId) config.defaults.userId = userId;
}
@@ -208,12 +223,19 @@ async function validatePlatform(config: Mem0Config): Promise<void> {
}
export async function runInit(
opts: { apiKey?: string; userId?: string; email?: string; code?: string } = {},
opts: {
apiKey?: string;
userId?: string;
email?: string;
code?: string;
} = {},
): Promise<void> {
const config = createDefaultConfig();
const savedConfig = loadConfig();
const baseUrl =
process.env["MEM0_BASE_URL"] || savedConfig.platform.baseUrl || DEFAULT_BASE_URL;
process.env.MEM0_BASE_URL ||
savedConfig.platform.baseUrl ||
DEFAULT_BASE_URL;
// Guards
if (opts.code && !opts.email) {
@@ -236,9 +258,11 @@ export async function runInit(
const result = await emailLogin(email, opts.code, baseUrl);
const apiKeyVal = result["api_key"] as string | undefined;
const apiKeyVal = result.api_key as string | undefined;
if (!apiKeyVal) {
printError("Auth succeeded but no API key was returned. Contact support.");
printError(
"Auth succeeded but no API key was returned. Contact support.",
);
process.exit(1);
}
@@ -307,9 +331,11 @@ export async function runInit(
const result = await emailLogin(email, undefined, baseUrl);
const apiKeyVal = result["api_key"] as string | undefined;
const apiKeyVal = result.api_key as string | undefined;
if (!apiKeyVal) {
printError("Auth succeeded but no API key was returned. Contact support.");
printError(
"Auth succeeded but no API key was returned. Contact support.",
);
process.exit(1);
}
+92 -24
View File
@@ -3,8 +3,14 @@
*/
import fs from "node:fs";
import { printError, printInfo, printScope, printSuccess, timedStatus } from "../branding.js";
import type { Backend } from "../backend/base.js";
import {
printError,
printInfo,
printScope,
printSuccess,
timedStatus,
} from "../branding.js";
import {
formatAddResult,
formatJson,
@@ -52,7 +58,9 @@ export async function cmdAdd(
try {
msgs = JSON.parse(opts.messages);
} catch (e) {
printError(`Invalid JSON in --messages: ${e instanceof Error ? e.message : e}`);
printError(
`Invalid JSON in --messages: ${e instanceof Error ? e.message : e}`,
);
process.exit(1);
}
}
@@ -66,14 +74,18 @@ export async function cmdAdd(
process.exit(1);
}
if (!content && !msgs) {
printError("No content provided. Pass text, --messages, --file, or pipe via stdin.");
printError(
"No content provided. Pass text, --messages, --file, or pipe via stdin.",
);
process.exit(1);
}
// Validate --expires
if (opts.expires) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(opts.expires)) {
printError("Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).");
printError(
"Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).",
);
process.exit(1);
}
if (new Date(opts.expires) <= new Date()) {
@@ -130,10 +142,19 @@ export async function cmdAdd(
}
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]);
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`);
printSuccess(
`Memory processed — ${count} memor${count === 1 ? "y" : "ies"} extracted`,
);
formatAddResult(result, opts.output);
}
@@ -170,7 +191,9 @@ export async function cmdSearch(
}
}
const fieldList = opts.fields ? opts.fields.split(",").map((f) => f.trim()) : undefined;
const fieldList = opts.fields
? opts.fields.split(",").map((f) => f.trim())
: undefined;
if (opts.topK < 1) {
printError("--top-k must be >= 1.");
@@ -185,6 +208,7 @@ export async function cmdSearch(
let results: Record<string, unknown>[];
try {
results = await timedStatus("Searching memories...", async () => {
// biome-ignore lint/style/noNonNullAssertion: guarded by process.exit above
return backend.search(query!, {
userId: opts.userId,
agentId: opts.agentId,
@@ -212,7 +236,11 @@ export async function cmdSearch(
} else if (opts.output === "table") {
if (results.length > 0) {
formatMemoriesTable(results, { showScore: true });
printResultSummary({ count: results.length, durationSecs: elapsed, scopeIds: { user_id: opts.userId, agent_id: opts.agentId } });
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.");
@@ -221,7 +249,11 @@ export async function cmdSearch(
} else {
if (results.length > 0) {
formatMemoriesText(results);
printResultSummary({ count: results.length, durationSecs: elapsed, scopeIds: { user_id: opts.userId, agent_id: opts.agentId } });
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.");
@@ -308,7 +340,12 @@ export async function cmdList(
} 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 } });
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.");
@@ -317,7 +354,12 @@ export async function cmdList(
} 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 } });
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.");
@@ -357,7 +399,9 @@ export async function cmdUpdate(
if (opts.output === "json") {
formatJson(result);
} else if (opts.output !== "quiet") {
printSuccess(`Memory ${memoryId.slice(0, 8)} updated (${elapsed.toFixed(2)}s)`);
printSuccess(
`Memory ${memoryId.slice(0, 8)} updated (${elapsed.toFixed(2)}s)`,
);
}
}
@@ -395,7 +439,9 @@ export async function cmdDelete(
if (opts.output === "json") {
formatJson(result);
} else if (opts.output !== "quiet") {
printSuccess(`Memory ${memoryId.slice(0, 8)} deleted (${elapsed.toFixed(2)}s)`);
printSuccess(
`Memory ${memoryId.slice(0, 8)} deleted (${elapsed.toFixed(2)}s)`,
);
}
}
@@ -416,16 +462,24 @@ export async function cmdDeleteAll(
// Project-wide wipe using wildcard entity IDs
if (opts.dryRun) {
printInfo("Would delete ALL memories project-wide.");
printInfo("Run without --dry-run to see the actual count of deleted memories.");
printInfo(
"Run without --dry-run to see the actual count of deleted memories.",
);
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 rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise<string>((resolve) => {
rl.question(`\n \u26a0 Delete ALL memories across the ENTIRE project? This cannot be undone. [y/N] `, 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") {
@@ -437,7 +491,9 @@ export async function cmdDeleteAll(
const start = performance.now();
let result: Record<string, unknown>;
try {
result = await timedStatus("Deleting all memories project-wide...", async () => {
result = await timedStatus(
"Deleting all memories project-wide...",
async () => {
return backend.delete(undefined, {
all: true,
userId: "*",
@@ -445,7 +501,8 @@ export async function cmdDeleteAll(
appId: "*",
runId: "*",
});
});
},
);
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
process.exit(1);
@@ -456,7 +513,9 @@ export async function cmdDeleteAll(
formatJson(result);
} else if (opts.output !== "quiet") {
if (result.message) {
printInfo("Deletion started. Memories will be removed in the background.");
printInfo(
"Deletion started. Memories will be removed in the background.",
);
} else {
printSuccess(`All project memories deleted (${elapsed.toFixed(2)}s)`);
}
@@ -488,12 +547,19 @@ export async function cmdDeleteAll(
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 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 rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise<string>((resolve) => {
rl.question(`\n \u26a0 Delete ALL memories for ${scope}? This cannot be undone. [y/N] `, resolve);
rl.question(
`\n \u26a0 Delete ALL memories for ${scope}? This cannot be undone. [y/N] `,
resolve,
);
});
rl.close();
if (answer.toLowerCase() !== "y") {
@@ -524,7 +590,9 @@ export async function cmdDeleteAll(
formatJson(result);
} else if (opts.output !== "quiet") {
if (result.message) {
printInfo("Deletion started. Memories will be removed in the background.");
printInfo(
"Deletion started. Memories will be removed in the background.",
);
} else {
printSuccess(`All matching memories deleted (${elapsed.toFixed(2)}s)`);
}
+17 -7
View File
@@ -3,10 +3,11 @@
*/
import fs from "node:fs";
import { printError, printSuccess, timedStatus, colors } from "../branding.js";
import type { Backend } from "../backend/base.js";
import { formatJsonEnvelope } from "../output.js";
import boxen from "boxen";
import type { Backend } from "../backend/base.js";
import { colors, printError, printSuccess, timedStatus } from "../branding.js";
import { formatJsonEnvelope } from "../output.js";
import { CLI_VERSION } from "../version.js";
const { brand, dim, success, error: errorColor } = colors;
@@ -21,7 +22,10 @@ export async function cmdStatus(
return backend.status({ userId: opts.userId, agentId: opts.agentId });
});
} catch (e) {
result = { connected: false, error: e instanceof Error ? e.message : String(e) };
result = {
connected: false,
error: e instanceof Error ? e.message : String(e),
};
}
const elapsed = (performance.now() - start) / 1000;
@@ -69,7 +73,7 @@ export async function cmdStatus(
}
export function cmdVersion(): void {
console.log(` ${brand("◆ Mem0")} CLI v${__CLI_VERSION__}`);
console.log(` ${brand("◆ Mem0")} CLI v${CLI_VERSION}`);
}
export async function cmdImport(
@@ -112,7 +116,9 @@ export async function cmdImport(
// Simple progress indicator
if ((i + 1) % 10 === 0 || i === data.length - 1) {
process.stdout.write(`\r ${dim(`Importing memories... ${i + 1}/${data.length}`)}`);
process.stdout.write(
`\r ${dim(`Importing memories... ${i + 1}/${data.length}`)}`,
);
}
}
@@ -122,7 +128,11 @@ export async function cmdImport(
if (opts.output === "json") {
formatJsonEnvelope({
command: "import",
data: { added, failed, duration_s: parseFloat(elapsed.toFixed(2)) },
data: {
added,
failed,
duration_s: Number.parseFloat(elapsed.toFixed(2)),
},
durationMs: Math.round(elapsed * 1000),
});
return;
+23 -15
View File
@@ -80,10 +80,14 @@ export function loadConfig(): Mem0Config {
}
// 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_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) {
@@ -119,8 +123,8 @@ export function saveConfig(config: Mem0Config): void {
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);
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. */
@@ -133,13 +137,13 @@ const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
"defaults.run_id": ["defaults", "runId"],
"defaults.enable_graph": ["defaults", "enableGraph"],
// Short-form aliases
"api_key": ["platform", "apiKey"],
"base_url": ["platform", "baseUrl"],
"user_id": ["defaults", "userId"],
"agent_id": ["defaults", "agentId"],
"app_id": ["defaults", "appId"],
"run_id": ["defaults", "runId"],
"enable_graph": ["defaults", "enableGraph"],
api_key: ["platform", "apiKey"],
base_url: ["platform", "baseUrl"],
user_id: ["defaults", "userId"],
agent_id: ["defaults", "agentId"],
app_id: ["defaults", "appId"],
run_id: ["defaults", "runId"],
enable_graph: ["defaults", "enableGraph"],
};
export function getNestedValue(config: Mem0Config, dottedKey: string): unknown {
@@ -149,7 +153,11 @@ export function getNestedValue(config: Mem0Config, dottedKey: string): unknown {
return (config[section] as unknown as Record<string, unknown>)[field];
}
export function setNestedValue(config: Mem0Config, dottedKey: string, value: string): boolean {
export function setNestedValue(
config: Mem0Config,
dottedKey: string,
value: string,
): boolean {
const mapping = KEY_MAP[dottedKey];
if (!mapping) return false;
const [section, field] = mapping;
@@ -159,7 +167,7 @@ export function setNestedValue(config: Mem0Config, dottedKey: string, value: str
if (typeof current === "boolean") {
obj[field] = ["true", "1", "yes"].includes(value.toLowerCase());
} else if (typeof current === "number") {
obj[field] = parseInt(value, 10);
obj[field] = Number.parseInt(value, 10);
} else {
obj[field] = value;
}
+2 -2
View File
@@ -1,2 +1,2 @@
/** Injected by tsup at build time from package.json version field. */
declare const __CLI_VERSION__: string;
/** Injected by tsup at build time from package.json version field. Undefined in dev/test. */
declare const __CLI_VERSION__: string | undefined;
+21 -17
View File
@@ -4,7 +4,7 @@
*/
import chalk from "chalk";
import type { Command, Help, Option, Argument } from "commander";
import type { Argument, Command, Help, Option } from "commander";
// Colors imported from chalk directly to match Typer/Rich defaults
// ── Colors (matching Typer/Rich defaults) ────────────────────────────────
@@ -19,7 +19,7 @@ const dimBorder = chalk.dim; // panel borders
// ── Strip ANSI ───────────────────────────────────────────────────────────
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence is intentional
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function stripAnsi(str: string): number {
@@ -141,11 +141,7 @@ const PANEL_ORDER: string[] = [
* ╰────────────────────────────────╯
* ```
*/
function renderPanel(
title: string,
rows: string[],
width: number,
): string {
function renderPanel(title: string, rows: string[], width: number): string {
if (rows.length === 0) return "";
// Inner width is total width minus the two border chars
@@ -161,13 +157,14 @@ function renderPanel(
dimBorder("╮");
// Bottom border: ╰─...─╯
const bottomLine = dimBorder("╰") + dimBorder("─".repeat(inner)) + dimBorder("╯");
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 `${dimBorder("│")} ${row}${" ".repeat(pad)}${dimBorder("│")}`;
});
return [topLine, ...contentLines, bottomLine].join("\n");
@@ -185,7 +182,7 @@ function formatOptionTerm(opt: Option): string {
if (opt.flags) {
const match = opt.flags.match(/<[^>]+>|\[[^\]]+\]/);
if (match) {
term += " " + match[0];
term += ` ${match[0]}`;
}
}
return term;
@@ -220,7 +217,9 @@ export function richFormatHelp(cmd: Command, helper: Help): string {
lines.push("");
if (isRoot) {
// Root: "Usage: mem0 <command> [options]" — <command> yellow, [options] bold
lines.push(` ${yellow("Usage:")} ${bold(cmd.name())} ${yellow("<command>")} ${bold("[options]")}`);
lines.push(
` ${yellow("Usage:")} ${bold(cmd.name())} ${yellow("<command>")} ${bold("[options]")}`,
);
} else {
// Subcommands: split into command path (bold) and args (yellow)
const usageParts = usage.split(" ");
@@ -235,7 +234,9 @@ export function richFormatHelp(cmd: Command, helper: Help): string {
argParts.push(part);
}
}
lines.push(` ${yellow("Usage:")} ${bold(cmdPath.join(" "))} ${yellow(argParts.join(" "))}`);
lines.push(
` ${yellow("Usage:")} ${bold(cmdPath.join(" "))} ${yellow(argParts.join(" "))}`,
);
}
lines.push("");
@@ -260,7 +261,9 @@ export function richFormatHelp(cmd: Command, helper: Help): string {
if (!isRoot) {
const visibleArgs = helper.visibleArguments(cmd);
if (visibleArgs.length > 0) {
const maxLen = Math.max(...visibleArgs.map((a: Argument) => a.name().length));
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);
@@ -274,7 +277,8 @@ export function richFormatHelp(cmd: Command, helper: Help): string {
// ── 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 panelMap =
!isRoot && OPTION_PANELS[cmdName] ? OPTION_PANELS[cmdName] : {};
const grouped: Record<string, Option[]> = { Options: [] };
for (const panelName of PANEL_ORDER) {
@@ -287,7 +291,7 @@ export function richFormatHelp(cmd: Command, helper: Help): string {
if (panel && PANEL_ORDER.includes(panel)) {
grouped[panel].push(opt);
} else {
grouped["Options"].push(opt);
grouped.Options.push(opt);
}
}
@@ -296,8 +300,8 @@ export function richFormatHelp(cmd: Command, helper: Help): string {
if (isRoot) {
// ROOT: Options first, then command groups (matches Python/Typer ordering)
if (grouped["Options"].length > 0) {
const optRows = formatOptionRows(grouped["Options"]);
if (grouped.Options.length > 0) {
const optRows = formatOptionRows(grouped.Options);
const panel = renderPanel("Options", optRows, width);
if (panel) lines.push(panel);
}
+159 -40
View File
@@ -8,11 +8,12 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Command } from "commander";
import { type Backend, getBackend } from "./backend/index.js";
import { colors, printError } from "./branding.js";
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";
import { CLI_VERSION } from "./version.js";
const program = new Command();
@@ -58,7 +59,12 @@ function resolveIds(
runId?: string;
},
): { userId?: string; agentId?: string; appId?: string; runId?: string } {
const hasExplicit = !!(opts.userId || opts.agentId || opts.appId || opts.runId);
const hasExplicit = !!(
opts.userId ||
opts.agentId ||
opts.appId ||
opts.runId
);
if (hasExplicit) {
return {
userId: opts.userId || undefined,
@@ -91,10 +97,12 @@ function resolveGraph(
program
.name("mem0")
.description(`◆ Mem0 CLI v${__CLI_VERSION__} · Node.js SDK\n\nThe Memory Layer for AI Agents`)
.description(
`◆ Mem0 CLI v${CLI_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${__CLI_VERSION__}`);
console.log(` ${colors.brand("◆ Mem0")} CLI v${CLI_VERSION}`);
process.exit(0);
})
.usage("<command> [options]")
@@ -106,18 +114,28 @@ program
program
.command("init")
.description("Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).")
.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("-u, --user-id <id>", "Default user ID (skip prompt).")
.option("--email <email>", "Login via email verification code.")
.option("--code <code>", "Verification code (use with --email for non-interactive login).")
.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) => {
const { runInit } = await import("./commands/init.js");
await runInit({ apiKey: opts.apiKey, userId: opts.userId, email: opts.email, code: opts.code });
await runInit({
apiKey: opts.apiKey,
userId: opts.userId,
email: opts.email,
code: opts.code,
});
});
// ── Memory: add ───────────────────────────────────────────────────────────
@@ -141,7 +159,10 @@ program
.option("-o, --output <format>", "Output format: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <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')
.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);
@@ -159,8 +180,18 @@ program
.option("--agent-id <id>", "Filter by agent.")
.option("--app-id <id>", "Filter by app.")
.option("--run-id <id>", "Filter by run.")
.option("-k, --top-k <n>", "Number of results.", (v) => parseInt(v), 10)
.option("--threshold <n>", "Minimum similarity score.", (v) => parseFloat(v), 0.3)
.option(
"-k, --top-k <n>",
"Number of results.",
(v) => Number.parseInt(v),
10,
)
.option(
"--threshold <n>",
"Minimum similarity score.",
(v) => Number.parseFloat(v),
0.3,
)
.option("--rerank", "Enable reranking (Platform only).", false)
.option("--keyword", "Use keyword search.", false)
.option("--filter <json>", "Advanced filter expression (JSON).")
@@ -170,7 +201,10 @@ program
.option("-o, --output <format>", "Output: text, json, table.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <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')
.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) {
@@ -205,7 +239,10 @@ program
.option("-o, --output <format>", "Output: text, json.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText("after", "\nExamples:\n $ mem0 get abc-123-def-456\n $ mem0 get abc-123-def-456 -o json")
.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);
@@ -221,8 +258,13 @@ program
.option("--agent-id <id>", "Filter by agent.")
.option("--app-id <id>", "Filter by app.")
.option("--run-id <id>", "Filter by run.")
.option("--page <n>", "Page number.", (v) => parseInt(v), 1)
.option("--page-size <n>", "Results per page.", (v) => parseInt(v), 100)
.option("--page <n>", "Page number.", (v) => Number.parseInt(v), 1)
.option(
"--page-size <n>",
"Results per page.",
(v) => Number.parseInt(v),
100,
)
.option("--category <name>", "Filter by category.")
.option("--after <date>", "Created after (YYYY-MM-DD).")
.option("--before <date>", "Created before (YYYY-MM-DD).")
@@ -231,7 +273,10 @@ program
.option("-o, --output <format>", "Output: text, json, table.", "table")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText("after", "\nExamples:\n $ mem0 list -u alice\n $ mem0 list --category prefs --after 2024-01-01 -o json")
.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);
@@ -258,7 +303,10 @@ program
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <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`)
.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) {
@@ -266,7 +314,10 @@ program
}
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 });
await cmdUpdate(backend, memoryId, resolvedText, {
metadata: opts.metadata,
output: opts.output,
});
});
// ── Memory: delete (consolidated) ─────────────────────────────────────────
@@ -275,7 +326,11 @@ 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(
"--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)
@@ -286,13 +341,16 @@ program
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText("after", [
.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"))
].join("\n"),
)
.action(async (memoryId, opts) => {
// ── Mutual-exclusion checks ──
if (memoryId && opts.all) {
@@ -300,7 +358,9 @@ program
process.exit(1);
}
if (memoryId && opts.entity) {
printError("Cannot combine <memoryId> with --entity. Use one or the other.");
printError(
"Cannot combine <memoryId> with --entity. Use one or the other.",
);
process.exit(1);
}
if (opts.all && opts.entity) {
@@ -321,18 +381,36 @@ program
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 });
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 { backend, config } = getBackendAndConfig(
opts.apiKey,
opts.baseUrl,
);
const ids = opts.project
? { userId: undefined, agentId: undefined, appId: undefined, runId: undefined }
? {
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 });
await cmdDeleteAll(backend, {
force: opts.force,
dryRun: opts.dryRun,
all: opts.project,
...ids,
output: opts.output,
});
return;
}
@@ -356,7 +434,10 @@ configCmd
.command("show")
.description("Display current configuration (secrets redacted).")
.option("-o, --output <format>", "Output: text, json.", "text")
.addHelpText("after", "\nExamples:\n $ mem0 config show\n $ mem0 config show -o json")
.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 });
@@ -365,7 +446,10 @@ configCmd
configCmd
.command("get <key>")
.description("Get a configuration value.")
.addHelpText("after", "\nExamples:\n $ mem0 config get platform.api_key\n $ mem0 config get defaults.user_id")
.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);
@@ -374,7 +458,10 @@ configCmd
configCmd
.command("set <key> <value>")
.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")
.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);
@@ -394,7 +481,10 @@ entityCmd
.option("-o, --output <format>", "Output: table, json.", "table")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText("after", "\nExamples:\n $ mem0 entity list users\n $ mem0 entity list agents -o json")
.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);
@@ -413,7 +503,10 @@ entityCmd
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <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")
.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);
@@ -439,7 +532,6 @@ program
});
});
program
.command("import <filePath>")
.description("Import memories from a JSON file.")
@@ -448,19 +540,28 @@ program
.option("-o, --output <format>", "Output: text, json.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <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")
.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 });
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).")
.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) => {
@@ -472,19 +573,35 @@ program
const spec = JSON.parse(fs.readFileSync(specPath, "utf-8"));
console.log(JSON.stringify(spec, null, 2));
} else {
console.log(JSON.stringify({ name: "mem0", version: __CLI_VERSION__, description: "The Memory Layer for AI Agents" }, null, 2));
console.log(
JSON.stringify(
{
name: "mem0",
version: CLI_VERSION,
description: "The Memory Layer for AI Agents",
},
null,
2,
),
);
}
} else {
const { brand: b } = colors;
console.log(`${b("◆ Mem0 CLI")} v${__CLI_VERSION__} · Node.js SDK\n The Memory Layer for AI Agents\n`);
console.log(
`${b("◆ Mem0 CLI")} v${CLI_VERSION} · Node.js SDK\n The Memory Layer for AI Agents\n`,
);
console.log("Usage: mem0 <command> [OPTIONS]\n");
console.log("Commands:");
console.log(" add Add a memory from text, messages, file, or stdin");
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(
" 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)");
@@ -492,7 +609,9 @@ program
console.log(" status Check connectivity and authentication");
console.log();
console.log(" mem0 <command> --help Get help for a command");
console.log(" mem0 help --json Machine-readable help (for LLM agents)");
console.log(
" mem0 help --json Machine-readable help (for LLM agents)",
);
console.log();
}
});
+28 -12
View File
@@ -2,8 +2,8 @@
* Output formatting for mem0 CLI — text, JSON, table, quiet modes.
*/
import Table from "cli-table3";
import boxen from "boxen";
import Table from "cli-table3";
import { colors, sym } from "./branding.js";
const { brand, accent, success, error: errorColor, dim } = colors;
@@ -52,13 +52,20 @@ export function formatMemoriesText(
}
}
export function formatMemoriesTable(memories: Record<string, unknown>[], opts: { showScore?: boolean } = {}): void {
export function formatMemoriesTable(
memories: Record<string, unknown>[],
opts: { showScore?: boolean } = {},
): void {
const head = opts.showScore
? [accent("ID"), accent("Score"), accent("Memory"), accent("Category"), accent("Created")]
? [
accent("ID"),
accent("Score"),
accent("Memory"),
accent("Category"),
accent("Created"),
]
: [accent("ID"), accent("Memory"), accent("Category"), accent("Created")];
const colWidths = opts.showScore
? [38, 8, 40, 16, 14]
: [38, 40, 16, 14];
const colWidths = opts.showScore ? [38, 8, 40, 16, 14] : [38, 40, 16, 14];
const table = new Table({
head,
colWidths,
@@ -67,10 +74,10 @@ export function formatMemoriesTable(memories: Record<string, unknown>[], opts: {
});
for (const mem of memories) {
const memId = ((mem.id as string) ?? "");
const memId = (mem.id as string) ?? "";
let memoryText = (mem.memory ?? mem.text ?? "") as string;
if (memoryText.length > 60) {
memoryText = memoryText.slice(0, 57) + "...";
memoryText = `${memoryText.slice(0, 57)}...`;
}
const categories = mem.categories;
const cat =
@@ -123,7 +130,9 @@ export function formatSingleMemory(
if (meta) lines.push(` ${dim("Metadata:")} ${JSON.stringify(meta)}`);
const categories = mem.categories;
if (categories) {
const catStr = Array.isArray(categories) ? categories.join(", ") : String(categories);
const catStr = Array.isArray(categories)
? categories.join(", ")
: String(categories);
lines.push(` ${dim("Categories:")} ${catStr}`);
}
@@ -165,7 +174,10 @@ export function formatAddResult(
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"];
const parts = [
` ${icon} ${dim("Queued".padEnd(10))}`,
"Processing in background",
];
if (eventId) parts.push(dim(`(event ${eventId})`));
console.log(parts.join(" "));
continue;
@@ -173,7 +185,10 @@ export function formatAddResult(
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);
const memId = ((r.id as string) ?? (r.memory_id as string) ?? "").slice(
0,
8,
);
let icon: string;
let label: string;
@@ -237,7 +252,8 @@ export function printResultSummary(opts: {
.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`);
if (opts.durationSecs !== undefined)
parts.push(`${opts.durationSecs.toFixed(2)}s`);
console.log(` ${dim(parts.join(" · "))}`);
console.log();
+10
View File
@@ -0,0 +1,10 @@
import { createRequire } from "node:module";
// __CLI_VERSION__ is replaced at build time by tsup (see tsup.config.ts).
// When running via tsx in dev/test mode, fall back to reading package.json.
// typeof is safe to use on undeclared identifiers — it returns 'undefined' without throwing.
export const CLI_VERSION: string =
typeof __CLI_VERSION__ !== "undefined"
? (__CLI_VERSION__ as string)
: (createRequire(import.meta.url)("../package.json") as { version: string })
.version;
+4 -4
View File
@@ -80,19 +80,19 @@ describe("printWarning", () => {
describe("printInfo", () => {
it("prints info message", () => {
printInfo("Important note");
expect(output).toContain("Important note");
expect(errOutput).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");
expect(errOutput).toContain("alice");
expect(errOutput).toContain("bot");
});
it("prints nothing when no IDs", () => {
printScope({});
expect(output).toBe("");
expect(errOutput).toBe("");
});
});
+2 -2
View File
@@ -125,7 +125,7 @@ describe("cmdSearch", () => {
enableGraph: false,
output: "text",
});
expect(output).toContain("No memories found");
expect(errOutput).toContain("No memories found");
});
});
@@ -166,7 +166,7 @@ describe("cmdList", () => {
enableGraph: false,
output: "text",
});
expect(output).toContain("No memories found");
expect(errOutput).toContain("No memories found");
});
});
+11
View File
@@ -0,0 +1,11 @@
import { createRequire } from "node:module";
import { defineConfig } from "vitest/config";
const _require = createRequire(import.meta.url);
const pkg = _require("./package.json") as { version: string };
export default defineConfig({
define: {
__CLI_VERSION__: JSON.stringify(pkg.version),
},
});
+36 -18
View File
@@ -253,8 +253,12 @@ def search(
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"),
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"
),
@@ -372,8 +376,12 @@ def list_cmd(
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"),
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"
),
@@ -463,11 +471,19 @@ def update(
@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)."),
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."),
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"
@@ -641,14 +657,12 @@ def entity_delete(
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"
),
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."),
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"
),
@@ -694,9 +708,13 @@ app.add_typer(entity_app, name="entity", rich_help_panel="Management")
@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)."),
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)."),
code: str | None = typer.Option(
None, "--code", help="Verification code (use with --email for non-interactive login)."
),
) -> None:
"""Interactive setup wizard for mem0 CLI.
@@ -747,7 +765,6 @@ def status(
)
@app.command("import", rich_help_panel="Management")
def import_cmd(
file_path: str = typer.Argument(..., help="JSON file to import."),
@@ -1018,6 +1035,7 @@ def help(
def version() -> None:
"""Show version and exit."""
from mem0_cli.commands.utils import cmd_version
cmd_version()
-1
View File
@@ -105,7 +105,6 @@ class Backend(ABC):
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
@@ -312,7 +312,6 @@ class PlatformBackend(Backend):
return items
class AuthError(Exception):
pass
+6 -2
View File
@@ -28,7 +28,9 @@ def cmd_entities_list(backend: Backend, entity_type: str, *, output: str) -> Non
"""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)}")
print_error(
err_console, f"Invalid entity type: {entity_type}. Use: {', '.join(valid_types)}"
)
raise typer.Exit(1)
_start = _time.perf_counter()
@@ -76,7 +78,9 @@ def cmd_entities_delete(
) -> 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.")
print_error(
err_console, "Provide at least one of --user-id, --agent-id, --app-id, --run-id."
)
raise typer.Exit(1)
if dry_run:
+3 -1
View File
@@ -265,7 +265,9 @@ def run_init(
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.")
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
+7 -2
View File
@@ -98,10 +98,14 @@ def cmd_add(
# Validate --expires
if expires:
import re
if not re.match(r'^\d{4}-\d{2}-\d{2}$', expires):
print_error(err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).")
if not re.match(r"^\d{4}-\d{2}-\d{2}$", expires):
print_error(
err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)."
)
raise typer.Exit(1)
from datetime import date
if date.fromisoformat(expires) <= date.today():
print_error(err_console, "--expires date must be in the future.")
raise typer.Exit(1)
@@ -290,6 +294,7 @@ def cmd_list(
if output == "json":
from mem0_cli.output import format_json_envelope
format_json_envelope(
console,
command="list",
+3 -1
View File
@@ -110,7 +110,9 @@ def cmd_import(
added = 0
failed = 0
_start = _time.perf_counter()
for item in track(data, description=f"[{DIM_COLOR}]Importing memories...[/]", console=err_console):
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
+8 -2
View File
@@ -49,7 +49,9 @@ def format_memories_text(console: Console, memories: list[dict], title: str = "m
console.print()
def format_memories_table(console: Console, memories: list[dict], *, show_score: bool = False) -> None:
def format_memories_table(
console: Console, memories: list[dict], *, show_score: bool = False
) -> None:
"""Render memories in a rich table."""
table = Table(
border_style=BRAND_COLOR,
@@ -71,7 +73,11 @@ def format_memories_table(console: Console, memories: list[dict], *, show_score:
memory_text = memory_text[:57] + "..."
categories = mem.get("categories", [])
if isinstance(categories, list) and categories:
cat = categories[0] if len(categories) == 1 else f"{categories[0]} (+{len(categories) - 1})"
cat = (
categories[0]
if len(categories) == 1
else f"{categories[0]} (+{len(categories) - 1})"
)
else:
cat = "—"
created = _format_date(mem.get("created_at")) or "—"
+28 -3
View File
@@ -8,11 +8,19 @@ boundaries).
from __future__ import annotations
import os
import re
import subprocess
import sys
import pytest
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes so substring checks work regardless of color mode."""
return _ANSI_RE.sub("", text)
def _run(
args: list[str],
@@ -28,22 +36,35 @@ def _run(
reads config from ``<home_dir>/.mem0/config.json`` instead
of the user's real config. This is critical for tests that
depend on a clean (no API key) or custom config state.
Returns a CompletedProcess whose stdout/stderr have ANSI escape codes
stripped. GitHub Actions sets FORCE_COLOR=1 which causes Rich/Typer to
fragment option names like --user-id into separately-styled ANSI segments,
making plain ``in`` checks fail. Stripping here is version-agnostic and
ensures all assertions see the same plain text regardless of terminal env.
"""
env = os.environ.copy()
# Strip all MEM0_ env vars so tests start clean
for key in list(env.keys()):
if key.startswith("MEM0_"):
del env[key]
env.pop("FORCE_COLOR", None)
if home_dir:
env["HOME"] = home_dir
if env_override:
env.update(env_override)
return subprocess.run(
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", *args],
capture_output=True,
text=True,
env=env,
)
return subprocess.CompletedProcess(
args=result.args,
returncode=result.returncode,
stdout=_strip_ansi(result.stdout),
stderr=_strip_ansi(result.stderr),
)
@pytest.fixture
@@ -67,7 +88,6 @@ class TestCLIIntegration:
assert result.returncode == 0
assert "0.1.0" in result.stdout
def test_add_help(self):
result = _run(["add", "--help"])
assert result.returncode == 0
@@ -161,7 +181,12 @@ class TestCLIIsolated:
)
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
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."""