fix(plugins): read the mem0 CLI key and ignore unexpanded host placeholders

The Python plugin core and the opencode, pi and deepseek plugins now fall back to the key `mem0 init` saves in ~/.mem0/config.json, so a configured CLI is enough to authenticate. The Python core also stops using or caching a literal ${api_key} left behind when a host (Cursor) does not expand its plugin variables, which is what kept asking for auth after the key was set.

Fixes #7346
This commit is contained in:
kartik-mem0
2026-09-25 10:24:04 +05:30
parent 8d6c001966
commit 71dc0cae07
32 changed files with 452 additions and 224 deletions
+4
View File
@@ -28,6 +28,10 @@ echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc && source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The Mem0 plugin also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` takes precedence when set.
</Tip>
## Installation
**Option A: degit** (recommended):
+1 -1
View File
@@ -166,7 +166,7 @@ claude plugin update mem0@mem0-plugins --scope user
| Problem | Fix |
| --- | --- |
| Missing key | Reinstall with `--config api_key="$MEM0_API_KEY"` while the var is set. |
| Missing key | Reinstall with `--config api_key="$MEM0_API_KEY"` while the var is set, or run `mem0 init` with the [Mem0 CLI](/platform/cli). The plugin falls back to the key it saves. |
| `401 Unauthorized` | API key is invalid or expired. Run `/mem0:status` to confirm. |
| No memory after ending a session | Extraction runs in the background. Wait a moment, then search again. |
| Sidekick won't start | Must be in a Git repo. Check that your Claude Code version supports plugin agents and worktrees. |
+4
View File
@@ -35,6 +35,10 @@ source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The plugin (Option A) also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` takes precedence when set.
</Tip>
## Installation
### Option A: Plugin Marketplace (Recommended)
+5
View File
@@ -35,6 +35,10 @@ source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The full plugin (Option A) also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. That includes Cursor opened from the Dock, which does not load your shell profile. A key from the plugin configuration or the environment takes precedence.
</Tip>
<Warning>
Already have `mem0` configured as an MCP server in Cursor? Remove the existing entry from your Cursor MCP settings before installing to avoid duplicate tools.
</Warning>
@@ -164,6 +168,7 @@ Captured prompts and responses retain their full redacted text without a per-mes
## Troubleshooting
- **"Connection failed"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`
- **Still asked for an API key**: Run `mem0 init` with the [Mem0 CLI](/platform/cli). The full plugin falls back to the key it saves.
- **Duplicate tools**: Do not combine the full plugin with an MCP-only option. Remove the standalone `mem0` MCP entry before installing the plugin.
- **No tools appearing**: Go to Cursor Settings > MCP and verify the `mem0` server shows as connected
+5 -1
View File
@@ -56,6 +56,10 @@ source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The plugin also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `config.apiKey` and `MEM0_API_KEY` take precedence.
</Tip>
## Try it locally
1. Build and pack the plugin:
@@ -103,7 +107,7 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
| Field | Required | Default | Notes |
|---|---|---|---|
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key. Falls back to the key `mem0 init` saved. |
| `userId` | yes | | Default entity that owns the memories |
| `allowUserOverride` | no | `false` | Permit model-selected access to a different user only in a trusted multi-user deployment |
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
+1 -1
View File
@@ -99,7 +99,7 @@ Captured prompts and responses retain their full redacted text without a per-mes
| Problem | Fix |
| --- | --- |
| Missing API key | Start Kimi from a shell where `MEM0_API_KEY` is exported. |
| Missing API key | Start Kimi from a shell where `MEM0_API_KEY` is exported, or run `mem0 init` with the [Mem0 CLI](/platform/cli). The plugin falls back to the key it saves. |
| Plugin changes do not appear | Run `/plugins reload`, then `/reload` or `/new`. |
| MCP server is disabled | Run `/plugins mcp enable mem0 mem0`, then `/reload`. |
| No memory in a later session | Wait a moment for the background flush, then ask Kimi to search memory explicitly. |
+4
View File
@@ -26,6 +26,10 @@ echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc && source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The OpenCode plugin also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` and your shell profile take precedence.
</Tip>
## Installation
### Option A: Plugin Install (Recommended)
+6 -2
View File
@@ -40,6 +40,10 @@ source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The extension also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` and `apiKey` in `mem0-config.json` take precedence.
</Tip>
## Installation
```bash
@@ -68,7 +72,7 @@ For advanced settings, create `~/.pi/agent/mem0-config.json`:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `apiKey` | `string` | `$MEM0_API_KEY` | Mem0 API key. Environment variable takes precedence. |
| `apiKey` | `string` | `$MEM0_API_KEY` | Mem0 API key. Environment variable takes precedence. Falls back to the key `mem0 init` saved. |
| `userId` | `string` | `$MEM0_USER_ID` or `"default"` | User identity for memory scoping |
| `autoCapture` | `boolean` | `true` | Store facts from conversations automatically |
| `defaultScope` | `string` | `"project"` | Default memory scope: `project`, `session`, or `global` |
@@ -147,7 +151,7 @@ You: What do you know about my preferences?
## Troubleshooting
- **"No API key found"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`. If empty, add it to your shell profile (see Prerequisites)
- **"No API key found"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`. If empty, add it to your shell profile (see Prerequisites) or run `mem0 init`
- **Extension not loading**: Check Pi startup output for errors. For a source checkout, run `pnpm build`, then `pi -e ./dist/entry.js` from the plugin directory
- **Memories not capturing**: Verify `autoCapture` is `true` (default). Check `/mem0-status` for connection health
- **Wrong project detected**: The plugin uses the git repository root as `app_id`. If not in a git repo, it falls back to the working directory name. Run `/mem0-status` to see the detected project
@@ -378,30 +378,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +468,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -0,0 +1,11 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
export function mem0CliApiKey(homeDir: string): string {
try {
const key = JSON.parse(readFileSync(join(homeDir, ".mem0", "config.json"), "utf8"))?.platform?.api_key;
return typeof key === "string" ? key.trim() : "";
} catch {
return "";
}
}
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { mem0CliApiKey } from "../src/credentials.ts";
function homeWithCliConfig(config: string): string {
const home = mkdtempSync(join(tmpdir(), "mem0-cli-home-"));
mkdirSync(join(home, ".mem0"));
writeFileSync(join(home, ".mem0", "config.json"), config);
return home;
}
test("reads the key mem0 init saved", () => {
const home = homeWithCliConfig(JSON.stringify({ platform: { api_key: " m0-cli-key\n" } }));
assert.equal(mem0CliApiKey(home), "m0-cli-key");
});
test("missing, malformed, or non-string config reads as no key", () => {
assert.equal(mem0CliApiKey(mkdtempSync(join(tmpdir(), "mem0-cli-home-"))), "");
assert.equal(mem0CliApiKey(homeWithCliConfig("{not json")), "");
assert.equal(mem0CliApiKey(homeWithCliConfig("null")), "");
assert.equal(mem0CliApiKey(homeWithCliConfig(JSON.stringify({ platform: { api_key: 42 } }))), "");
});
@@ -378,30 +378,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +468,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -378,30 +378,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +468,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -31,9 +31,13 @@ def isolated_env(tmp_path, monkeypatch):
monkeypatch.setenv("MEM0_CODE_DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("MEM0_CODE_USER_ID", "test-user")
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("PLUGIN_OPTION_API_KEY", raising=False)
monkeypatch.delenv("PLUGIN_OPTION_USER_ID", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_DATA", raising=False)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("USERPROFILE", str(tmp_path / "home"))
# The 0.2.x plugin exports these into every hooked shell; without this the
# suite fails for anyone running it inside a session with that plugin active.
monkeypatch.delenv("MEM0_PROJECT_ID", raising=False)
@@ -3750,6 +3754,71 @@ def test_stale_cached_api_key_is_cleared_when_config_is_removed(
assert memory_core.clear_stale_api_key_cache() is False
def _mem0_cli_init(home: Path, config: object) -> None:
(home / ".mem0").mkdir(parents=True, exist_ok=True)
(home / ".mem0" / "config.json").write_text(json.dumps(config), encoding="utf-8")
def test_api_key_falls_back_to_the_mem0_cli_config(isolated_env):
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": " m0-cli-key\n"}})
assert memory_core.api_key() == "m0-cli-key"
@pytest.mark.parametrize(
"config",
[{"platform": {}}, {"platform": "m0-oops"}, {"platform": {"api_key": 42}}, ["m0-list"]],
)
def test_malformed_mem0_cli_config_reads_as_no_key(isolated_env, config):
_mem0_cli_init(isolated_env / "home", config)
assert memory_core.api_key() == ""
def test_unreadable_mem0_cli_config_reads_as_no_key(isolated_env):
(isolated_env / "home" / ".mem0").mkdir(parents=True)
(isolated_env / "home" / ".mem0" / "config.json").write_text("{not json", encoding="utf-8")
assert memory_core.api_key() == ""
def test_plugin_configured_key_wins_over_the_mem0_cli_config(isolated_env, monkeypatch):
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
monkeypatch.setenv("PLUGIN_OPTION_API_KEY", "m0-plugin-key")
assert memory_core.cache_plugin_api_key() is True
assert memory_core.api_key() == "m0-plugin-key"
monkeypatch.delenv("PLUGIN_OPTION_API_KEY")
assert memory_core.api_key() == "m0-plugin-key"
def test_unexpanded_host_placeholder_is_never_used_as_the_api_key(isolated_env, monkeypatch):
monkeypatch.setenv("PLUGIN_OPTION_API_KEY", "${api_key}")
assert memory_core.cache_plugin_api_key() is False
assert not (isolated_env / "data" / "api-key").exists()
assert memory_core.api_key() == ""
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
assert memory_core.api_key() == "m0-cli-key"
def test_placeholder_cached_by_an_older_plugin_is_ignored(isolated_env):
(isolated_env / "data").mkdir()
(isolated_env / "data" / "api-key").write_text("${api_key}", encoding="utf-8")
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
assert memory_core.api_key() == "m0-cli-key"
def test_unexpanded_placeholder_plugin_options_fall_back(isolated_env, monkeypatch):
monkeypatch.setenv("PLUGIN_OPTION_USER_ID", "${user_id}")
monkeypatch.setenv("PLUGIN_OPTION_TOP_K", "${top_k}")
assert memory_core.user_id() == "test-user"
assert memory_core._plugin_option("top_k") == ""
def _big_batch_messages() -> list[dict[str, str]]:
return [
{"role": "user", "content": "A" * 20000},
@@ -24,6 +24,8 @@ def isolated_env(tmp_path, monkeypatch):
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.delenv("MEM0_API_URL", raising=False)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("USERPROFILE", str(tmp_path / "home"))
return tmp_path
+33 -29
View File
@@ -378,30 +378,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +468,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
+33 -29
View File
@@ -378,30 +378,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +468,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
+3 -1
View File
@@ -10,6 +10,7 @@
* tools registered via `ctx.tools.register(...)` are auto-unregistered when the
* plugin unmounts (Cordis revertible effects).
*/
import { homedir } from "node:os";
import type { Context } from "@deepseek-ai/cordis";
import type {} from "@deepseek-ai/dsh-agent";
import type { PromptAssembly } from "@deepseek-ai/dsh-system-prompt";
@@ -20,6 +21,7 @@ import { formatMemoryList, formatAddResult } from "./formatting.ts";
import { truncateOutput } from "./output.ts";
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
import { captureEvent, errorKind } from "./telemetry.ts";
import { mem0CliApiKey } from "../../agent-plugin-core/typescript/src/credentials.ts";
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
import {
USER_RECALL_HEADING,
@@ -94,7 +96,7 @@ const scopeParams = {
} as const;
export function apply(ctx: Context, config: Config): void {
const apiKey = config.apiKey ?? process.env.MEM0_API_KEY;
const apiKey = config.apiKey || process.env.MEM0_API_KEY || mem0CliApiKey(homedir());
if (!apiKey) {
throw new Error("deepseek-plugin: set config.apiKey or the MEM0_API_KEY env var");
}
@@ -1,12 +1,19 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Offline mock of the Mem0 SDK so these tests never touch the network.
const mockSearch = vi.fn();
const mockAdd = vi.fn();
const mockClientOptions = vi.fn();
vi.mock("mem0ai", () => ({
MemoryClient: class {
search = mockSearch;
add = mockAdd;
constructor(options: unknown) {
mockClientOptions(options);
}
},
}));
@@ -49,18 +56,24 @@ function applyAndCollectListeners(config: Config): Map<string, HarnessListener>
let savedKey: string | undefined;
let savedTelemetry: string | undefined;
let savedHome: string | undefined;
beforeEach(() => {
savedKey = process.env.MEM0_API_KEY;
savedTelemetry = process.env.MEM0_TELEMETRY;
savedHome = process.env.HOME;
process.env.MEM0_TELEMETRY = "false";
process.env.HOME = mkdtempSync(join(tmpdir(), "deepseek-home-"));
mockSearch.mockReset();
mockAdd.mockReset();
mockClientOptions.mockReset();
});
afterEach(() => {
if (savedKey === undefined) delete process.env.MEM0_API_KEY;
else process.env.MEM0_API_KEY = savedKey;
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = savedTelemetry;
});
@@ -71,6 +84,17 @@ describe("apply() config validation", () => {
expect(() => applyAndCollect({ userId: "u" } as Config)).toThrow(/apiKey|MEM0_API_KEY/);
});
it("falls back to the key mem0 init saved", () => {
delete process.env.MEM0_API_KEY;
const home = process.env.HOME as string;
mkdirSync(join(home, ".mem0"));
writeFileSync(join(home, ".mem0", "config.json"), JSON.stringify({ platform: { api_key: "m0-cli-key" } }));
applyAndCollect({ userId: "u" } as Config);
expect(mockClientOptions).toHaveBeenCalledWith({ apiKey: "m0-cli-key" });
});
it("throws when userId is missing", () => {
expect(() => applyAndCollect({ apiKey: "k", userId: "" } as Config)).toThrow(/userId/);
});
+33 -29
View File
@@ -378,30 +378,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +468,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -378,30 +378,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +468,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -18,5 +18,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -102,6 +102,16 @@ describe("resolveApiKey", () => {
expect(resolveApiKey({}, dir)).toBe("m0-later");
});
test("falls back to the key mem0 init saved", () => {
const dir = home();
mkdirSync(join(dir, ".mem0"));
writeFileSync(join(dir, ".mem0", "config.json"), JSON.stringify({platform: {api_key: "m0-cli-key"}}));
expect(resolveApiKey({}, dir)).toBe("m0-cli-key");
writeFileSync(join(dir, ".zshrc"), "export MEM0_API_KEY=m0-from-profile\n");
expect(resolveApiKey({}, dir)).toBe("m0-from-profile");
});
test("ignores unsupported files and invalid assignments", () => {
const dir = home();
writeFileSync(join(dir, ".env"), "MEM0_API_KEY=unsupported\n");
+2 -1
View File
@@ -1,6 +1,7 @@
import {readFileSync} from "fs";
import {homedir} from "os";
import {join} from "path";
import {mem0CliApiKey} from "../agent-plugin-core/typescript/src/credentials.ts";
const PROFILE_FILES = [".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".profile"];
@@ -26,5 +27,5 @@ export function resolveApiKey(env: NodeJS.ProcessEnv = process.env, homeDir = ho
}
}
return "";
return mem0CliApiKey(homeDir);
}
@@ -1,6 +1,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { mem0CliApiKey } from "../../../agent-plugin-core/typescript/src/credentials.ts";
import type { Mem0Config } from "../types.ts";
const AGENT_ROOT = path.join(os.homedir(), ".pi", "agent");
@@ -36,6 +37,9 @@ export function loadConfig(): Mem0Config {
if (process.env.MEM0_API_KEY) {
config.apiKey = process.env.MEM0_API_KEY;
}
if (!config.apiKey) {
config.apiKey = mem0CliApiKey(os.homedir());
}
if (process.env.MEM0_USER_ID) {
config.userId = process.env.MEM0_USER_ID;
}
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { loadConfig } from "../src/config/index.ts";
vi.mock("node:fs");
@@ -33,6 +34,17 @@ describe("loadConfig", () => {
expect(config.apiKey).toBe("");
});
it("falls back to the key mem0 init saved", () => {
delete process.env.MEM0_API_KEY;
vi.mocked(fs.readFileSync).mockImplementation((file) => {
if (String(file).endsWith(path.join(".mem0", "config.json"))) {
return JSON.stringify({ platform: { api_key: "m0-cli-key" } });
}
throw new Error("ENOENT");
});
expect(loadConfig().apiKey).toBe("m0-cli-key");
});
it("reads config file and merges with defaults", () => {
delete process.env.MEM0_API_KEY;
delete process.env.MEM0_USER_ID;