mem0-cli 0.2.4__tar.gz → 0.2.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/.gitignore +6 -2
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/PKG-INFO +1 -1
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/pyproject.toml +1 -1
- mem0_cli-0.2.5/src/mem0_cli/agent_detect.py +36 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/app.py +71 -5
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/backend/platform.py +21 -2
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/branding.py +5 -3
- mem0_cli-0.2.5/src/mem0_cli/commands/agent_mode_cmd.py +239 -0
- mem0_cli-0.2.5/src/mem0_cli/commands/identify_cmd.py +75 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/commands/init_cmd.py +160 -4
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/commands/utils.py +1 -1
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/config.py +31 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/output.py +19 -0
- mem0_cli-0.2.5/src/mem0_cli/plugin_sync.py +119 -0
- mem0_cli-0.2.5/src/mem0_cli/state.py +45 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/telemetry.py +4 -2
- mem0_cli-0.2.4/src/mem0_cli/state.py +0 -24
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/README.md +0 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/__init__.py +0 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/__main__.py +0 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/backend/__init__.py +0 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/backend/base.py +0 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/commands/__init__.py +0 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/commands/config_cmd.py +0 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/commands/entities.py +0 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/commands/events_cmd.py +0 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/commands/memory.py +0 -0
- {mem0_cli-0.2.4 → mem0_cli-0.2.5}/src/mem0_cli/telemetry_sender.py +0 -0
|
@@ -4,6 +4,10 @@ __pycache__/
|
|
|
4
4
|
*$py.class
|
|
5
5
|
**/node_modules/
|
|
6
6
|
|
|
7
|
+
# Self-hosted server local runtime state
|
|
8
|
+
server/history/
|
|
9
|
+
server/.env
|
|
10
|
+
|
|
7
11
|
# C extensions
|
|
8
12
|
*.so
|
|
9
13
|
|
|
@@ -15,8 +19,8 @@ dist/
|
|
|
15
19
|
downloads/
|
|
16
20
|
eggs/
|
|
17
21
|
.eggs/
|
|
18
|
-
lib/
|
|
19
|
-
lib64/
|
|
22
|
+
/lib/
|
|
23
|
+
/lib64/
|
|
20
24
|
parts/
|
|
21
25
|
sdist/
|
|
22
26
|
var/
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Detect whether the CLI is being invoked from inside an AI-agent context.
|
|
2
|
+
|
|
3
|
+
Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an
|
|
4
|
+
agent runtime env var is present. The return value is a context **trigger
|
|
5
|
+
only** — the canonical agent identity is self-declared by the agent via
|
|
6
|
+
``--agent-caller <name>`` (Proof Editor-style) and never sniffed from env
|
|
7
|
+
vars to fill the ``agent_caller`` field on the APIKey row.
|
|
8
|
+
|
|
9
|
+
Returns a short name or None. The list is curated, not exhaustive — env
|
|
10
|
+
vars we don't recognise fall through to None (caller treated as
|
|
11
|
+
non-agent). Honest reporting depends on ``--agent-caller``; this list is
|
|
12
|
+
just enough to enable the zero-friction auto-bootstrap UX.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
_AGENT_CALLER_ENV: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
20
|
+
("claude-code", ("CLAUDECODE", "CLAUDE_CODE")),
|
|
21
|
+
("cursor", ("CURSOR_AGENT", "CURSOR_SESSION_ID")),
|
|
22
|
+
("codex", ("CODEX_CLI", "OPENAI_CODEX")),
|
|
23
|
+
("cline", ("CLINE_AGENT", "CLINE")),
|
|
24
|
+
("continue", ("CONTINUE_AGENT", "CONTINUE_SESSION")),
|
|
25
|
+
("aider", ("AIDER_SESSION",)),
|
|
26
|
+
("goose", ("GOOSE_AGENT",)),
|
|
27
|
+
("windsurf", ("WINDSURF_AGENT",)),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def detect_agent_caller() -> str | None:
|
|
32
|
+
"""Return a canonical agent name if any agent env var is set, else None."""
|
|
33
|
+
for name, env_vars in _AGENT_CALLER_ENV:
|
|
34
|
+
if any(os.environ.get(v) for v in env_vars):
|
|
35
|
+
return name
|
|
36
|
+
return None
|
|
@@ -237,6 +237,14 @@ def main_callback(
|
|
|
237
237
|
cmd_version()
|
|
238
238
|
raise typer.Exit()
|
|
239
239
|
if ctx.invoked_subcommand:
|
|
240
|
+
# Stash the active subcommand name so the JSON error envelope
|
|
241
|
+
# (print_error in agent mode) can report which command failed
|
|
242
|
+
# instead of an empty `"command": ""` field.
|
|
243
|
+
from mem0_cli.state import set_current_command
|
|
244
|
+
|
|
245
|
+
set_current_command(ctx.invoked_subcommand)
|
|
246
|
+
if ctx.invoked_subcommand and ctx.invoked_subcommand != "init":
|
|
247
|
+
# init fires its own telemetry from init_cmd.run_init with full M1-M6 props.
|
|
240
248
|
_fire_telemetry(ctx.invoked_subcommand)
|
|
241
249
|
|
|
242
250
|
|
|
@@ -851,6 +859,19 @@ def init(
|
|
|
851
859
|
force: bool = typer.Option(
|
|
852
860
|
False, "--force", help="Overwrite existing config without confirmation."
|
|
853
861
|
),
|
|
862
|
+
agent_signal: bool = typer.Option(
|
|
863
|
+
False, "--agent", help="Bootstrap an unattended Agent Mode account (no email required)."
|
|
864
|
+
),
|
|
865
|
+
source: str | None = typer.Option(
|
|
866
|
+
None,
|
|
867
|
+
"--source",
|
|
868
|
+
help="Channel attribution for signup (e.g. github, hn, ph).",
|
|
869
|
+
),
|
|
870
|
+
agent_caller: str | None = typer.Option(
|
|
871
|
+
None,
|
|
872
|
+
"--agent-caller",
|
|
873
|
+
help="Self-declared agent identity (e.g. claude-code, cursor). Used with --agent to attribute Agent Mode signups.",
|
|
874
|
+
),
|
|
854
875
|
) -> None:
|
|
855
876
|
"""Interactive setup wizard for mem0 CLI.
|
|
856
877
|
|
|
@@ -859,10 +880,38 @@ def init(
|
|
|
859
880
|
mem0 init --api-key m0-xxx --user-id alice
|
|
860
881
|
mem0 init --email alice@company.com
|
|
861
882
|
mem0 init --email alice@company.com --code 482901
|
|
883
|
+
mem0 init --agent --agent-caller claude-code # AI agent self-identifies on Agent Mode bootstrap
|
|
884
|
+
mem0 init --email alice@company.com # Claims an existing Agent Mode key when one is present
|
|
862
885
|
"""
|
|
863
886
|
from mem0_cli.commands.init_cmd import run_init
|
|
864
887
|
|
|
865
|
-
run_init(
|
|
888
|
+
run_init(
|
|
889
|
+
api_key=api_key,
|
|
890
|
+
user_id=user_id,
|
|
891
|
+
email=email,
|
|
892
|
+
code=code,
|
|
893
|
+
force=force,
|
|
894
|
+
source=source,
|
|
895
|
+
agent=agent_signal,
|
|
896
|
+
agent_caller=agent_caller,
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@app.command(rich_help_panel="Setup")
|
|
901
|
+
def identify(
|
|
902
|
+
name: str = typer.Argument(..., help="Agent identity (e.g. claude-code, cursor, my-bot)."),
|
|
903
|
+
) -> None:
|
|
904
|
+
"""Tag your active Agent Mode key with the AI agent that's using it.
|
|
905
|
+
|
|
906
|
+
Run this once after `mem0 init --agent` if you didn't pass --agent-caller.
|
|
907
|
+
Idempotent — re-running just overwrites the value.
|
|
908
|
+
|
|
909
|
+
Example:
|
|
910
|
+
mem0 identify claude-code
|
|
911
|
+
"""
|
|
912
|
+
from mem0_cli.commands.identify_cmd import run_identify
|
|
913
|
+
|
|
914
|
+
run_identify(name)
|
|
866
915
|
|
|
867
916
|
|
|
868
917
|
# (entity_app registered at module level, below sub-group definitions)
|
|
@@ -1198,11 +1247,28 @@ def main() -> None:
|
|
|
1198
1247
|
import sys
|
|
1199
1248
|
|
|
1200
1249
|
# Allow --json/--agent anywhere in the command line (not just before subcommand).
|
|
1201
|
-
|
|
1202
|
-
|
|
1250
|
+
# Special case: `mem0 init --agent` is a subcommand flag (Agent Mode bootstrap)
|
|
1251
|
+
# consumed by init_cmd, not a global JSON-output toggle — leave it in argv.
|
|
1252
|
+
argv_rest = sys.argv[1:]
|
|
1253
|
+
is_init = "init" in argv_rest
|
|
1254
|
+
_global_flags = {"--json"} if is_init else {"--json", "--agent"}
|
|
1255
|
+
if any(a in _global_flags for a in argv_rest):
|
|
1203
1256
|
from mem0_cli.state import set_agent_mode
|
|
1204
1257
|
|
|
1205
1258
|
set_agent_mode(True)
|
|
1206
|
-
sys.argv = [sys.argv[0]] + [a for a in
|
|
1259
|
+
sys.argv = [sys.argv[0]] + [a for a in argv_rest if a not in _global_flags]
|
|
1207
1260
|
|
|
1208
|
-
|
|
1261
|
+
try:
|
|
1262
|
+
app()
|
|
1263
|
+
finally:
|
|
1264
|
+
# Surface any unclaimed Agent Mode notice once per command, after the
|
|
1265
|
+
# primary output. In JSON/agent mode the notice is folded into the
|
|
1266
|
+
# envelope by format_json_envelope, so skip the stderr banner there
|
|
1267
|
+
# to avoid duplicate output.
|
|
1268
|
+
from mem0_cli.state import is_agent_mode, take_notice
|
|
1269
|
+
|
|
1270
|
+
notice = take_notice()
|
|
1271
|
+
if notice and not is_agent_mode():
|
|
1272
|
+
from rich.console import Console
|
|
1273
|
+
|
|
1274
|
+
Console(stderr=True).print(f"\n[yellow]🔔 {notice}[/yellow]\n")
|
|
@@ -30,7 +30,7 @@ class PlatformBackend(Backend):
|
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
33
|
-
from mem0_cli.state import is_agent_mode
|
|
33
|
+
from mem0_cli.state import capture_notice, is_agent_mode
|
|
34
34
|
|
|
35
35
|
self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user"
|
|
36
36
|
resp = self._client.request(method, path, **kwargs)
|
|
@@ -48,7 +48,26 @@ class PlatformBackend(Backend):
|
|
|
48
48
|
resp.raise_for_status()
|
|
49
49
|
if resp.status_code == 204:
|
|
50
50
|
return {}
|
|
51
|
-
|
|
51
|
+
data = resp.json()
|
|
52
|
+
|
|
53
|
+
# Pull the unclaimed-Agent-Mode notice out of the body (or the header
|
|
54
|
+
# fallback for endpoints that return non-dict / non-dict-leading
|
|
55
|
+
# payloads) and stash it for end-of-command surfacing.
|
|
56
|
+
notice = None
|
|
57
|
+
if isinstance(data, dict) and "mem0_notice" in data:
|
|
58
|
+
notice = data.pop("mem0_notice")
|
|
59
|
+
elif (
|
|
60
|
+
isinstance(data, list)
|
|
61
|
+
and data
|
|
62
|
+
and isinstance(data[0], dict)
|
|
63
|
+
and "mem0_notice" in data[0]
|
|
64
|
+
):
|
|
65
|
+
notice = data[0].pop("mem0_notice")
|
|
66
|
+
if notice is None:
|
|
67
|
+
notice = resp.headers.get("X-Mem0-Notice-Message") or None
|
|
68
|
+
capture_notice(notice)
|
|
69
|
+
|
|
70
|
+
return data
|
|
52
71
|
|
|
53
72
|
def add(
|
|
54
73
|
self,
|
|
@@ -87,10 +87,12 @@ def print_error(console: Console, message: str, hint: str | None = None) -> None
|
|
|
87
87
|
}
|
|
88
88
|
print(_json.dumps(envelope))
|
|
89
89
|
return
|
|
90
|
+
from rich.markup import escape
|
|
91
|
+
|
|
90
92
|
sym = _sym("✗", "[error]")
|
|
91
|
-
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {message}")
|
|
93
|
+
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {escape(str(message))}")
|
|
92
94
|
if hint:
|
|
93
|
-
console.print(f" [{DIM_COLOR}]{hint}[/]")
|
|
95
|
+
console.print(f" [{DIM_COLOR}]{escape(str(hint))}[/]")
|
|
94
96
|
|
|
95
97
|
|
|
96
98
|
def print_warning(console: Console, message: str) -> None:
|
|
@@ -146,7 +148,7 @@ def timed_status(console: Console, message: str):
|
|
|
146
148
|
if "Authentication failed" in ctx.error_msg:
|
|
147
149
|
_err.print(
|
|
148
150
|
f" [{DIM_COLOR}]Run [bold]mem0 init[/bold] to reconfigure your API key"
|
|
149
|
-
f" · [bold]https://app.mem0.ai/dashboard/api-keys[/bold][/]"
|
|
151
|
+
f" · [bold]https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python[/bold][/]"
|
|
150
152
|
)
|
|
151
153
|
raise
|
|
152
154
|
else:
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Agent Mode commands — bootstrap (unattended signup) and claim (OTP-based human upgrade)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.prompt import Prompt
|
|
14
|
+
|
|
15
|
+
from mem0_cli.branding import (
|
|
16
|
+
BRAND_COLOR,
|
|
17
|
+
DIM_COLOR,
|
|
18
|
+
print_error,
|
|
19
|
+
print_success,
|
|
20
|
+
)
|
|
21
|
+
from mem0_cli.config import Mem0Config, save_config
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
err_console = Console(stderr=True)
|
|
25
|
+
|
|
26
|
+
_SOURCE_HEADERS = {
|
|
27
|
+
"X-Mem0-Source": "cli",
|
|
28
|
+
"X-Mem0-Client-Language": "python",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_envelope(envelope: Any) -> None:
|
|
33
|
+
"""Defend against partial/malformed backend responses.
|
|
34
|
+
|
|
35
|
+
A backend regression that returns ``{"api_key": null}`` would otherwise be
|
|
36
|
+
silently persisted, producing confusing downstream errors far from the
|
|
37
|
+
source. Fail fast with a clear message if the required fields are missing.
|
|
38
|
+
"""
|
|
39
|
+
if not isinstance(envelope, dict):
|
|
40
|
+
print_error(err_console, "Bootstrap response was not a JSON object.")
|
|
41
|
+
raise typer.Exit(1)
|
|
42
|
+
for field in ("api_key", "default_user_id"):
|
|
43
|
+
value = envelope.get(field)
|
|
44
|
+
if not isinstance(value, str) or not value:
|
|
45
|
+
print_error(
|
|
46
|
+
err_console,
|
|
47
|
+
f"Bootstrap response missing required field {field!r} — please update the CLI.",
|
|
48
|
+
)
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bootstrap_via_backend(
|
|
53
|
+
config: Mem0Config,
|
|
54
|
+
*,
|
|
55
|
+
source: str | None = None,
|
|
56
|
+
agent_caller: str | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""POST /api/v1/auth/agent_mode/ and mutate config in place.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
config: Mem0Config mutated in place with the new platform values.
|
|
62
|
+
source: ``--source`` flag passthrough (analytics tag, free-form).
|
|
63
|
+
agent_caller: Self-declared agent identity passed via ``--agent-caller``
|
|
64
|
+
(e.g. ``claude-code``, ``cursor``). May be None when the caller
|
|
65
|
+
omitted the flag; the agent can backfill later via
|
|
66
|
+
``mem0 identify <name>``. Sent to the backend in the request body
|
|
67
|
+
and saved into ``platform.agent_caller`` for local introspection.
|
|
68
|
+
|
|
69
|
+
Raises typer.Exit(1) on failure.
|
|
70
|
+
"""
|
|
71
|
+
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
|
72
|
+
body: dict[str, Any] = {}
|
|
73
|
+
if source:
|
|
74
|
+
body["source"] = source
|
|
75
|
+
if agent_caller:
|
|
76
|
+
body["agent_caller"] = agent_caller
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
with httpx.Client(timeout=30.0) as client:
|
|
80
|
+
resp = client.post(
|
|
81
|
+
f"{base_url}/api/v1/auth/agent_mode/",
|
|
82
|
+
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
|
83
|
+
json=body,
|
|
84
|
+
)
|
|
85
|
+
except httpx.HTTPError as exc:
|
|
86
|
+
print_error(err_console, f"Network error contacting Mem0: {exc}")
|
|
87
|
+
raise typer.Exit(1) from exc
|
|
88
|
+
|
|
89
|
+
if resp.status_code == 429:
|
|
90
|
+
print_error(err_console, "Rate-limited. Try again in a few minutes.")
|
|
91
|
+
raise typer.Exit(1)
|
|
92
|
+
if resp.status_code == 503:
|
|
93
|
+
print_error(err_console, "Agent Mode is temporarily disabled. Try again later.")
|
|
94
|
+
raise typer.Exit(1)
|
|
95
|
+
if resp.status_code != 200:
|
|
96
|
+
detail = resp.text
|
|
97
|
+
try:
|
|
98
|
+
err_body = resp.json()
|
|
99
|
+
detail = err_body.get("error") or err_body.get("detail") or resp.text
|
|
100
|
+
except (json.JSONDecodeError, ValueError, AttributeError):
|
|
101
|
+
pass
|
|
102
|
+
# Backend's @ratelimit decorator raises PermissionDenied, which DRF
|
|
103
|
+
# translates to a generic 403 "You do not have permission to perform
|
|
104
|
+
# this action." That's opaque — surface as the rate-limit it actually is.
|
|
105
|
+
if resp.status_code == 403 and "permission" in str(detail).lower():
|
|
106
|
+
print_error(
|
|
107
|
+
err_console,
|
|
108
|
+
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
|
|
109
|
+
)
|
|
110
|
+
raise typer.Exit(1)
|
|
111
|
+
print_error(err_console, f"Bootstrap failed: {detail}")
|
|
112
|
+
raise typer.Exit(1)
|
|
113
|
+
|
|
114
|
+
envelope = resp.json()
|
|
115
|
+
_validate_envelope(envelope)
|
|
116
|
+
config.platform.api_key = envelope["api_key"]
|
|
117
|
+
config.platform.base_url = base_url
|
|
118
|
+
config.platform.agent_mode = True
|
|
119
|
+
config.platform.created_via = "agent_mode"
|
|
120
|
+
config.platform.agent_caller = agent_caller or ""
|
|
121
|
+
config.platform.claimed_at = ""
|
|
122
|
+
config.platform.default_user_id = envelope["default_user_id"]
|
|
123
|
+
# Adopt the slug-derived user_id as the default scope for memory ops.
|
|
124
|
+
config.defaults.user_id = envelope["default_user_id"]
|
|
125
|
+
save_config(config)
|
|
126
|
+
|
|
127
|
+
print_success(console, f"Agent Mode active. Default user_id: {envelope['default_user_id']}")
|
|
128
|
+
notice = envelope.get("mem0_notice")
|
|
129
|
+
if notice:
|
|
130
|
+
console.print(f"\n[yellow]🔔 {notice}[/yellow]\n")
|
|
131
|
+
else:
|
|
132
|
+
# Fallback if the backend hasn't deployed the unified notice yet.
|
|
133
|
+
claim_cmd = envelope.get("claim_command", "mem0 init --email <your-email>")
|
|
134
|
+
console.print(f" [{DIM_COLOR}]To claim this account later: {claim_cmd}[/]")
|
|
135
|
+
|
|
136
|
+
if not agent_caller:
|
|
137
|
+
console.print(
|
|
138
|
+
f" [{DIM_COLOR}]If you are an AI agent, identify yourself: "
|
|
139
|
+
f"`mem0 identify <your-name>` (e.g. claude-code, cursor).[/]"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None:
|
|
144
|
+
"""Claim an existing Agent Mode account via OTP — no browser, no polling.
|
|
145
|
+
|
|
146
|
+
Reuses the standard email-code flow (`/api/v1/auth/email_code/` then
|
|
147
|
+
`/.../verify/`) and adds the local agent-mode API key in the verify body
|
|
148
|
+
as `agent_mode_api_key`. Backend's `verify_email_code` runs the
|
|
149
|
+
upgrade-in-place transaction inline and returns claim result.
|
|
150
|
+
|
|
151
|
+
On success: flips `platform.agent_mode=false`, sets `claimed_at`, stamps
|
|
152
|
+
`user_email`. The api_key value itself never changes.
|
|
153
|
+
"""
|
|
154
|
+
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
|
155
|
+
if not config.platform.api_key or not config.platform.agent_mode:
|
|
156
|
+
print_error(
|
|
157
|
+
err_console,
|
|
158
|
+
"This command requires an active Agent Mode config. Run `mem0 init` first.",
|
|
159
|
+
)
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
|
|
162
|
+
raw_key = config.platform.api_key
|
|
163
|
+
|
|
164
|
+
with httpx.Client(timeout=30.0) as client:
|
|
165
|
+
# Step 1: request OTP (unless --code provided)
|
|
166
|
+
if not code:
|
|
167
|
+
send = client.post(
|
|
168
|
+
f"{base_url}/api/v1/auth/email_code/",
|
|
169
|
+
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
|
170
|
+
json={"email": email},
|
|
171
|
+
)
|
|
172
|
+
if send.status_code == 429:
|
|
173
|
+
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
|
174
|
+
raise typer.Exit(1)
|
|
175
|
+
if send.status_code != 200:
|
|
176
|
+
try:
|
|
177
|
+
detail = send.json().get("error", send.text)
|
|
178
|
+
except Exception:
|
|
179
|
+
detail = send.text
|
|
180
|
+
print_error(err_console, f"Failed to send code: {detail}")
|
|
181
|
+
raise typer.Exit(1)
|
|
182
|
+
|
|
183
|
+
print_success(console, f"Verification code sent to {email}. Check your inbox.")
|
|
184
|
+
|
|
185
|
+
if not sys.stdin.isatty():
|
|
186
|
+
print_error(
|
|
187
|
+
err_console,
|
|
188
|
+
"No --code provided and terminal is non-interactive.",
|
|
189
|
+
hint=f"Re-run: mem0 init --email {email} --code <code>",
|
|
190
|
+
)
|
|
191
|
+
raise typer.Exit(1)
|
|
192
|
+
|
|
193
|
+
console.print()
|
|
194
|
+
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
|
|
195
|
+
if not code:
|
|
196
|
+
print_error(err_console, "Code is required.")
|
|
197
|
+
raise typer.Exit(1)
|
|
198
|
+
|
|
199
|
+
# Step 2: verify + claim in one shot
|
|
200
|
+
verify = client.post(
|
|
201
|
+
f"{base_url}/api/v1/auth/email_code/verify/",
|
|
202
|
+
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
|
203
|
+
json={
|
|
204
|
+
"email": email,
|
|
205
|
+
"code": code.strip(),
|
|
206
|
+
"agent_mode_api_key": raw_key,
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if verify.status_code != 200:
|
|
211
|
+
try:
|
|
212
|
+
err_body = verify.json()
|
|
213
|
+
detail = err_body.get("error", verify.text)
|
|
214
|
+
code_str = err_body.get("code", "")
|
|
215
|
+
except (json.JSONDecodeError, ValueError, AttributeError):
|
|
216
|
+
detail = verify.text
|
|
217
|
+
code_str = ""
|
|
218
|
+
print_error(err_console, f"Claim failed: {detail}")
|
|
219
|
+
if code_str == "email_already_claimed":
|
|
220
|
+
console.print(
|
|
221
|
+
f" [{DIM_COLOR}]Tip: this email already has a Mem0 account. Sign in there and run `mem0 link <key>` to attach this agent.[/]"
|
|
222
|
+
)
|
|
223
|
+
raise typer.Exit(1)
|
|
224
|
+
|
|
225
|
+
claim_body = verify.json()
|
|
226
|
+
if not claim_body.get("claimed"):
|
|
227
|
+
print_error(err_console, f"Unexpected verify response: {claim_body}")
|
|
228
|
+
raise typer.Exit(1)
|
|
229
|
+
|
|
230
|
+
config.platform.agent_mode = False
|
|
231
|
+
config.platform.claimed_at = claim_body.get("claimed_at") or _utcnow_iso()
|
|
232
|
+
config.platform.user_email = email
|
|
233
|
+
config.platform.created_via = "email"
|
|
234
|
+
save_config(config)
|
|
235
|
+
print_success(console, f"Agent claimed to {email}. Your API key is unchanged.")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _utcnow_iso() -> str:
|
|
239
|
+
return datetime.now(timezone.utc).isoformat()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""mem0 identify — declare which agent owns the current agent-mode key.
|
|
2
|
+
|
|
3
|
+
Used when `mem0 init --agent` ran without --agent-caller, so the backend
|
|
4
|
+
saved agent_caller=NULL. The agent re-runs `mem0 identify <name>` to PATCH
|
|
5
|
+
its own row with its real identity. Idempotent — running it again just
|
|
6
|
+
overwrites.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from mem0_cli.branding import print_error, print_success
|
|
16
|
+
from mem0_cli.config import load_config, save_config
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
err_console = Console(stderr=True)
|
|
20
|
+
|
|
21
|
+
_SOURCE_HEADERS = {
|
|
22
|
+
"X-Mem0-Source": "cli",
|
|
23
|
+
"X-Mem0-Client-Language": "python",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_identify(name: str) -> None:
|
|
28
|
+
"""PATCH the active agent-mode key's agent_caller field."""
|
|
29
|
+
config = load_config()
|
|
30
|
+
if not config.platform.api_key:
|
|
31
|
+
print_error(
|
|
32
|
+
err_console,
|
|
33
|
+
"No API key configured. Run `mem0 init --agent` first.",
|
|
34
|
+
)
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
if not config.platform.agent_mode:
|
|
37
|
+
print_error(
|
|
38
|
+
err_console,
|
|
39
|
+
"This command only works on unclaimed agent-mode keys.",
|
|
40
|
+
)
|
|
41
|
+
raise typer.Exit(1)
|
|
42
|
+
|
|
43
|
+
name = (name or "").strip()
|
|
44
|
+
if not name:
|
|
45
|
+
print_error(err_console, "Agent name is required.")
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
|
|
48
|
+
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
|
49
|
+
try:
|
|
50
|
+
with httpx.Client(timeout=30.0) as client:
|
|
51
|
+
resp = client.patch(
|
|
52
|
+
f"{base_url}/api/v1/auth/agent_mode/caller/",
|
|
53
|
+
headers={
|
|
54
|
+
**_SOURCE_HEADERS,
|
|
55
|
+
"Authorization": f"Token {config.platform.api_key}",
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
},
|
|
58
|
+
json={"agent_caller": name},
|
|
59
|
+
)
|
|
60
|
+
except httpx.HTTPError as exc:
|
|
61
|
+
print_error(err_console, f"Network error: {exc}")
|
|
62
|
+
raise typer.Exit(1) from exc
|
|
63
|
+
|
|
64
|
+
if resp.status_code != 200:
|
|
65
|
+
try:
|
|
66
|
+
detail = resp.json().get("error", resp.text)
|
|
67
|
+
except Exception:
|
|
68
|
+
detail = resp.text
|
|
69
|
+
print_error(err_console, f"Identify failed: {detail}")
|
|
70
|
+
raise typer.Exit(1)
|
|
71
|
+
|
|
72
|
+
canonical = resp.json().get("agent_caller", name)
|
|
73
|
+
config.platform.agent_caller = canonical
|
|
74
|
+
save_config(config)
|
|
75
|
+
print_success(console, f"Identified as {canonical}.")
|
|
@@ -19,7 +19,13 @@ from mem0_cli.branding import (
|
|
|
19
19
|
print_info,
|
|
20
20
|
print_success,
|
|
21
21
|
)
|
|
22
|
-
from mem0_cli.config import
|
|
22
|
+
from mem0_cli.config import (
|
|
23
|
+
CONFIG_FILE,
|
|
24
|
+
DEFAULT_BASE_URL,
|
|
25
|
+
Mem0Config,
|
|
26
|
+
load_config,
|
|
27
|
+
save_config,
|
|
28
|
+
)
|
|
23
29
|
|
|
24
30
|
console = Console()
|
|
25
31
|
err_console = Console(stderr=True)
|
|
@@ -97,6 +103,25 @@ def _validate_email(email: str) -> None:
|
|
|
97
103
|
raise typer.Exit(1)
|
|
98
104
|
|
|
99
105
|
|
|
106
|
+
def _ping_key(api_key: str, base_url: str, timeout: float = 5.0) -> bool:
|
|
107
|
+
"""Validate api_key against /v1/ping/.
|
|
108
|
+
|
|
109
|
+
Returns False ONLY on a definitive "invalid key" signal (HTTP 401 / 403).
|
|
110
|
+
Network errors, timeouts, and 5xx responses return True so we prefer
|
|
111
|
+
reusing an existing key over silently minting a new shadow on a transient
|
|
112
|
+
blip (which would also clobber config + plugin-sync targets).
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
resp = httpx.get(
|
|
116
|
+
f"{base_url.rstrip('/')}/v1/ping/",
|
|
117
|
+
headers={"Authorization": f"Token {api_key}"},
|
|
118
|
+
timeout=timeout,
|
|
119
|
+
)
|
|
120
|
+
except httpx.HTTPError:
|
|
121
|
+
return True # unknown — prefer reuse
|
|
122
|
+
return resp.status_code not in (401, 403)
|
|
123
|
+
|
|
124
|
+
|
|
100
125
|
def _email_login(
|
|
101
126
|
email: str,
|
|
102
127
|
code: str | None,
|
|
@@ -176,21 +201,143 @@ def run_init(
|
|
|
176
201
|
email: str | None = None,
|
|
177
202
|
code: str | None = None,
|
|
178
203
|
force: bool = False,
|
|
204
|
+
source: str | None = None,
|
|
205
|
+
agent: bool = False,
|
|
206
|
+
agent_caller: str | None = None,
|
|
179
207
|
) -> None:
|
|
180
208
|
"""Interactive setup wizard for mem0 CLI.
|
|
181
209
|
|
|
182
210
|
When both *api_key* and *user_id* are supplied, all prompts are skipped
|
|
183
211
|
(non-interactive mode). When running in a non-TTY without the required
|
|
184
212
|
flags, an error message is printed.
|
|
213
|
+
|
|
214
|
+
Agent Mode dispatch (no email/api-key flags):
|
|
215
|
+
- If existing config has an active API key → reuse (existing_key path).
|
|
216
|
+
- Else if any positive agent signal (--agent, --json global, agent env
|
|
217
|
+
var, or `agent` flag) → POST /api/v1/auth/agent_mode/ and write config.
|
|
218
|
+
- Else fall through to the interactive wizard.
|
|
219
|
+
|
|
220
|
+
Claim dispatch:
|
|
221
|
+
- If `--email` is set AND existing config has `agent_mode=true`, run the
|
|
222
|
+
claim device-flow against the existing key instead of minting a new
|
|
223
|
+
email-based key.
|
|
185
224
|
"""
|
|
225
|
+
from mem0_cli.agent_detect import detect_agent_caller
|
|
226
|
+
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend, claim_via_otp
|
|
227
|
+
from mem0_cli.state import is_agent_mode as _global_agent_mode
|
|
228
|
+
from mem0_cli.telemetry import capture_event
|
|
229
|
+
|
|
230
|
+
def _fire_init(mode: str, *, claimed: bool = False) -> None:
|
|
231
|
+
"""Fire cli.init telemetry with M1-M6 properties."""
|
|
232
|
+
props: dict = {"command": "init", "mode": mode}
|
|
233
|
+
if agent_caller:
|
|
234
|
+
# Self-declared via --agent-caller; not sniffed from env vars.
|
|
235
|
+
props["agent_caller"] = agent_caller
|
|
236
|
+
if source:
|
|
237
|
+
props["signup_source"] = source
|
|
238
|
+
if claimed:
|
|
239
|
+
props["claimed_agent_mode"] = True
|
|
240
|
+
capture_event("cli.init", props)
|
|
241
|
+
|
|
186
242
|
config = Mem0Config()
|
|
187
243
|
|
|
188
244
|
base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL)
|
|
245
|
+
config.platform.base_url = base_url
|
|
189
246
|
|
|
190
247
|
if code and not email:
|
|
191
248
|
print_error(err_console, "--code requires --email.")
|
|
192
249
|
raise typer.Exit(1)
|
|
193
250
|
|
|
251
|
+
# ── Email + existing agent-mode config → claim flow ─────────────────
|
|
252
|
+
if email and CONFIG_FILE.exists():
|
|
253
|
+
existing = load_config()
|
|
254
|
+
if existing.platform.agent_mode and existing.platform.api_key:
|
|
255
|
+
email = email.strip().lower()
|
|
256
|
+
_validate_email(email)
|
|
257
|
+
print_info(console, f"Claiming Agent Mode account to {email}...")
|
|
258
|
+
claim_via_otp(existing, email=email, code=code)
|
|
259
|
+
_fire_init("email", claimed=True)
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
# ── Agent Mode path runs BEFORE the existing-config guard ──────────
|
|
263
|
+
# Rules 1/2 REUSE a valid existing key (not overwrite), so we must
|
|
264
|
+
# short-circuit before the guard prompts. Rule 3 mints only when there
|
|
265
|
+
# is no valid key to reuse — in that case overwriting is correct.
|
|
266
|
+
_agent_ctx = agent or _global_agent_mode() or (detect_agent_caller() is not None)
|
|
267
|
+
if not api_key and not email and _agent_ctx:
|
|
268
|
+
from mem0_cli.output import format_json_envelope
|
|
269
|
+
from mem0_cli.state import is_agent_mode as _is_json_mode
|
|
270
|
+
|
|
271
|
+
def _emit_reuse(source: str) -> None:
|
|
272
|
+
if _is_json_mode():
|
|
273
|
+
format_json_envelope(
|
|
274
|
+
console,
|
|
275
|
+
command="init",
|
|
276
|
+
data={
|
|
277
|
+
"api_key_saved": False,
|
|
278
|
+
"api_key_source": source,
|
|
279
|
+
"agent_mode": False,
|
|
280
|
+
"message": "Existing Mem0 API key found and reused. No Agent Mode key was created.",
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
msg = (
|
|
285
|
+
"Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted."
|
|
286
|
+
if source == "env"
|
|
287
|
+
else "Existing API key in config is valid; reusing it. No new Agent Mode key was minted."
|
|
288
|
+
)
|
|
289
|
+
print_success(console, msg)
|
|
290
|
+
|
|
291
|
+
def _maybe_identify(key: str) -> None:
|
|
292
|
+
"""Best-effort PATCH agent_caller when --agent-caller is supplied on a
|
|
293
|
+
reused key. Silent no-op on any failure — reuse must not break.
|
|
294
|
+
"""
|
|
295
|
+
if not agent_caller:
|
|
296
|
+
return
|
|
297
|
+
try:
|
|
298
|
+
resp = httpx.patch(
|
|
299
|
+
f"{base_url.rstrip('/')}/api/v1/auth/agent_mode/caller/",
|
|
300
|
+
headers={
|
|
301
|
+
"Authorization": f"Token {key}",
|
|
302
|
+
"Content-Type": "application/json",
|
|
303
|
+
},
|
|
304
|
+
json={"agent_caller": agent_caller},
|
|
305
|
+
timeout=10.0,
|
|
306
|
+
)
|
|
307
|
+
# Also reflect in local config so introspection matches backend.
|
|
308
|
+
if resp.status_code == 200 and CONFIG_FILE.exists():
|
|
309
|
+
try:
|
|
310
|
+
cfg = load_config()
|
|
311
|
+
cfg.platform.agent_caller = resp.json().get("agent_caller", agent_caller)
|
|
312
|
+
save_config(cfg)
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
except httpx.HTTPError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
# Rule 1: env MEM0_API_KEY valid → reuse, no new key.
|
|
319
|
+
_env_key = (os.environ.get("MEM0_API_KEY") or "").strip()
|
|
320
|
+
if _env_key and _ping_key(_env_key, base_url):
|
|
321
|
+
_maybe_identify(_env_key)
|
|
322
|
+
_emit_reuse("env")
|
|
323
|
+
_fire_init("existing_key")
|
|
324
|
+
return
|
|
325
|
+
# Rule 2: existing config api_key valid → reuse.
|
|
326
|
+
if CONFIG_FILE.exists():
|
|
327
|
+
_existing = load_config()
|
|
328
|
+
if _existing.platform.api_key and _ping_key(_existing.platform.api_key, base_url):
|
|
329
|
+
_maybe_identify(_existing.platform.api_key)
|
|
330
|
+
_emit_reuse("config")
|
|
331
|
+
_fire_init("existing_key")
|
|
332
|
+
return
|
|
333
|
+
# Rule 3: mint a fresh shadow (no valid key to reuse).
|
|
334
|
+
# agent_caller is the agent's self-declared identity from --agent-caller
|
|
335
|
+
# (Proof Editor-style). Env-var auto-detect is still used above to
|
|
336
|
+
# decide we're in an agent context, but never to fill identity.
|
|
337
|
+
bootstrap_via_backend(config, source=source, agent_caller=agent_caller)
|
|
338
|
+
_fire_init("agent")
|
|
339
|
+
return
|
|
340
|
+
|
|
194
341
|
# Warn if an existing config with an API key would be overwritten
|
|
195
342
|
if not force and CONFIG_FILE.exists():
|
|
196
343
|
existing = load_config()
|
|
@@ -236,6 +383,7 @@ def run_init(
|
|
|
236
383
|
config.platform.api_key = api_key_val
|
|
237
384
|
config.platform.base_url = base_url
|
|
238
385
|
config.platform.user_email = email
|
|
386
|
+
config.platform.created_via = "email"
|
|
239
387
|
config.defaults.user_id = (
|
|
240
388
|
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
|
241
389
|
)
|
|
@@ -252,6 +400,8 @@ def run_init(
|
|
|
252
400
|
return
|
|
253
401
|
|
|
254
402
|
# ── API key flow (existing) ───────────────────────────────────────
|
|
403
|
+
# (Agent Mode branch runs earlier — see above, before the existing-config
|
|
404
|
+
# guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.)
|
|
255
405
|
|
|
256
406
|
# Non-TTY: resolve defaults so partial flags work in pipelines / CI
|
|
257
407
|
if not sys.stdin.isatty():
|
|
@@ -259,7 +409,7 @@ def run_init(
|
|
|
259
409
|
print_error(
|
|
260
410
|
err_console,
|
|
261
411
|
"Non-interactive terminal detected and --api-key is required.",
|
|
262
|
-
hint="Run: mem0 init --api-key <key
|
|
412
|
+
hint="Run: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
|
|
263
413
|
)
|
|
264
414
|
raise typer.Exit(1)
|
|
265
415
|
user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
|
@@ -267,6 +417,7 @@ def run_init(
|
|
|
267
417
|
# Fully non-interactive when both flags provided
|
|
268
418
|
if api_key and user_id:
|
|
269
419
|
config.platform.api_key = api_key
|
|
420
|
+
config.platform.created_via = "api_key"
|
|
270
421
|
config.defaults.user_id = user_id
|
|
271
422
|
_validate_platform(config)
|
|
272
423
|
save_config(config)
|
|
@@ -307,6 +458,7 @@ def run_init(
|
|
|
307
458
|
config.platform.api_key = api_key_val
|
|
308
459
|
config.platform.base_url = base_url
|
|
309
460
|
config.platform.user_email = email_addr
|
|
461
|
+
config.platform.created_via = "email"
|
|
310
462
|
config.defaults.user_id = (
|
|
311
463
|
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
|
312
464
|
)
|
|
@@ -325,6 +477,7 @@ def run_init(
|
|
|
325
477
|
# API key flow
|
|
326
478
|
if api_key:
|
|
327
479
|
config.platform.api_key = api_key
|
|
480
|
+
config.platform.created_via = "api_key"
|
|
328
481
|
else:
|
|
329
482
|
_setup_platform(config)
|
|
330
483
|
|
|
@@ -352,7 +505,9 @@ def run_init(
|
|
|
352
505
|
def _setup_platform(config: Mem0Config) -> None:
|
|
353
506
|
"""Platform setup flow."""
|
|
354
507
|
console.print()
|
|
355
|
-
console.print(
|
|
508
|
+
console.print(
|
|
509
|
+
f" [{DIM_COLOR}]Get your API key at https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python[/]"
|
|
510
|
+
)
|
|
356
511
|
console.print()
|
|
357
512
|
|
|
358
513
|
console.print(f" [{BRAND_COLOR}]API Key[/]: ", end="")
|
|
@@ -362,6 +517,7 @@ def _setup_platform(config: Mem0Config) -> None:
|
|
|
362
517
|
raise typer.Exit(1)
|
|
363
518
|
|
|
364
519
|
config.platform.api_key = api_key
|
|
520
|
+
config.platform.created_via = "api_key"
|
|
365
521
|
|
|
366
522
|
|
|
367
523
|
def _setup_defaults(config: Mem0Config) -> None:
|
|
@@ -404,7 +560,7 @@ def _validate_platform(config: Mem0Config) -> None:
|
|
|
404
560
|
print_error(
|
|
405
561
|
err_console,
|
|
406
562
|
f"Could not connect: {status.get('error', 'Unknown error')}",
|
|
407
|
-
hint="Visit https://app.mem0.ai/dashboard/api-keys to get a new key, then run mem0 init again.",
|
|
563
|
+
hint="Visit https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python to get a new key, then run mem0 init again.",
|
|
408
564
|
)
|
|
409
565
|
except Exception as e:
|
|
410
566
|
print_error(err_console, f"Connection test failed: {e}")
|
|
@@ -77,7 +77,7 @@ def cmd_status(
|
|
|
77
77
|
f" [{DIM_COLOR}]Run [bold]mem0 init[/bold] to reconfigure your API key[/]"
|
|
78
78
|
)
|
|
79
79
|
lines.append(
|
|
80
|
-
f" [{DIM_COLOR}]Get a key at [bold]https://app.mem0.ai/dashboard/api-keys[/bold][/]"
|
|
80
|
+
f" [{DIM_COLOR}]Get a key at [bold]https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python[/bold][/]"
|
|
81
81
|
)
|
|
82
82
|
lines.append(f" [{DIM_COLOR}]Latency:[/] {_elapsed:.2f}s")
|
|
83
83
|
|
|
@@ -28,6 +28,14 @@ class PlatformConfig:
|
|
|
28
28
|
api_key: str = ""
|
|
29
29
|
base_url: str = DEFAULT_BASE_URL
|
|
30
30
|
user_email: str = ""
|
|
31
|
+
# Agent Mode (unclaimed-shadow signup)
|
|
32
|
+
agent_mode: bool = False # True while the key is an unclaimed agent-mode key
|
|
33
|
+
created_via: str = "" # "agent_mode" | "email" | "api_key" | "existing_key"
|
|
34
|
+
agent_caller: str = (
|
|
35
|
+
"" # canonical agent name when created_via == "agent_mode" (e.g. "claude-code")
|
|
36
|
+
)
|
|
37
|
+
claimed_at: str = "" # ISO timestamp once the agent has been claimed by a human
|
|
38
|
+
default_user_id: str = "" # `user_<slug>` returned by bootstrap; used as auto-default
|
|
31
39
|
|
|
32
40
|
|
|
33
41
|
@dataclass
|
|
@@ -83,6 +91,11 @@ def load_config() -> Mem0Config:
|
|
|
83
91
|
config.platform.api_key = plat.get("api_key", "")
|
|
84
92
|
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
|
|
85
93
|
config.platform.user_email = plat.get("user_email", "")
|
|
94
|
+
config.platform.agent_mode = bool(plat.get("agent_mode", False))
|
|
95
|
+
config.platform.created_via = plat.get("created_via", "")
|
|
96
|
+
config.platform.agent_caller = plat.get("agent_caller", "")
|
|
97
|
+
config.platform.claimed_at = plat.get("claimed_at", "")
|
|
98
|
+
config.platform.default_user_id = plat.get("default_user_id", "")
|
|
86
99
|
|
|
87
100
|
defaults = data.get("defaults", {})
|
|
88
101
|
config.defaults.user_id = defaults.get("user_id", "")
|
|
@@ -136,6 +149,11 @@ def save_config(config: Mem0Config) -> None:
|
|
|
136
149
|
"api_key": config.platform.api_key,
|
|
137
150
|
"base_url": config.platform.base_url,
|
|
138
151
|
"user_email": config.platform.user_email,
|
|
152
|
+
"agent_mode": config.platform.agent_mode,
|
|
153
|
+
"created_via": config.platform.created_via,
|
|
154
|
+
"agent_caller": config.platform.agent_caller,
|
|
155
|
+
"claimed_at": config.platform.claimed_at,
|
|
156
|
+
"default_user_id": config.platform.default_user_id,
|
|
139
157
|
},
|
|
140
158
|
"telemetry": {
|
|
141
159
|
"anonymous_id": config.telemetry.anonymous_id,
|
|
@@ -147,6 +165,19 @@ def save_config(config: Mem0Config) -> None:
|
|
|
147
165
|
|
|
148
166
|
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
149
167
|
|
|
168
|
+
# Propagate the active api_key to ecosystem touchpoints (Claude Code
|
|
169
|
+
# plugin env injection, shell rc exports). Idempotent — only updates
|
|
170
|
+
# EXISTING entries; never creates new ones. Best-effort: any IOError
|
|
171
|
+
# in the sync is swallowed so config.json is always the authoritative
|
|
172
|
+
# write, never blocked by plugin-state issues.
|
|
173
|
+
if config.platform.api_key:
|
|
174
|
+
try:
|
|
175
|
+
from mem0_cli.plugin_sync import sync_api_key
|
|
176
|
+
|
|
177
|
+
sync_api_key(config.platform.api_key)
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
150
181
|
|
|
151
182
|
def redact_key(key: str) -> str:
|
|
152
183
|
"""Redact an API key for display: m0-xxx...xxx"""
|
|
@@ -229,6 +229,16 @@ def format_json_envelope(
|
|
|
229
229
|
if error:
|
|
230
230
|
envelope["error"] = error
|
|
231
231
|
envelope["data"] = data
|
|
232
|
+
|
|
233
|
+
# If the platform flagged this as an unclaimed Agent Mode account, surface
|
|
234
|
+
# the notice inside the JSON envelope so an agent consuming the output
|
|
235
|
+
# sees it without needing to inspect HTTP headers.
|
|
236
|
+
from mem0_cli.state import take_notice
|
|
237
|
+
|
|
238
|
+
notice = take_notice()
|
|
239
|
+
if notice:
|
|
240
|
+
envelope["mem0_notice"] = notice
|
|
241
|
+
|
|
232
242
|
console.print_json(json.dumps(envelope, default=str))
|
|
233
243
|
|
|
234
244
|
|
|
@@ -323,6 +333,15 @@ def format_agent_envelope(
|
|
|
323
333
|
if count is not None:
|
|
324
334
|
envelope["count"] = count
|
|
325
335
|
envelope["data"] = sanitize_agent_data(command, data)
|
|
336
|
+
|
|
337
|
+
# Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an
|
|
338
|
+
# agent reading the JSON output sees it without inspecting HTTP headers.
|
|
339
|
+
from mem0_cli.state import take_notice
|
|
340
|
+
|
|
341
|
+
notice = take_notice()
|
|
342
|
+
if notice:
|
|
343
|
+
envelope["mem0_notice"] = notice
|
|
344
|
+
|
|
326
345
|
console.print_json(json.dumps(envelope, default=str))
|
|
327
346
|
|
|
328
347
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Sync the active Mem0 API key into other ecosystem touchpoints.
|
|
2
|
+
|
|
3
|
+
Why this exists:
|
|
4
|
+
The CLI canonical state lives in ``~/.mem0/config.json``. But MCP servers
|
|
5
|
+
(Claude Code plugin, Codex plugin, etc.) read ``MEM0_API_KEY`` from env
|
|
6
|
+
vars or their own config files. Without a sync, an agent-mode bootstrap
|
|
7
|
+
mints a new key into config.json but the plugin's MCP keeps using the
|
|
8
|
+
old key from env — silent surprise.
|
|
9
|
+
|
|
10
|
+
Design:
|
|
11
|
+
- Update ONLY entries that already exist (never create new ones)
|
|
12
|
+
- Preserve all surrounding content / formatting / other keys
|
|
13
|
+
- Atomic writes (tmpfile + rename) so a crash mid-write doesn't corrupt
|
|
14
|
+
- Idempotent — re-running with the same key is a no-op
|
|
15
|
+
- Skip on dry_run
|
|
16
|
+
|
|
17
|
+
Targets currently handled:
|
|
18
|
+
- ``~/.claude/settings.json::env::MEM0_API_KEY`` (Claude Code env injection)
|
|
19
|
+
- ``~/.zshrc`` / ``~/.bashrc`` ``export MEM0_API_KEY="..."`` lines
|
|
20
|
+
|
|
21
|
+
Out of scope (deliberately not touched):
|
|
22
|
+
- Codex / Cursor MCP configs — would require schema-aware edits and
|
|
23
|
+
those tools don't have mem0 entries by default
|
|
24
|
+
- Plugin's own ``<plugin-dir>/.api_key`` file — plugin-managed
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import contextlib
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import tempfile
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
# Files we know how to update safely.
|
|
37
|
+
_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
38
|
+
_SHELL_RCS = [Path.home() / ".zshrc", Path.home() / ".bashrc", Path.home() / ".bash_profile"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sync_api_key(api_key: str) -> list[str]:
|
|
42
|
+
"""Propagate ``api_key`` into known ecosystem touchpoints.
|
|
43
|
+
|
|
44
|
+
Returns the list of paths actually updated. Empty list means nothing
|
|
45
|
+
needed updating (either targets didn't exist or already had this value).
|
|
46
|
+
"""
|
|
47
|
+
if not api_key:
|
|
48
|
+
return []
|
|
49
|
+
updated: list[str] = []
|
|
50
|
+
if _update_claude_settings(_CLAUDE_SETTINGS, api_key):
|
|
51
|
+
updated.append(str(_CLAUDE_SETTINGS))
|
|
52
|
+
for rc in _SHELL_RCS:
|
|
53
|
+
if _update_shell_rc(rc, api_key):
|
|
54
|
+
updated.append(str(rc))
|
|
55
|
+
return updated
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _update_claude_settings(path: Path, api_key: str) -> bool:
|
|
59
|
+
"""Update ``env.MEM0_API_KEY`` in path. Returns True if file was changed."""
|
|
60
|
+
if not path.is_file():
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
with path.open("r", encoding="utf-8") as f:
|
|
64
|
+
data = json.load(f)
|
|
65
|
+
except (json.JSONDecodeError, OSError):
|
|
66
|
+
return False
|
|
67
|
+
env = data.get("env")
|
|
68
|
+
if not isinstance(env, dict) or "MEM0_API_KEY" not in env:
|
|
69
|
+
# No existing entry — don't create one.
|
|
70
|
+
return False
|
|
71
|
+
if env["MEM0_API_KEY"] == api_key:
|
|
72
|
+
return False # already in sync
|
|
73
|
+
env["MEM0_API_KEY"] = api_key
|
|
74
|
+
_atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Match `export MEM0_API_KEY="..."` (or single quotes, or no quotes).
|
|
79
|
+
# Use [ \t]* (not \s*) for trailing whitespace so a trailing newline at
|
|
80
|
+
# end-of-file is preserved when MEM0_API_KEY is the last line.
|
|
81
|
+
_RC_LINE = re.compile(
|
|
82
|
+
r'^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["\']?)([^"\'\n]*)(["\']?)[ \t]*$',
|
|
83
|
+
re.MULTILINE,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _update_shell_rc(path: Path, api_key: str) -> bool:
|
|
88
|
+
"""Update an existing ``export MEM0_API_KEY=...`` line in path."""
|
|
89
|
+
if not path.is_file():
|
|
90
|
+
return False
|
|
91
|
+
try:
|
|
92
|
+
text = path.read_text(encoding="utf-8")
|
|
93
|
+
except OSError:
|
|
94
|
+
return False
|
|
95
|
+
match = _RC_LINE.search(text)
|
|
96
|
+
if not match:
|
|
97
|
+
return False # no existing line
|
|
98
|
+
if match.group(3) == api_key:
|
|
99
|
+
return False
|
|
100
|
+
new_text = _RC_LINE.sub(lambda m: f'{m.group(1)}"{api_key}"', text, count=1)
|
|
101
|
+
_atomic_write_text(path, new_text)
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _atomic_write_text(path: Path, content: str) -> None:
|
|
106
|
+
"""Write content to path atomically (temp + rename)."""
|
|
107
|
+
dirname = path.parent
|
|
108
|
+
fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=dirname)
|
|
109
|
+
try:
|
|
110
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
111
|
+
f.write(content)
|
|
112
|
+
# Preserve mode if the original existed.
|
|
113
|
+
if path.exists():
|
|
114
|
+
os.chmod(tmp_path, path.stat().st_mode & 0o777)
|
|
115
|
+
os.replace(tmp_path, path)
|
|
116
|
+
except Exception:
|
|
117
|
+
with contextlib.suppress(OSError):
|
|
118
|
+
os.unlink(tmp_path)
|
|
119
|
+
raise
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Agent mode state — set by the root callback, read by commands and branding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_agent_mode: bool = False
|
|
6
|
+
_current_command: str = ""
|
|
7
|
+
_pending_notice: str = ""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_agent_mode() -> bool:
|
|
11
|
+
return _agent_mode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def set_agent_mode(val: bool) -> None:
|
|
15
|
+
global _agent_mode
|
|
16
|
+
_agent_mode = val
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_current_command() -> str:
|
|
20
|
+
return _current_command
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def set_current_command(name: str) -> None:
|
|
24
|
+
global _current_command
|
|
25
|
+
_current_command = name
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def capture_notice(notice: str | None) -> None:
|
|
29
|
+
"""Stash a Mem0 backend notice for end-of-command surfacing.
|
|
30
|
+
|
|
31
|
+
Called from the platform backend after each response so the notice can
|
|
32
|
+
be printed once per command (regardless of how many sub-requests fired).
|
|
33
|
+
Last-write-wins is fine — the message text is identical across requests.
|
|
34
|
+
"""
|
|
35
|
+
global _pending_notice
|
|
36
|
+
if notice:
|
|
37
|
+
_pending_notice = notice
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def take_notice() -> str:
|
|
41
|
+
"""Return and clear the pending notice."""
|
|
42
|
+
global _pending_notice
|
|
43
|
+
msg = _pending_notice
|
|
44
|
+
_pending_notice = ""
|
|
45
|
+
return msg
|
|
@@ -87,7 +87,6 @@ def capture_event(
|
|
|
87
87
|
try:
|
|
88
88
|
from mem0_cli import __version__
|
|
89
89
|
from mem0_cli.config import CONFIG_FILE, load_config, save_config
|
|
90
|
-
from mem0_cli.state import is_agent_mode
|
|
91
90
|
|
|
92
91
|
config = load_config()
|
|
93
92
|
distinct_id = pre_resolved_email or _get_distinct_id()
|
|
@@ -107,6 +106,9 @@ def capture_event(
|
|
|
107
106
|
with contextlib.suppress(Exception):
|
|
108
107
|
save_config(config)
|
|
109
108
|
|
|
109
|
+
# M4: every cli.* event carries agent_mode based on the config flag
|
|
110
|
+
# (unclaimed Agent Mode key). This is the growth-doc property used to
|
|
111
|
+
# join init → add → search funnels in PostHog.
|
|
110
112
|
payload = {
|
|
111
113
|
"api_key": POSTHOG_API_KEY,
|
|
112
114
|
"distinct_id": distinct_id,
|
|
@@ -115,7 +117,7 @@ def capture_event(
|
|
|
115
117
|
"source": "CLI",
|
|
116
118
|
"language": "python",
|
|
117
119
|
"cli_version": __version__,
|
|
118
|
-
"agent_mode":
|
|
120
|
+
"agent_mode": bool(config.platform.agent_mode),
|
|
119
121
|
"python_version": sys.version,
|
|
120
122
|
"os": sys.platform,
|
|
121
123
|
"os_version": platform.version(),
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
"""Agent mode state — set by the root callback, read by commands and branding."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
_agent_mode: bool = False
|
|
6
|
-
_current_command: str = ""
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def is_agent_mode() -> bool:
|
|
10
|
-
return _agent_mode
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def set_agent_mode(val: bool) -> None:
|
|
14
|
-
global _agent_mode
|
|
15
|
-
_agent_mode = val
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def get_current_command() -> str:
|
|
19
|
-
return _current_command
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def set_current_command(name: str) -> None:
|
|
23
|
-
global _current_command
|
|
24
|
-
_current_command = name
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|