feat(cli): auto-sync active api_key to plugin env touchpoints
When saveConfig writes a fresh api_key (e.g. agent-mode bootstrap, OTP
signup), propagate the value into other ecosystem locations that hold
the same key:
- ~/.claude/settings.json::env::MEM0_API_KEY (Claude Code env injection)
- ~/.zshrc / ~/.bashrc / ~/.bash_profile `export MEM0_API_KEY="..."`
Without this, agent-mode bootstrap mints a new shadow into config.json
but the Claude plugin's MCP server keeps using the OLD env-var key —
silent surprise.
Hard guarantees:
1. **Update-only**, never create. If a target file doesn't already
contain a MEM0_API_KEY entry, we leave it alone. The user's
existing setup decides which surfaces are managed; we don't
unilaterally start writing to new files.
2. **Preserve surrounding content.** JSON files keep all other keys.
Shell rc files keep all other lines, comments, and the trailing
newline (regex uses [ \t]* not \s*, which would eat the final \n
when MEM0_API_KEY is the last line of .zshrc).
3. **Atomic writes.** tmpfile + rename, so a crash mid-write leaves
the original intact.
4. **Idempotent.** If the target already has this value, no-op.
5. **Best-effort.** Any IOError in the sync is swallowed; the
canonical config.json write is never blocked by plugin-state.
Implemented identically in Python (plugin_sync.py) and Node
(plugin-sync.ts). Hooked into save_config() / saveConfig() so every
api_key change propagates without any caller plumbing.
Verified on a sandbox copy of real ~/.claude/settings.json and
~/.zshrc: only the MEM0_API_KEY values changed; all 36 other lines
in settings.json and 50+ lines in .zshrc preserved byte-for-byte
including the trailing newline.
Out of scope (deliberate non-changes):
- ~/.codex/config.toml — no mem0 server entry to update
- ~/.cursor/mcp.json — no mem0 server entry to update
- <plugin-install-dir>/.api_key — plugin-managed, different schema
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
* <plugin-dir>/.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<string, unknown>;
|
||||
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<string, string>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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"""
|
||||
|
||||
@@ -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 ``<plugin-dir>/.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
|
||||
Reference in New Issue
Block a user