mem0-cli 0.2.3__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.
Files changed (28) hide show
  1. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/.gitignore +6 -2
  2. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/PKG-INFO +1 -1
  3. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/pyproject.toml +1 -1
  4. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/__init__.py +1 -1
  5. mem0_cli-0.2.5/src/mem0_cli/agent_detect.py +36 -0
  6. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/app.py +71 -43
  7. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/backend/base.py +0 -3
  8. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/backend/platform.py +26 -16
  9. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/branding.py +5 -3
  10. mem0_cli-0.2.5/src/mem0_cli/commands/agent_mode_cmd.py +239 -0
  11. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/commands/config_cmd.py +0 -5
  12. mem0_cli-0.2.5/src/mem0_cli/commands/identify_cmd.py +75 -0
  13. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/commands/init_cmd.py +160 -4
  14. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/commands/memory.py +0 -6
  15. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/commands/utils.py +1 -1
  16. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/config.py +31 -9
  17. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/output.py +19 -0
  18. mem0_cli-0.2.5/src/mem0_cli/plugin_sync.py +119 -0
  19. mem0_cli-0.2.5/src/mem0_cli/state.py +45 -0
  20. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/telemetry.py +4 -2
  21. mem0_cli-0.2.3/src/mem0_cli/state.py +0 -24
  22. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/README.md +0 -0
  23. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/__main__.py +0 -0
  24. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/backend/__init__.py +0 -0
  25. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/commands/__init__.py +0 -0
  26. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/commands/entities.py +0 -0
  27. {mem0_cli-0.2.3 → mem0_cli-0.2.5}/src/mem0_cli/commands/events_cmd.py +0 -0
  28. {mem0_cli-0.2.3 → 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/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mem0-cli
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: The official CLI for mem0 — the memory layer for AI agents
5
5
  Author-email: "mem0.ai" <founders@mem0.ai>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mem0-cli"
7
- version = "0.2.3"
7
+ version = "0.2.5"
8
8
  description = "The official CLI for mem0 — the memory layer for AI agents"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,3 +1,3 @@
1
1
  """mem0 CLI — the command-line interface for the mem0 memory layer."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
@@ -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
 
@@ -267,8 +275,6 @@ def add(
267
275
  categories: str | None = typer.Option(
268
276
  None, "--categories", help="Categories (JSON array or comma-separated)."
269
277
  ),
270
- graph: bool = typer.Option(False, "--graph", help="Enable graph memory extraction."),
271
- no_graph: bool = typer.Option(False, "--no-graph", help="Disable graph memory extraction."),
272
278
  output: str = typer.Option(
273
279
  "text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output"
274
280
  ),
@@ -295,13 +301,6 @@ def add(
295
301
  backend, config = _get_backend_and_config(api_key, base_url)
296
302
  ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
297
303
 
298
- if no_graph:
299
- graph_enabled = False
300
- elif graph:
301
- graph_enabled = True
302
- else:
303
- graph_enabled = config.defaults.enable_graph
304
-
305
304
  cmd_add(
306
305
  backend,
307
306
  text,
@@ -313,7 +312,6 @@ def add(
313
312
  no_infer=no_infer,
314
313
  expires=expires,
315
314
  categories=categories,
316
- enable_graph=graph_enabled,
317
315
  output=output,
318
316
  )
319
317
 
@@ -357,12 +355,6 @@ def search(
357
355
  help="Specific fields to return (comma-separated).",
358
356
  rich_help_panel="Search",
359
357
  ),
360
- graph: bool = typer.Option(
361
- False, "--graph", help="Enable graph in search.", rich_help_panel="Search"
362
- ),
363
- no_graph: bool = typer.Option(
364
- False, "--no-graph", help="Disable graph in search.", rich_help_panel="Search"
365
- ),
366
358
  output: str = typer.Option(
367
359
  "text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
368
360
  ),
@@ -396,13 +388,6 @@ def search(
396
388
  backend, config = _get_backend_and_config(api_key, base_url)
397
389
  ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
398
390
 
399
- if no_graph:
400
- graph_enabled = False
401
- elif graph:
402
- graph_enabled = True
403
- else:
404
- graph_enabled = config.defaults.enable_graph
405
-
406
391
  cmd_search(
407
392
  backend,
408
393
  query,
@@ -413,7 +398,6 @@ def search(
413
398
  keyword=keyword,
414
399
  filter_json=filter_json,
415
400
  fields=fields,
416
- enable_graph=graph_enabled,
417
401
  output=output,
418
402
  )
419
403
 
@@ -480,12 +464,6 @@ def list_cmd(
480
464
  before: str | None = typer.Option(
481
465
  None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters"
482
466
  ),
483
- graph: bool = typer.Option(
484
- False, "--graph", help="Enable graph in listing.", rich_help_panel="Filters"
485
- ),
486
- no_graph: bool = typer.Option(
487
- False, "--no-graph", help="Disable graph in listing.", rich_help_panel="Filters"
488
- ),
489
467
  output: str = typer.Option(
490
468
  "table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
491
469
  ),
@@ -511,13 +489,6 @@ def list_cmd(
511
489
  backend, config = _get_backend_and_config(api_key, base_url)
512
490
  ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
513
491
 
514
- if no_graph:
515
- graph_enabled = False
516
- elif graph:
517
- graph_enabled = True
518
- else:
519
- graph_enabled = config.defaults.enable_graph
520
-
521
492
  cmd_list(
522
493
  backend,
523
494
  **ids,
@@ -526,7 +497,6 @@ def list_cmd(
526
497
  category=category,
527
498
  after=after,
528
499
  before=before,
529
- enable_graph=graph_enabled,
530
500
  output=output,
531
501
  )
532
502
 
@@ -889,6 +859,19 @@ def init(
889
859
  force: bool = typer.Option(
890
860
  False, "--force", help="Overwrite existing config without confirmation."
891
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
+ ),
892
875
  ) -> None:
893
876
  """Interactive setup wizard for mem0 CLI.
894
877
 
@@ -897,10 +880,38 @@ def init(
897
880
  mem0 init --api-key m0-xxx --user-id alice
898
881
  mem0 init --email alice@company.com
899
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
900
885
  """
901
886
  from mem0_cli.commands.init_cmd import run_init
902
887
 
903
- run_init(api_key=api_key, user_id=user_id, email=email, code=code, force=force)
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)
904
915
 
905
916
 
906
917
  # (entity_app registered at module level, below sub-group definitions)
@@ -1236,11 +1247,28 @@ def main() -> None:
1236
1247
  import sys
1237
1248
 
1238
1249
  # Allow --json/--agent anywhere in the command line (not just before subcommand).
1239
- _json_flags = {"--json", "--agent"}
1240
- if any(a in _json_flags for a in sys.argv[1:]):
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):
1241
1256
  from mem0_cli.state import set_agent_mode
1242
1257
 
1243
1258
  set_agent_mode(True)
1244
- sys.argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in _json_flags]
1259
+ sys.argv = [sys.argv[0]] + [a for a in argv_rest if a not in _global_flags]
1245
1260
 
1246
- app()
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")
@@ -26,7 +26,6 @@ class Backend(ABC):
26
26
  infer: bool = True,
27
27
  expires: str | None = None,
28
28
  categories: list[str] | None = None,
29
- enable_graph: bool = False,
30
29
  ) -> dict: ...
31
30
 
32
31
  @abstractmethod
@@ -44,7 +43,6 @@ class Backend(ABC):
44
43
  keyword: bool = False,
45
44
  filters: dict | None = None,
46
45
  fields: list[str] | None = None,
47
- enable_graph: bool = False,
48
46
  ) -> list[dict]: ...
49
47
 
50
48
  @abstractmethod
@@ -63,7 +61,6 @@ class Backend(ABC):
63
61
  category: str | None = None,
64
62
  after: str | None = None,
65
63
  before: str | None = None,
66
- enable_graph: bool = False,
67
64
  ) -> list[dict]: ...
68
65
 
69
66
  @abstractmethod
@@ -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
- return resp.json()
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,
@@ -64,7 +83,6 @@ class PlatformBackend(Backend):
64
83
  infer: bool = True,
65
84
  expires: str | None = None,
66
85
  categories: list[str] | None = None,
67
- enable_graph: bool = False,
68
86
  ) -> dict:
69
87
  payload: dict[str, Any] = {}
70
88
 
@@ -91,11 +109,9 @@ class PlatformBackend(Backend):
91
109
  payload["expiration_date"] = expires
92
110
  if categories:
93
111
  payload["categories"] = categories
94
- if enable_graph:
95
- payload["enable_graph"] = True
96
112
  payload["source"] = "CLI"
97
113
 
98
- return self._request("POST", "/v1/memories/", json=payload)
114
+ return self._request("POST", "/v3/memories/add/", json=payload)
99
115
 
100
116
  def _build_filters(
101
117
  self,
@@ -106,7 +122,7 @@ class PlatformBackend(Backend):
106
122
  run_id: str | None = None,
107
123
  extra_filters: dict | None = None,
108
124
  ) -> dict | None:
109
- """Build a filters dict for v2 API endpoints.
125
+ """Build a filters dict for v3 API endpoints.
110
126
 
111
127
  Entity IDs are ANDed (all provided IDs must match).
112
128
  Extra filters (date ranges, categories) are also ANDed.
@@ -152,7 +168,6 @@ class PlatformBackend(Backend):
152
168
  keyword: bool = False,
153
169
  filters: dict | None = None,
154
170
  fields: list[str] | None = None,
155
- enable_graph: bool = False,
156
171
  ) -> list[dict]:
157
172
  payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold}
158
173
 
@@ -171,11 +186,9 @@ class PlatformBackend(Backend):
171
186
  payload["keyword_search"] = True
172
187
  if fields:
173
188
  payload["fields"] = fields
174
- if enable_graph:
175
- payload["enable_graph"] = True
176
189
  payload["source"] = "CLI"
177
190
 
178
- result = self._request("POST", "/v2/memories/search/", json=payload)
191
+ result = self._request("POST", "/v3/memories/search/", json=payload)
179
192
  return (
180
193
  result
181
194
  if isinstance(result, list)
@@ -197,12 +210,11 @@ class PlatformBackend(Backend):
197
210
  category: str | None = None,
198
211
  after: str | None = None,
199
212
  before: str | None = None,
200
- enable_graph: bool = False,
201
213
  ) -> list[dict]:
202
214
  payload: dict[str, Any] = {}
203
215
  params = {"page": str(page), "page_size": str(page_size)}
204
216
 
205
- # Build filters for v2 API — entity IDs and date filters go inside "filters"
217
+ # Build filters — entity IDs and date filters go inside "filters"
206
218
  extra: dict[str, Any] = {}
207
219
  if category:
208
220
  extra["categories"] = {"contains": category}
@@ -220,11 +232,9 @@ class PlatformBackend(Backend):
220
232
  )
221
233
  if api_filters:
222
234
  payload["filters"] = api_filters
223
- if enable_graph:
224
- payload["enable_graph"] = True
225
235
  payload["source"] = "CLI"
226
236
 
227
- result = self._request("POST", "/v2/memories/", json=payload, params=params)
237
+ result = self._request("POST", "/v3/memories/", json=payload, params=params)
228
238
  return (
229
239
  result
230
240
  if isinstance(result, list)
@@ -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()