diff --git a/cli/node/src/config.ts b/cli/node/src/config.ts index 91cb76d94..5077d6a61 100644 --- a/cli/node/src/config.ts +++ b/cli/node/src/config.ts @@ -143,6 +143,20 @@ export function saveConfig(config: Mem0Config): void { fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2)); fs.chmodSync(CONFIG_FILE, 0o600); + + // Propagate api_key to ecosystem touchpoints (Claude plugin env injection, + // shell rc exports). Idempotent — updates only EXISTING entries; never + // creates new ones. Best-effort: errors swallowed so config.json is + // always authoritative, never blocked by plugin-state issues. + if (config.platform.apiKey) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { syncApiKey } = require("./plugin-sync.js"); + syncApiKey(config.platform.apiKey); + } catch { + /* swallow */ + } + } } export function redactKey(key: string): string { diff --git a/cli/node/src/plugin-sync.ts b/cli/node/src/plugin-sync.ts new file mode 100644 index 000000000..4c98eabfd --- /dev/null +++ b/cli/node/src/plugin-sync.ts @@ -0,0 +1,115 @@ +/** + * Sync the active Mem0 API key into other ecosystem touchpoints. + * + * Why: the CLI canonical state is ~/.mem0/config.json. MCP servers + * (Claude Code plugin, Codex plugin) read MEM0_API_KEY from env or + * their own config files. Without a sync, agent-mode bootstrap mints a + * new key into config.json but the plugin's MCP keeps using the old + * key from env — silent surprise. + * + * Design: + * - Update ONLY entries that already exist; never create new ones + * - Preserve surrounding content, formatting, other keys + * - Atomic writes (tmp + rename) so a crash mid-write doesn't corrupt + * - Idempotent — re-running with the same key is a no-op + * + * Targets: + * - ~/.claude/settings.json::env::MEM0_API_KEY (Claude Code env injection) + * - ~/.zshrc / ~/.bashrc `export MEM0_API_KEY="..."` lines + * + * Out of scope: Codex / Cursor MCP configs and the plugin's own + * /.api_key file (plugin-managed, different schema). + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const CLAUDE_SETTINGS = path.join(os.homedir(), ".claude", "settings.json"); +const SHELL_RCS = [ + path.join(os.homedir(), ".zshrc"), + path.join(os.homedir(), ".bashrc"), + path.join(os.homedir(), ".bash_profile"), +]; + +// Use [ \t]* (not \s*) so a trailing newline at end-of-file is preserved +// when the MEM0_API_KEY export is the last line of the rc file. +const RC_LINE_RE = + /^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["']?)([^"'\n]*)(["']?)[ \t]*$/m; + +export function syncApiKey(apiKey: string): string[] { + if (!apiKey) return []; + const updated: string[] = []; + if (updateClaudeSettings(CLAUDE_SETTINGS, apiKey)) { + updated.push(CLAUDE_SETTINGS); + } + for (const rc of SHELL_RCS) { + if (updateShellRc(rc, apiKey)) updated.push(rc); + } + return updated; +} + +function updateClaudeSettings(filePath: string, apiKey: string): boolean { + if (!fs.existsSync(filePath)) return false; + let raw: string; + let data: Record; + try { + raw = fs.readFileSync(filePath, "utf-8"); + data = JSON.parse(raw); + } catch { + return false; + } + const env = data.env; + if (!env || typeof env !== "object" || !("MEM0_API_KEY" in env)) { + return false; // no existing entry — don't create one + } + const envObj = env as Record; + if (envObj.MEM0_API_KEY === apiKey) return false; // already in sync + envObj.MEM0_API_KEY = apiKey; + atomicWriteText(filePath, `${JSON.stringify(data, null, 2)}\n`); + return true; +} + +function updateShellRc(filePath: string, apiKey: string): boolean { + if (!fs.existsSync(filePath)) return false; + let text: string; + try { + text = fs.readFileSync(filePath, "utf-8"); + } catch { + return false; + } + const match = text.match(RC_LINE_RE); + if (!match) return false; // no existing line + if (match[3] === apiKey) return false; + const newText = text.replace( + RC_LINE_RE, + (_full, prefix) => `${prefix}"${apiKey}"`, + ); + atomicWriteText(filePath, newText); + return true; +} + +function atomicWriteText(filePath: string, content: string): void { + const dir = path.dirname(filePath); + const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`); + try { + fs.writeFileSync(tmp, content, "utf-8"); + // Preserve permissions if original existed. + if (fs.existsSync(filePath)) { + try { + const mode = fs.statSync(filePath).mode & 0o777; + fs.chmodSync(tmp, mode); + } catch { + /* best-effort */ + } + } + fs.renameSync(tmp, filePath); + } catch (err) { + try { + fs.unlinkSync(tmp); + } catch { + /* ignore */ + } + throw err; + } +} diff --git a/cli/python/src/mem0_cli/config.py b/cli/python/src/mem0_cli/config.py index caf231be7..a8f1285df 100644 --- a/cli/python/src/mem0_cli/config.py +++ b/cli/python/src/mem0_cli/config.py @@ -160,6 +160,19 @@ def save_config(config: Mem0Config) -> None: os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600 + # Propagate the active api_key to ecosystem touchpoints (Claude Code + # plugin env injection, shell rc exports). Idempotent — only updates + # EXISTING entries; never creates new ones. Best-effort: any IOError + # in the sync is swallowed so config.json is always the authoritative + # write, never blocked by plugin-state issues. + if config.platform.api_key: + try: + from mem0_cli.plugin_sync import sync_api_key + + sync_api_key(config.platform.api_key) + except Exception: + pass + def redact_key(key: str) -> str: """Redact an API key for display: m0-xxx...xxx""" diff --git a/cli/python/src/mem0_cli/plugin_sync.py b/cli/python/src/mem0_cli/plugin_sync.py new file mode 100644 index 000000000..f984c8cd5 --- /dev/null +++ b/cli/python/src/mem0_cli/plugin_sync.py @@ -0,0 +1,117 @@ +"""Sync the active Mem0 API key into other ecosystem touchpoints. + +Why this exists: + The CLI canonical state lives in ``~/.mem0/config.json``. But MCP servers + (Claude Code plugin, Codex plugin, etc.) read ``MEM0_API_KEY`` from env + vars or their own config files. Without a sync, an agent-mode bootstrap + mints a new key into config.json but the plugin's MCP keeps using the + old key from env — silent surprise. + +Design: + - Update ONLY entries that already exist (never create new ones) + - Preserve all surrounding content / formatting / other keys + - Atomic writes (tmpfile + rename) so a crash mid-write doesn't corrupt + - Idempotent — re-running with the same key is a no-op + - Skip on dry_run + +Targets currently handled: + - ``~/.claude/settings.json::env::MEM0_API_KEY`` (Claude Code env injection) + - ``~/.zshrc`` / ``~/.bashrc`` ``export MEM0_API_KEY="..."`` lines + +Out of scope (deliberately not touched): + - Codex / Cursor MCP configs — would require schema-aware edits and + those tools don't have mem0 entries by default + - Plugin's own ``/.api_key`` file — plugin-managed +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile +from pathlib import Path + +# Files we know how to update safely. +_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json" +_SHELL_RCS = [Path.home() / ".zshrc", Path.home() / ".bashrc", Path.home() / ".bash_profile"] + + +def sync_api_key(api_key: str) -> list[str]: + """Propagate ``api_key`` into known ecosystem touchpoints. + + Returns the list of paths actually updated. Empty list means nothing + needed updating (either targets didn't exist or already had this value). + """ + if not api_key: + return [] + updated: list[str] = [] + if _update_claude_settings(_CLAUDE_SETTINGS, api_key): + updated.append(str(_CLAUDE_SETTINGS)) + for rc in _SHELL_RCS: + if _update_shell_rc(rc, api_key): + updated.append(str(rc)) + return updated + + +def _update_claude_settings(path: Path, api_key: str) -> bool: + """Update ``env.MEM0_API_KEY`` in path. Returns True if file was changed.""" + if not path.is_file(): + return False + try: + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return False + env = data.get("env") + if not isinstance(env, dict) or "MEM0_API_KEY" not in env: + # No existing entry — don't create one. + return False + if env["MEM0_API_KEY"] == api_key: + return False # already in sync + env["MEM0_API_KEY"] = api_key + _atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n") + return True + + +# Match `export MEM0_API_KEY="..."` (or single quotes, or no quotes). +# Use [ \t]* (not \s*) for trailing whitespace so a trailing newline at +# end-of-file is preserved when MEM0_API_KEY is the last line. +_RC_LINE = re.compile(r'^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["\']?)([^"\'\n]*)(["\']?)[ \t]*$', re.MULTILINE) + + +def _update_shell_rc(path: Path, api_key: str) -> bool: + """Update an existing ``export MEM0_API_KEY=...`` line in path.""" + if not path.is_file(): + return False + try: + text = path.read_text(encoding="utf-8") + except OSError: + return False + match = _RC_LINE.search(text) + if not match: + return False # no existing line + if match.group(3) == api_key: + return False + new_text = _RC_LINE.sub(lambda m: f'{m.group(1)}"{api_key}"', text, count=1) + _atomic_write_text(path, new_text) + return True + + +def _atomic_write_text(path: Path, content: str) -> None: + """Write content to path atomically (temp + rename).""" + dirname = path.parent + fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=dirname) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + # Preserve mode if the original existed. + if path.exists(): + os.chmod(tmp_path, path.stat().st_mode & 0o777) + os.replace(tmp_path, path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise