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> </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 ## Installation
**Option A: degit** (recommended): **Option A: degit** (recommended):
+1 -1
View File
@@ -166,7 +166,7 @@ claude plugin update mem0@mem0-plugins --scope user
| Problem | Fix | | 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. | | `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. | | 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. | | 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> </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 ## Installation
### Option A: Plugin Marketplace (Recommended) ### Option A: Plugin Marketplace (Recommended)
+5
View File
@@ -35,6 +35,10 @@ source ~/.bashrc
``` ```
</CodeGroup> </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> <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. 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> </Warning>
@@ -164,6 +168,7 @@ Captured prompts and responses retain their full redacted text without a per-mes
## Troubleshooting ## Troubleshooting
- **"Connection failed"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY` - **"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. - **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 - **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> </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 ## Try it locally
1. Build and pack the plugin: 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 | | 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 | | `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 | | `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) | | `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 | | 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`. | | Plugin changes do not appear | Run `/plugins reload`, then `/reload` or `/new`. |
| MCP server is disabled | Run `/plugins mcp enable mem0 mem0`, then `/reload`. | | 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. | | 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> </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 ## Installation
### Option A: Plugin Install (Recommended) ### Option A: Plugin Install (Recommended)
+6 -2
View File
@@ -40,6 +40,10 @@ source ~/.bashrc
``` ```
</CodeGroup> </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 ## Installation
```bash ```bash
@@ -68,7 +72,7 @@ For advanced settings, create `~/.pi/agent/mem0-config.json`:
| Key | Type | Default | Description | | 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 | | `userId` | `string` | `$MEM0_USER_ID` or `"default"` | User identity for memory scoping |
| `autoCapture` | `boolean` | `true` | Store facts from conversations automatically | | `autoCapture` | `boolean` | `true` | Store facts from conversations automatically |
| `defaultScope` | `string` | `"project"` | Default memory scope: `project`, `session`, or `global` | | `defaultScope` | `string` | `"project"` | Default memory scope: `project`, `session`, or `global` |
@@ -147,7 +151,7 @@ You: What do you know about my preferences?
## Troubleshooting ## 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 - **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 - **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 - **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())) 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: def api_key() -> str:
configured = ( configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
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 configured:
return configured return configured
try: 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: except OSError:
return "" cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool: def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage.""" """Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = ( configured = _first_env(*_PLUGIN_API_KEY_ENV)
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 not configured: if not configured:
return False return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool: def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone.""" """Drop the cached key file once every configured key source is gone."""
configured = ( if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
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:
return False return False
path = data_dir() / "api-key" path = data_dir() / "api-key"
if not path.exists(): 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: def _plugin_option(name: str, fallback: str = "") -> str:
return ( return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
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()
def user_id() -> str: 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 number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say 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 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 being created. Never report an auth failure as "no memories found". When the
reinstalling with `--config api_key=...` in that case. 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())) 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: def api_key() -> str:
configured = ( configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
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 configured:
return configured return configured
try: 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: except OSError:
return "" cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool: def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage.""" """Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = ( configured = _first_env(*_PLUGIN_API_KEY_ENV)
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 not configured: if not configured:
return False return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool: def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone.""" """Drop the cached key file once every configured key source is gone."""
configured = ( if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
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:
return False return False
path = data_dir() / "api-key" path = data_dir() / "api-key"
if not path.exists(): 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: def _plugin_option(name: str, fallback: str = "") -> str:
return ( return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
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()
def user_id() -> str: 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 number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say 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 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 being created. Never report an auth failure as "no memories found". When the
reinstalling with `--config api_key=...` in that case. 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())) 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: def api_key() -> str:
configured = ( configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
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 configured:
return configured return configured
try: 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: except OSError:
return "" cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool: def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage.""" """Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = ( configured = _first_env(*_PLUGIN_API_KEY_ENV)
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 not configured: if not configured:
return False return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool: def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone.""" """Drop the cached key file once every configured key source is gone."""
configured = ( if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
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:
return False return False
path = data_dir() / "api-key" path = data_dir() / "api-key"
if not path.exists(): 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: def _plugin_option(name: str, fallback: str = "") -> str:
return ( return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
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()
def user_id() -> str: 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 number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say 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 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 being created. Never report an auth failure as "no memories found". When the
reinstalling with `--config api_key=...` in that case. 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_DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("MEM0_CODE_USER_ID", "test-user") monkeypatch.setenv("MEM0_CODE_USER_ID", "test-user")
monkeypatch.delenv("MEM0_API_KEY", raising=False) 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_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_DATA", 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 # 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. # suite fails for anyone running it inside a session with that plugin active.
monkeypatch.delenv("MEM0_PROJECT_ID", raising=False) 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 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]]: def _big_batch_messages() -> list[dict[str, str]]:
return [ return [
{"role": "user", "content": "A" * 20000}, {"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_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.delenv("MEM0_API_URL", 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 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())) 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: def api_key() -> str:
configured = ( configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
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 configured:
return configured return configured
try: 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: except OSError:
return "" cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool: def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage.""" """Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = ( configured = _first_env(*_PLUGIN_API_KEY_ENV)
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 not configured: if not configured:
return False return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool: def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone.""" """Drop the cached key file once every configured key source is gone."""
configured = ( if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
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:
return False return False
path = data_dir() / "api-key" path = data_dir() / "api-key"
if not path.exists(): 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: def _plugin_option(name: str, fallback: str = "") -> str:
return ( return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
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()
def user_id() -> str: 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 number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say 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 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 being created. Never report an auth failure as "no memories found". When the
reinstalling with `--config api_key=...` in that case. 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())) 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: def api_key() -> str:
configured = ( configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
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 configured:
return configured return configured
try: 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: except OSError:
return "" cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool: def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage.""" """Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = ( configured = _first_env(*_PLUGIN_API_KEY_ENV)
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 not configured: if not configured:
return False return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool: def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone.""" """Drop the cached key file once every configured key source is gone."""
configured = ( if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
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:
return False return False
path = data_dir() / "api-key" path = data_dir() / "api-key"
if not path.exists(): 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: def _plugin_option(name: str, fallback: str = "") -> str:
return ( return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
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()
def user_id() -> str: 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 number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say 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 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 being created. Never report an auth failure as "no memories found". When the
reinstalling with `--config api_key=...` in that case. 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 * tools registered via `ctx.tools.register(...)` are auto-unregistered when the
* plugin unmounts (Cordis revertible effects). * plugin unmounts (Cordis revertible effects).
*/ */
import { homedir } from "node:os";
import type { Context } from "@deepseek-ai/cordis"; import type { Context } from "@deepseek-ai/cordis";
import type {} from "@deepseek-ai/dsh-agent"; import type {} from "@deepseek-ai/dsh-agent";
import type { PromptAssembly } from "@deepseek-ai/dsh-system-prompt"; 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 { truncateOutput } from "./output.ts";
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts"; import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
import { captureEvent, errorKind } from "./telemetry.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 { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
import { import {
USER_RECALL_HEADING, USER_RECALL_HEADING,
@@ -94,7 +96,7 @@ const scopeParams = {
} as const; } as const;
export function apply(ctx: Context, config: Config): void { 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) { if (!apiKey) {
throw new Error("deepseek-plugin: set config.apiKey or the MEM0_API_KEY env var"); 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"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Offline mock of the Mem0 SDK so these tests never touch the network. // Offline mock of the Mem0 SDK so these tests never touch the network.
const mockSearch = vi.fn(); const mockSearch = vi.fn();
const mockAdd = vi.fn(); const mockAdd = vi.fn();
const mockClientOptions = vi.fn();
vi.mock("mem0ai", () => ({ vi.mock("mem0ai", () => ({
MemoryClient: class { MemoryClient: class {
search = mockSearch; search = mockSearch;
add = mockAdd; add = mockAdd;
constructor(options: unknown) {
mockClientOptions(options);
}
}, },
})); }));
@@ -49,18 +56,24 @@ function applyAndCollectListeners(config: Config): Map<string, HarnessListener>
let savedKey: string | undefined; let savedKey: string | undefined;
let savedTelemetry: string | undefined; let savedTelemetry: string | undefined;
let savedHome: string | undefined;
beforeEach(() => { beforeEach(() => {
savedKey = process.env.MEM0_API_KEY; savedKey = process.env.MEM0_API_KEY;
savedTelemetry = process.env.MEM0_TELEMETRY; savedTelemetry = process.env.MEM0_TELEMETRY;
savedHome = process.env.HOME;
process.env.MEM0_TELEMETRY = "false"; process.env.MEM0_TELEMETRY = "false";
process.env.HOME = mkdtempSync(join(tmpdir(), "deepseek-home-"));
mockSearch.mockReset(); mockSearch.mockReset();
mockAdd.mockReset(); mockAdd.mockReset();
mockClientOptions.mockReset();
}); });
afterEach(() => { afterEach(() => {
if (savedKey === undefined) delete process.env.MEM0_API_KEY; if (savedKey === undefined) delete process.env.MEM0_API_KEY;
else process.env.MEM0_API_KEY = savedKey; 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; if (savedTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = savedTelemetry; 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/); 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", () => { it("throws when userId is missing", () => {
expect(() => applyAndCollect({ apiKey: "k", userId: "" } as Config)).toThrow(/userId/); 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())) 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: def api_key() -> str:
configured = ( configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
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 configured:
return configured return configured
try: 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: except OSError:
return "" cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool: def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage.""" """Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = ( configured = _first_env(*_PLUGIN_API_KEY_ENV)
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 not configured: if not configured:
return False return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool: def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone.""" """Drop the cached key file once every configured key source is gone."""
configured = ( if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
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:
return False return False
path = data_dir() / "api-key" path = data_dir() / "api-key"
if not path.exists(): 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: def _plugin_option(name: str, fallback: str = "") -> str:
return ( return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
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()
def user_id() -> str: 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 number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say 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 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 being created. Never report an auth failure as "no memories found". When the
reinstalling with `--config api_key=...` in that case. 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())) 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: def api_key() -> str:
configured = ( configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
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 configured:
return configured return configured
try: 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: except OSError:
return "" cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool: def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage.""" """Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = ( configured = _first_env(*_PLUGIN_API_KEY_ENV)
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 not configured: if not configured:
return False return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool: def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone.""" """Drop the cached key file once every configured key source is gone."""
configured = ( if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
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:
return False return False
path = data_dir() / "api-key" path = data_dir() / "api-key"
if not path.exists(): 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: def _plugin_option(name: str, fallback: str = "") -> str:
return ( return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
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()
def user_id() -> str: 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 number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say 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 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 being created. Never report an auth failure as "no memories found". When the
reinstalling with `--config api_key=...` in that case. 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"); 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", () => { test("ignores unsupported files and invalid assignments", () => {
const dir = home(); const dir = home();
writeFileSync(join(dir, ".env"), "MEM0_API_KEY=unsupported\n"); writeFileSync(join(dir, ".env"), "MEM0_API_KEY=unsupported\n");
+2 -1
View File
@@ -1,6 +1,7 @@
import {readFileSync} from "fs"; import {readFileSync} from "fs";
import {homedir} from "os"; import {homedir} from "os";
import {join} from "path"; import {join} from "path";
import {mem0CliApiKey} from "../agent-plugin-core/typescript/src/credentials.ts";
const PROFILE_FILES = [".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".profile"]; 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 fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { mem0CliApiKey } from "../../../agent-plugin-core/typescript/src/credentials.ts";
import type { Mem0Config } from "../types.ts"; import type { Mem0Config } from "../types.ts";
const AGENT_ROOT = path.join(os.homedir(), ".pi", "agent"); const AGENT_ROOT = path.join(os.homedir(), ".pi", "agent");
@@ -36,6 +37,9 @@ export function loadConfig(): Mem0Config {
if (process.env.MEM0_API_KEY) { if (process.env.MEM0_API_KEY) {
config.apiKey = 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) { if (process.env.MEM0_USER_ID) {
config.userId = 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path";
import { loadConfig } from "../src/config/index.ts"; import { loadConfig } from "../src/config/index.ts";
vi.mock("node:fs"); vi.mock("node:fs");
@@ -33,6 +34,17 @@ describe("loadConfig", () => {
expect(config.apiKey).toBe(""); 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", () => { it("reads config file and merges with defaults", () => {
delete process.env.MEM0_API_KEY; delete process.env.MEM0_API_KEY;
delete process.env.MEM0_USER_ID; delete process.env.MEM0_USER_ID;