memorysync-cli 1.0.3__tar.gz → 1.1.1__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 (27) hide show
  1. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/PKG-INFO +1 -1
  2. memorysync_cli-1.1.1/src/memorysync_cli/_version.py +1 -0
  3. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/commands/admin.py +164 -21
  4. memorysync_cli-1.1.1/src/memorysync_cli/commands/init.py +333 -0
  5. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/config.py +32 -3
  6. memorysync_cli-1.1.1/src/memorysync_cli/evaluation.py +54 -0
  7. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/http.py +42 -1
  8. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/main.py +22 -0
  9. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/registry.json +49 -3
  10. memorysync_cli-1.0.3/src/memorysync_cli/_version.py +0 -1
  11. memorysync_cli-1.0.3/src/memorysync_cli/commands/init.py +0 -109
  12. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/.gitignore +0 -0
  13. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/LICENSE +0 -0
  14. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/README.md +0 -0
  15. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/pyproject.toml +0 -0
  16. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/__init__.py +0 -0
  17. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/__main__.py +0 -0
  18. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/args.py +0 -0
  19. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/commands/__init__.py +0 -0
  20. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/commands/memory.py +0 -0
  21. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/commands/source.py +0 -0
  22. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/commands/tooling.py +0 -0
  23. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/completions.py +0 -0
  24. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/credentials.py +0 -0
  25. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/errors.py +0 -0
  26. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/output.py +0 -0
  27. {memorysync_cli-1.0.3 → memorysync_cli-1.1.1}/src/memorysync_cli/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memorysync-cli
3
- Version: 1.0.3
3
+ Version: 1.1.1
4
4
  Summary: MemorySync from your terminal. Zero dependencies.
5
5
  Project-URL: Documentation, https://docs.memorysync.io/cli
6
6
  Project-URL: Homepage, https://memorysync.io/cli
@@ -0,0 +1 @@
1
+ __version__ = "1.1.1"
@@ -20,8 +20,18 @@ from .. import config as config_module
20
20
  from .. import credentials
21
21
  from .._version import __version__
22
22
  from ..errors import usage_error
23
+ from ..evaluation import is_evaluation_key
23
24
  from ..output import render_table, style
24
25
 
26
+ #: Keys ``config set`` / ``config unset`` accept. Identical to the Node CLI's
27
+ #: ``SETTABLE``; ``output`` was previously missing here, so `config set output table`
28
+ #: worked under one CLI and was refused by the other.
29
+ #:
30
+ #: ``api_key`` is deliberately absent. Keys go to the keychain through ``init``, and
31
+ #: accepting one here would write a credential into a file people paste into bug
32
+ #: reports.
33
+ _SETTABLE = frozenset({"user", "project", "base_url", "output", "timeout"})
34
+
25
35
 
26
36
  def _percent(used: Any, limit: Any) -> str:
27
37
  if not isinstance(used, (int, float)) or not isinstance(limit, (int, float)) or limit <= 0:
@@ -29,6 +39,38 @@ def _percent(used: Any, limit: Any) -> str:
29
39
  return f"{min(100, round(used / limit * 100))}%"
30
40
 
31
41
 
42
+ def _evaluation_usage_summary(api: Any) -> dict:
43
+ """Reshape ``/evaluation/usage`` into what ``quota`` already renders.
44
+
45
+ Same keys the billing summary returns, so the renderer and ``--json`` consumers
46
+ do not have to care which kind of key they are looking at. Mirrors the Node
47
+ CLI's ``evaluationQuota``.
48
+ """
49
+ usage = api.evaluation_usage() or {}
50
+ add = usage.get("add_requests") or {}
51
+ retrieval = usage.get("retrieval_requests") or {}
52
+ return {
53
+ "plan_id": usage.get("plan"),
54
+ "metrics": [
55
+ {
56
+ "metric": "add_requests",
57
+ "label": "Memories added",
58
+ "used": add.get("used"),
59
+ "limit": add.get("limit"),
60
+ },
61
+ {
62
+ "metric": "retrieval_requests",
63
+ "label": "Retrievals",
64
+ "used": retrieval.get("used"),
65
+ "limit": retrieval.get("limit"),
66
+ },
67
+ ],
68
+ "claimed": usage.get("claimed") is True,
69
+ "expires_at": usage.get("expires_at"),
70
+ "claim_command": usage.get("claim_command"),
71
+ }
72
+
73
+
32
74
  def quota(ctx: dict) -> dict:
33
75
  """Plan usage, with headroom made explicit.
34
76
 
@@ -36,7 +78,17 @@ def quota(ctx: dict) -> dict:
36
78
  Mem0, Supermemory or Zep expose this from their CLI.
37
79
  """
38
80
  api = ctx["api"]
39
- summary = api.usage_summary() or {}
81
+
82
+ if is_evaluation_key(ctx.get("api_key")):
83
+ # ``/org/billing/usage-summary`` needs the ``billing.view`` capability and
84
+ # the ``billing:read`` scope, and an evaluation key is granted neither on
85
+ # purpose: ``billing:read`` also opens ``GET /org/billing/profit``, which
86
+ # returns cost and margin from our internal pricing model. The server
87
+ # exposes the same counters without the priced fields, so route there.
88
+ summary = _evaluation_usage_summary(api)
89
+ else:
90
+ summary = api.usage_summary() or {}
91
+
40
92
  metrics = [m for m in (summary.get("metrics") or []) if isinstance(m, dict)]
41
93
 
42
94
  rows = [
@@ -71,6 +123,15 @@ def quota(ctx: dict) -> dict:
71
123
  for row in rows
72
124
  ],
73
125
  )
126
+ # An evaluation allowance does not reset on a cycle: the key stops working
127
+ # at expiry, so that is the date worth printing. Matches the Node CLI.
128
+ expiry_note = ""
129
+ if summary.get("expires_at") and not summary.get("claimed"):
130
+ expiry_note = "\n" + style.yellow(
131
+ "Unclaimed evaluation key, expires "
132
+ f"{str(summary['expires_at'])[:10]}."
133
+ )
134
+
74
135
  exhausted = [r["metric"] for r in rows if r["remaining"] == 0]
75
136
  if exhausted:
76
137
  warning = style.yellow(
@@ -80,8 +141,8 @@ def quota(ctx: dict) -> dict:
80
141
  "Writes and reads return empty rather than failing, so check here "
81
142
  "before assuming memory is missing."
82
143
  )
83
- return f"{table}\n\n{warning}\n{note}"
84
- return table
144
+ return f"{table}\n\n{warning}\n{note}{expiry_note}"
145
+ return f"{table}{expiry_note}"
85
146
 
86
147
  return {"data": rows, "text": render}
87
148
 
@@ -323,41 +384,123 @@ def config_command(ctx: dict) -> dict:
323
384
  ),
324
385
  }
325
386
 
387
+ if sub == "profiles":
388
+ cfg = config_module.load()
389
+ current = cfg.get("current_profile", "default")
390
+ profiles = cfg.get("profiles", {})
391
+ rows = [
392
+ [
393
+ style.green("*") if name == current else " ",
394
+ name,
395
+ entry.get("user") or "-",
396
+ entry.get("project") or "-",
397
+ entry.get("base_url") or "(default)",
398
+ ]
399
+ for name, entry in profiles.items()
400
+ ]
401
+
402
+ def render_profiles() -> str:
403
+ if not rows:
404
+ return style.dim("No profiles. Run `memorysync init`.")
405
+ return render_table([" ", "profile", "user", "project", "base url"], rows)
406
+
407
+ return {
408
+ "data": {"current": current, "profiles": profiles},
409
+ "text": render_profiles,
410
+ }
411
+
412
+ if sub == "use-profile":
413
+ if len(positionals) < 2:
414
+ raise usage_error("Which profile?")
415
+ name = positionals[1]
416
+ cfg = config_module.load()
417
+ if name not in cfg.get("profiles", {}):
418
+ raise usage_error(
419
+ f'No profile named "{name}".',
420
+ "Run `memorysync config profiles` to see what exists, or "
421
+ f"`memorysync init --profile {name}` to create it.",
422
+ )
423
+ cfg["current_profile"] = name
424
+ config_module.save(cfg)
425
+ return {
426
+ "data": {"current_profile": name},
427
+ "text": lambda: f"{style.green('Switched to')} {name}",
428
+ }
429
+
430
+ if sub == "delete-profile":
431
+ if len(positionals) < 2:
432
+ raise usage_error("Which profile?")
433
+ name = positionals[1]
434
+ cfg = config_module.load()
435
+ if name not in cfg.get("profiles", {}):
436
+ raise usage_error(f'No profile named "{name}".')
437
+ del cfg["profiles"][name]
438
+ if cfg.get("current_profile") == name:
439
+ remaining = list(cfg.get("profiles", {}))
440
+ cfg["current_profile"] = remaining[0] if remaining else "default"
441
+ config_module.save(cfg)
442
+ # The stored credential goes with the profile. Leaving it behind would keep
443
+ # a usable key in the keychain for a profile the user just removed.
444
+ credentials.delete_key(name)
445
+ return {
446
+ "data": {"deleted": name},
447
+ "text": lambda: f"{style.green('Deleted profile')} {name}",
448
+ }
449
+
326
450
  if sub == "get":
327
451
  if len(positionals) < 2:
328
452
  raise usage_error("Which key?", "memorysync config get user")
329
453
  key = positionals[1]
330
- settings = config_module.resolve(ctx["flags"])
331
- value = settings.get(key)
454
+ # Reads the stored profile, not the resolved value. Matches the Node CLI:
455
+ # `config get` answers "what is written down", so an environment variable
456
+ # does not make it look as though something was saved.
457
+ cfg = config_module.load()
458
+ entry = cfg.get("profiles", {}).get(cfg.get("current_profile", "default"), {})
459
+ value = entry.get(key)
332
460
  return {"data": {key: value}, "text": lambda: "" if value is None else str(value)}
333
461
 
334
- if sub == "set":
335
- if len(positionals) < 3:
336
- raise usage_error("What to set?", "memorysync config set user alice")
337
- key, value = positionals[1], positionals[2]
462
+ if sub in {"set", "unset"}:
463
+ if len(positionals) < 2:
464
+ raise usage_error(
465
+ "Which key?", f"Settable: {', '.join(sorted(_SETTABLE))}"
466
+ )
467
+ key = positionals[1]
338
468
  if key in {"api_key", "apikey", "key"}:
339
469
  raise usage_error(
340
470
  "Keys are not stored in the config file.",
341
471
  "Run `memorysync init --api-key <key>`; it goes to the OS keychain.",
342
472
  )
343
- allowed = {"user", "project", "base_url", "timeout"}
344
- if key not in allowed:
473
+ if key not in _SETTABLE:
345
474
  raise usage_error(
346
- f'Cannot set "{key}".',
347
- f"One of: {', '.join(sorted(allowed))}.",
475
+ f'"{key}" is not settable.',
476
+ f"Settable keys: {', '.join(sorted(_SETTABLE))}. The API key is "
477
+ "never stored here; use `memorysync init`.",
348
478
  )
349
-
350
- profile_name = ctx["settings"]["profile_name"]
351
- data = config_module.load()
352
- data.setdefault("profiles", {}).setdefault(profile_name, {})[key] = value
353
- path = config_module.save(data)
479
+ if sub == "set" and len(positionals) < 3:
480
+ raise usage_error(f"config set {key} <value>")
481
+ value = positionals[2] if sub == "set" else None
482
+
483
+ cfg = config_module.load()
484
+ name = cfg.get("current_profile", "default")
485
+ entry = dict(cfg.get("profiles", {}).get(name, {}))
486
+ if sub == "set":
487
+ entry[key] = value
488
+ else:
489
+ entry.pop(key, None)
490
+ cfg.setdefault("profiles", {})[name] = entry
491
+ config_module.save(cfg)
354
492
  return {
355
- "data": {"profile": profile_name, key: value, "path": str(path)},
356
- "text": lambda: f"{style.green('Set')} {key} = {value} in profile {profile_name}",
493
+ "data": {"profile": name, key: value},
494
+ "text": lambda: (
495
+ f"{style.green('Set' if sub == 'set' else 'Unset')} {key} "
496
+ f"on profile {name}"
497
+ ),
357
498
  }
358
499
 
359
500
  if sub == "path":
360
501
  path = str(config_module.config_path())
361
502
  return {"data": {"path": path}, "text": lambda: path}
362
503
 
363
- raise usage_error(f'Unknown subcommand "{sub}".', "Try show, get, set or path.")
504
+ raise usage_error(
505
+ f'Unknown subcommand "{sub}".', "Try `memorysync config show`."
506
+ )
@@ -0,0 +1,333 @@
1
+ """init: store a credential and pick defaults.
2
+
3
+ The key is verified against the API before it is written. Storing an unverified key
4
+ means the next command fails with something that looks like a network problem, and
5
+ the person has no reason to suspect the thing they just typed.
6
+
7
+ It is never written to the config file in plain text. It goes to the OS keychain
8
+ where one is reachable, and otherwise to an owner-only encrypted file, and `init`
9
+ says which of the two happened rather than implying a keychain that was never
10
+ there.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import getpass
16
+ import os
17
+ import sys
18
+
19
+ from .. import config as config_module
20
+ from .. import credentials
21
+ from ..errors import auth_error, usage_error
22
+ from ..evaluation import CLAIM_HINT
23
+ from ..http import ApiClient
24
+ from ..output import style
25
+
26
+
27
+ def _init_as_agent(ctx: dict, profile_name: str) -> dict:
28
+ """``init --agent`` — mint an evaluation key with no signup.
29
+
30
+ Stores the key exactly the way the interactive path does, so every later
31
+ command works with no further setup, and keeps the server's generated
32
+ end-user id as the profile default: an agent forced to invent one will invent
33
+ a different one next run and then wonder where its memories went.
34
+ """
35
+ flags, settings = ctx["flags"], ctx["settings"]
36
+
37
+ # No credential yet, by definition.
38
+ client = ApiClient(
39
+ base_url=settings["base_url"], api_key=None, timeout=settings["timeout"]
40
+ )
41
+ body = {}
42
+ if flags.get("agent_caller"):
43
+ body["agent_caller"] = flags["agent_caller"]
44
+ minted = client.mint_evaluation_key(body) or {}
45
+
46
+ api_key = minted.get("api_key")
47
+ if not api_key:
48
+ raise auth_error(
49
+ "The server did not return an evaluation key.",
50
+ 'Try again, or run "memorysync init" with a key from https://memorysync.io.',
51
+ )
52
+
53
+ tier = credentials.write_key(api_key, profile_name)
54
+
55
+ data = config_module.load()
56
+ entry = data.setdefault("profiles", {}).setdefault(profile_name, {})
57
+ user = flags.get("user") or minted.get("default_user_id")
58
+ if user:
59
+ entry["user"] = user
60
+ if minted.get("project_id"):
61
+ entry["project"] = minted["project_id"]
62
+ if settings["base_url"] != config_module.DEFAULT_BASE_URL:
63
+ entry["base_url"] = settings["base_url"]
64
+ path = config_module.save(data)
65
+
66
+ payload = {
67
+ "profile": profile_name,
68
+ "mode": "agent",
69
+ "api_key": api_key,
70
+ "default_user_id": minted.get("default_user_id"),
71
+ "project_id": minted.get("project_id"),
72
+ "plan": minted.get("plan"),
73
+ "claimed": False,
74
+ "expires_at": minted.get("expires_at"),
75
+ "claim_command": minted.get("claim_command") or CLAIM_HINT,
76
+ "limits": minted.get("limits"),
77
+ "mcp_url": minted.get("mcp_url"),
78
+ "stored_in": tier,
79
+ "config": str(path),
80
+ }
81
+
82
+ def render() -> str:
83
+ where = (
84
+ "the OS keychain" if tier == "keychain" else "an owner-only encrypted file"
85
+ )
86
+ return "\n".join(
87
+ [
88
+ style.green("Agent mode active."),
89
+ f"{style.dim('default user')} {payload['default_user_id'] or '-'}",
90
+ f"{style.dim('plan ')} {payload['plan'] or '-'}",
91
+ f"{style.dim('expires ')} {payload['expires_at'] or '-'}",
92
+ f"{style.dim('key stored ')} in {where}",
93
+ "",
94
+ style.dim('Try: memorysync add "I prefer TypeScript"'),
95
+ "",
96
+ style.yellow(
97
+ "Nobody owns this account yet. To keep it and everything in it:"
98
+ ),
99
+ f" {payload['claim_command']}",
100
+ ]
101
+ )
102
+
103
+ return {"data": payload, "text": render}
104
+
105
+
106
+ def _init_by_claiming(ctx: dict, profile_name: str) -> dict:
107
+ """``init --email`` — claim the evaluation account behind the stored key.
108
+
109
+ Two steps in one command, chosen by whether ``--code`` is present, because
110
+ that is how a person experiences it: run it, read the email, run it again with
111
+ the code.
112
+ """
113
+ flags, settings = ctx["flags"], ctx["settings"]
114
+ email = str(flags.get("email") or "").strip()
115
+
116
+ if flags.get("code"):
117
+ # Completing needs no credential, so this works on a machine that never
118
+ # held the key.
119
+ client = ApiClient(
120
+ base_url=settings["base_url"], api_key=None, timeout=settings["timeout"]
121
+ )
122
+ body = {"email": email, "code": str(flags["code"]).strip()}
123
+ if flags.get("password"):
124
+ body["password"] = str(flags["password"])
125
+ claimed = client.complete_evaluation_claim(body) or {}
126
+
127
+ # Recorded locally so the per-command reminder stops. The key keeps its
128
+ # ``ms_eval_`` prefix after claiming, so the prefix alone cannot tell us.
129
+ data = config_module.load()
130
+ entry = data.setdefault("profiles", {}).setdefault(profile_name, {})
131
+ entry["claimed"] = True
132
+ config_module.save(data)
133
+
134
+ payload = {**claimed, "profile": profile_name, "mode": "claim-complete"}
135
+
136
+ def render_complete() -> str:
137
+ return "\n".join(
138
+ [
139
+ style.green("Account claimed."),
140
+ f"{style.dim('email ')} {claimed.get('email') or email}",
141
+ f"{style.dim('plan ')} {claimed.get('plan') or '-'}",
142
+ "",
143
+ style.dim(
144
+ "Your existing key keeps working and no longer expires. "
145
+ "Everything stored during the evaluation is still there."
146
+ ),
147
+ ]
148
+ )
149
+
150
+ return {"data": payload, "text": render_complete}
151
+
152
+ # The value ``main`` already resolved from --api-key, the environment, then the
153
+ # stored profile. Resolving it again here would be a second precedence order
154
+ # for the Node CLI to disagree with.
155
+ api_key = ctx.get("api_key")
156
+ if not api_key:
157
+ raise usage_error(
158
+ "No stored key, so there is no evaluation account to claim.",
159
+ 'Run "memorysync init --agent" first, or pass --code to finish a claim '
160
+ "you already started.",
161
+ )
162
+
163
+ client = ApiClient(
164
+ base_url=settings["base_url"], api_key=api_key, timeout=settings["timeout"]
165
+ )
166
+ started = client.start_evaluation_claim({"email": email}) or {}
167
+ payload = {**started, "profile": profile_name, "mode": "claim-start"}
168
+
169
+ def render_start() -> str:
170
+ target = started.get("email") or email
171
+ return "\n".join(
172
+ [
173
+ style.green("Claim code sent."),
174
+ f"{style.dim('email ')} {target}",
175
+ f"{style.dim('expires')} {started.get('expires_in_minutes') or '-'} minutes",
176
+ "",
177
+ style.dim("Check your email, then run:"),
178
+ f" memorysync init --email {target} --code <CODE>",
179
+ ]
180
+ )
181
+
182
+ return {"data": payload, "text": render_start}
183
+
184
+
185
+ def identify(ctx: dict) -> dict:
186
+ """``identify <name>`` — replace the profile's default end-user id.
187
+
188
+ Purely local. Renaming the default cannot rename memories already stored under
189
+ the old id, and pretending otherwise would be worse than saying so: the output
190
+ names the previous id so it can still be read back with ``--user``.
191
+ """
192
+ positionals = ctx.get("positionals") or []
193
+ name = (positionals[0] if positionals else "").strip()
194
+ if not name:
195
+ raise usage_error("No name given.", "Try: memorysync identify alice")
196
+
197
+ profile_name = ctx["settings"]["profile_name"]
198
+ data = config_module.load()
199
+ entry = data.setdefault("profiles", {}).setdefault(profile_name, {})
200
+ previous = entry.get("user")
201
+ entry["user"] = name
202
+ # Also make it current, matching the Node CLI. Without this, `identify` under
203
+ # one CLI set a default that the other did not switch to, which is exactly the
204
+ # kind of near-miss the two-CLI contract exists to prevent.
205
+ data["current_profile"] = profile_name
206
+ config_module.save(data)
207
+
208
+ payload = {
209
+ "profile": profile_name,
210
+ "end_user": name,
211
+ "previous_end_user": previous,
212
+ }
213
+
214
+ def render() -> str:
215
+ lines = [style.green(f"Default end user is now {name}.")]
216
+ if previous and previous != name:
217
+ lines.append(
218
+ style.dim(
219
+ f'Memories stored as "{previous}" keep that id - '
220
+ f"read them with --user {previous}."
221
+ )
222
+ )
223
+ else:
224
+ lines.append(style.dim("Nothing was stored under a different id."))
225
+ return "\n".join(lines)
226
+
227
+ return {"data": payload, "text": render}
228
+
229
+
230
+ def init(ctx: dict) -> dict:
231
+ flags, settings = ctx["flags"], ctx["settings"]
232
+ profile_name = settings["profile_name"]
233
+
234
+ # Claiming acts on the account behind an existing key rather than creating a
235
+ # profile, so it runs before the overwrite guard.
236
+ if flags.get("email"):
237
+ if flags.get("agent"):
238
+ raise usage_error(
239
+ "--agent mints a new account and --email claims the current one.",
240
+ 'Run "memorysync init --agent" first, then '
241
+ '"memorysync init --email <address>".',
242
+ )
243
+ return _init_by_claiming(ctx, profile_name)
244
+
245
+ if flags.get("agent") and flags.get("api_key"):
246
+ raise usage_error(
247
+ "--agent mints a key, so there is nothing to pass with --api-key.",
248
+ "Drop one of the two.",
249
+ )
250
+
251
+ existing = config_module.profile(profile_name)
252
+ if existing and not flags.get("force"):
253
+ stored = credentials.read_key(profile_name)
254
+ if stored:
255
+ raise usage_error(
256
+ f'Profile "{profile_name}" already has a key.',
257
+ "Pass --force to replace it, or --profile <name> to add another.",
258
+ )
259
+
260
+ if flags.get("agent"):
261
+ return _init_as_agent(ctx, profile_name)
262
+
263
+ api_key = flags.get("api_key") or os.environ.get("MEMORYSYNC_API_KEY")
264
+ if not api_key:
265
+ if not sys.stdin.isatty():
266
+ raise usage_error(
267
+ "No API key given and no terminal to prompt from.",
268
+ "Pass --api-key, or set MEMORYSYNC_API_KEY.",
269
+ )
270
+ # getpass, so the key is not echoed into the terminal or the scrollback.
271
+ api_key = getpass.getpass("MemorySync API key: ").strip()
272
+
273
+ if not api_key:
274
+ raise usage_error("No API key given.")
275
+
276
+ # Verified before storing. A key that cannot read its own plan is not usable,
277
+ # and finding that out now is far cheaper than on the next command.
278
+ probe = ApiClient(
279
+ base_url=settings["base_url"],
280
+ api_key=api_key,
281
+ timeout=settings["timeout"],
282
+ )
283
+ try:
284
+ plan = probe.current_plan() or {}
285
+ except Exception as error: # noqa: BLE001 - re-raised as an auth failure below
286
+ raise auth_error(
287
+ f"That key was refused: {error}",
288
+ "Check it in the dashboard, or pass --base-url if you are not on production.",
289
+ ) from None
290
+
291
+ tier = credentials.write_key(api_key, profile_name)
292
+
293
+ data = config_module.load()
294
+ entry = data.setdefault("profiles", {}).setdefault(profile_name, {})
295
+ if flags.get("user"):
296
+ entry["user"] = flags["user"]
297
+ if flags.get("project"):
298
+ entry["project"] = flags["project"]
299
+ if settings["base_url"] != config_module.DEFAULT_BASE_URL:
300
+ entry["base_url"] = settings["base_url"]
301
+ path = config_module.save(data)
302
+
303
+ payload = {
304
+ "profile": profile_name,
305
+ "stored_in": tier,
306
+ "config": str(path),
307
+ "user": entry.get("user"),
308
+ "project": entry.get("project"),
309
+ "plan": plan.get("plan") or plan.get("name"),
310
+ }
311
+
312
+ def render() -> str:
313
+ where = (
314
+ "the OS keychain"
315
+ if tier == "keychain"
316
+ else "an owner-only encrypted file"
317
+ )
318
+ lines = [
319
+ f"{style.green('Ready.')} Profile {style.bold(profile_name)} is set up.",
320
+ f"{style.dim('key ')} stored in {where}",
321
+ f"{style.dim('config ')} {path}",
322
+ ]
323
+ if entry.get("user"):
324
+ lines.append(f"{style.dim('user ')} {entry['user']}")
325
+ else:
326
+ lines.append(
327
+ style.dim("no default user; pass --user on each call or re-run with --user")
328
+ )
329
+ if payload["plan"]:
330
+ lines.append(f"{style.dim('plan ')} {payload['plan']}")
331
+ return "\n".join(lines)
332
+
333
+ return {"data": payload, "text": render}
@@ -55,7 +55,7 @@ def config_path() -> Path:
55
55
 
56
56
 
57
57
  def _empty() -> dict[str, Any]:
58
- return {"version": CONFIG_VERSION, "profiles": {}}
58
+ return {"version": CONFIG_VERSION, "profiles": {}, "current_profile": "default"}
59
59
 
60
60
 
61
61
  def load() -> dict[str, Any]:
@@ -78,6 +78,18 @@ def load() -> dict[str, Any]:
78
78
  return {
79
79
  "version": parsed.get("version", CONFIG_VERSION),
80
80
  "profiles": parsed.get("profiles") if isinstance(parsed.get("profiles"), dict) else {},
81
+ # Preserved, not dropped. This key was being discarded on read, so the
82
+ # Python CLI had no concept of an active profile at all: `config
83
+ # use-profile work` wrote it and every later command still used "default",
84
+ # and a profile switched under the Node CLI was ignored here. The two are
85
+ # documented as interchangeable and share ~/.memorysync, so that was a real
86
+ # divergence rather than a missing nicety.
87
+ "current_profile": (
88
+ parsed.get("current_profile")
89
+ if isinstance(parsed.get("current_profile"), str)
90
+ and parsed.get("current_profile")
91
+ else "default"
92
+ ),
81
93
  }
82
94
 
83
95
 
@@ -105,10 +117,18 @@ def resolve(flags: dict[str, Any]) -> dict[str, Any]:
105
117
  Values are read once here so every command sees the same view, rather than
106
118
  each one reaching for the environment at a different moment.
107
119
  """
120
+ # Same precedence as the Node CLI's ``resolveSettings``: an explicit flag, then
121
+ # the environment, then the profile the user selected with
122
+ # ``config use-profile``, then "default". The stored selection was missing here,
123
+ # which is why a switch made under either CLI had no effect on this one.
124
+ config = load()
108
125
  profile_name = (
109
- flags.get("profile") or os.environ.get("MEMORYSYNC_PROFILE") or "default"
126
+ flags.get("profile")
127
+ or os.environ.get("MEMORYSYNC_PROFILE")
128
+ or config.get("current_profile")
129
+ or "default"
110
130
  )
111
- stored = profile(profile_name)
131
+ stored = config.get("profiles", {}).get(profile_name, {})
112
132
 
113
133
  def pick(
114
134
  flag_key: str,
@@ -151,6 +171,15 @@ def resolve(flags: dict[str, Any]) -> dict[str, Any]:
151
171
  # `-o table` saved into a profile worked under one CLI only.
152
172
  "output": pick("output", "MEMORYSYNC_OUTPUT", "output"),
153
173
  "timeout": timeout,
174
+ # Whether the evaluation account behind this profile has been claimed.
175
+ # Recorded locally by ``init --email ... --code ...`` because a claimed
176
+ # key keeps its ``ms_eval_`` prefix, so the prefix alone cannot tell us
177
+ # and the per-command reminder would never stop.
178
+ #
179
+ # Deliberately read straight off the profile rather than through ``pick``:
180
+ # it is a fact about the account, not a preference, so a flag or an
181
+ # environment variable must not be able to silence the reminder.
182
+ "claimed": stored.get("claimed") is True,
154
183
  }
155
184
 
156
185
 
@@ -0,0 +1,54 @@
1
+ """Evaluation-key awareness shared by every command.
2
+
3
+ Mirrors ``sdk/cli/src/evaluation.mjs`` exactly. The two CLIs are documented as
4
+ interchangeable, so a reminder that appears in one and not the other is a parity
5
+ bug, not a cosmetic difference.
6
+
7
+ An evaluation key is minted by an agent and expires in seven days unless a human
8
+ claims it. The most expensive outcome is somebody building on one, never noticing
9
+ it is temporary, and losing the account on day eight — so every command running
10
+ on an unclaimed key ends with one line saying so, on stderr.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Mapping, Optional
16
+
17
+ #: Prefix the server puts on anonymously minted keys.
18
+ EVALUATION_KEY_PREFIX = "ms_eval_"
19
+
20
+ #: The command a human runs to keep the account.
21
+ CLAIM_HINT = "memorysync init --email you@example.com"
22
+
23
+
24
+ def is_evaluation_key(key: Optional[str]) -> bool:
25
+ """True when *key* was minted anonymously."""
26
+ return isinstance(key, str) and key.startswith(EVALUATION_KEY_PREFIX)
27
+
28
+
29
+ def claim_reminder(
30
+ api_key: Optional[str] = None,
31
+ profile: Optional[Mapping[str, Any]] = None,
32
+ ) -> Optional[str]:
33
+ """The reminder line for this invocation, or ``None``.
34
+
35
+ Reads ``claimed`` from the local profile rather than asking the API. Checking
36
+ on every command would add a round-trip to ``memorysync --help``, and the
37
+ claim path already knows the answer the moment it succeeds, so it records it.
38
+ """
39
+ if not is_evaluation_key(api_key):
40
+ return None
41
+ if profile and profile.get("claimed") is True:
42
+ return None
43
+ return (
44
+ "This is an unclaimed evaluation key and expires. "
45
+ f"Keep it: {CLAIM_HINT}"
46
+ )
47
+
48
+
49
+ __all__ = [
50
+ "EVALUATION_KEY_PREFIX",
51
+ "CLAIM_HINT",
52
+ "is_evaluation_key",
53
+ "claim_reminder",
54
+ ]
@@ -64,12 +64,22 @@ class ApiClient:
64
64
 
65
65
  def headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
66
66
  headers = {
67
- "X-API-Key": self.api_key,
68
67
  "Accept": "application/json",
69
68
  # From the package version, never a literal. The Node CLI shipped
70
69
  # 1.0.1 announcing itself as 1.0.0 because this was hardcoded.
71
70
  "User-Agent": f"memorysync-cli-py/{__version__}",
72
71
  }
72
+ # Omitted rather than sent empty when there is no key.
73
+ #
74
+ # Two endpoints are deliberately keyless: minting an evaluation key, and
75
+ # redeeming a claim code. Setting the header to ``None`` made urllib raise
76
+ # ``TypeError: expected string or bytes-like object`` inside ``putheader``,
77
+ # before the request was sent -- so `init --agent` and `init --email --code`
78
+ # could never work in this package at all. The Node CLI survived the same
79
+ # bug only because ``fetch`` coerces null to the string "null", which the
80
+ # server then ignores on an unauthenticated route.
81
+ if self.api_key:
82
+ headers["X-API-Key"] = self.api_key
73
83
  # The API rejects key-authenticated memory calls without this, because
74
84
  # memory is always scoped to (tenant, project, user) and storing
75
85
  # everything under the key owner is not allowed. Commands validate it up
@@ -194,6 +204,37 @@ class ApiClient:
194
204
  # Named endpoints, so commands never carry raw paths
195
205
  # -----------------------------------------------------------------------
196
206
 
207
+ def mint_evaluation_key(self, body: dict) -> Any:
208
+ """Mint an evaluation key. The only endpoint here needing no credential.
209
+
210
+ Rate limited server-side per IP and per /24, failing closed, so a 429 is a
211
+ real answer rather than a transient error — it carries
212
+ ``retry_after_seconds`` pointing at the next UTC day.
213
+ """
214
+ return self.request("POST", "/evaluation/keys", body=body)
215
+
216
+ def start_evaluation_claim(self, body: dict) -> Any:
217
+ """Ask for a claim code by email. Needs the evaluation key."""
218
+ return self.request("POST", "/evaluation/claim/start", body=body)
219
+
220
+ def complete_evaluation_claim(self, body: dict) -> Any:
221
+ """Redeem a claim code.
222
+
223
+ Deliberately does not require the key: the code is the proof, so a claim
224
+ started on one machine can be finished on another — which is what happens
225
+ whenever somebody reads the code off their phone.
226
+ """
227
+ return self.request("POST", "/evaluation/claim/complete", body=body)
228
+
229
+ def evaluation_usage(self) -> Any:
230
+ """Remaining allowance for an evaluation key.
231
+
232
+ Evaluation keys are not granted ``billing:read``, so
233
+ ``/org/billing/usage`` is closed to them. This reports the same counters
234
+ without the pricing fields.
235
+ """
236
+ return self.request("GET", "/evaluation/usage")
237
+
197
238
  def add_memory(self, body: dict) -> Any:
198
239
  return self.request("POST", "/memory/add", body=body)
199
240
 
@@ -21,6 +21,7 @@ from .args import parse_args, suggest
21
21
  from .commands import admin, init as init_command, memory, source as source_command, tooling
22
22
  from .credentials import read_key
23
23
  from .errors import CliError, Exit, auth_error, usage_error
24
+ from .evaluation import claim_reminder
24
25
  from .http import ApiClient
25
26
  from .output import (
26
27
  emit,
@@ -37,8 +38,23 @@ Handler = Callable[[dict], dict]
37
38
  # which is the same reason the Node CLI keeps its HANDLERS map next to its own. A
38
39
  # name in the registry with no entry here fails as "not available" rather than
39
40
  # "unknown command", so `help --json` stays honest about what exists.
41
+ def _note_unclaimed_key(api_key: str | None, settings: dict) -> None:
42
+ """Tell the user their evaluation key is temporary, once per command.
43
+
44
+ On stderr so stdout stays machine-parseable — an agent piping ``--json``
45
+ somewhere sees nothing extra, and a person sees the line. Silent for a claimed
46
+ key or a normal one. Matches the Node CLI line for line.
47
+ """
48
+ reminder = claim_reminder(
49
+ api_key, {"claimed": bool((settings or {}).get("claimed"))}
50
+ )
51
+ if reminder:
52
+ print(reminder, file=sys.stderr)
53
+
54
+
40
55
  _HANDLERS: dict[str, Handler] = {
41
56
  "init": init_command.init,
57
+ "identify": init_command.identify,
42
58
  "add": memory.add,
43
59
  "search": memory.search,
44
60
  "list": memory.list_memories,
@@ -260,6 +276,7 @@ def dispatch(argv: list[str]) -> int:
260
276
  # command list.
261
277
  if command_name == "help" and (agent_mode or fmt == "json"):
262
278
  write(json.dumps(result["data"], indent=2))
279
+ _note_unclaimed_key(api_key, settings)
263
280
  return Exit.OK
264
281
 
265
282
  if agent_mode:
@@ -274,6 +291,7 @@ def dispatch(argv: list[str]) -> int:
274
291
  indent=2,
275
292
  )
276
293
  )
294
+ _note_unclaimed_key(api_key, settings)
277
295
  return result.get("exit_code", Exit.OK)
278
296
 
279
297
  # `completion` and `mcp` emit text that must not be decorated or reshaped.
@@ -281,9 +299,13 @@ def dispatch(argv: list[str]) -> int:
281
299
  text = result["text"]()
282
300
  if text:
283
301
  write(text)
302
+ # No reminder here: `completion` and `mcp` emit text that gets pasted
303
+ # into a shell profile or a config file, and a stray line would be
304
+ # pasted too.
284
305
  return result.get("exit_code", Exit.OK)
285
306
 
286
307
  emit(fmt=fmt, data=result["data"], text=result["text"])
308
+ _note_unclaimed_key(api_key, settings)
287
309
  return result.get("exit_code", Exit.OK)
288
310
 
289
311
  except CliError as error:
@@ -92,14 +92,38 @@
92
92
  {
93
93
  "name": "init",
94
94
  "summary": "Store a credential and pick a default user and project.",
95
- "description": "Prompts for an API key, verifies it against the API, then writes a profile. The key is never written to the config file in plain text: it goes to the OS keychain where one is reachable, and otherwise to an encrypted file readable only by you.",
96
- "usage": "memorysync init [--api-key <key>] [--user <id>] [--project <id>] [--force]",
95
+ "description": "Prompts for an API key, verifies it against the API, then writes a profile. The key is never written to the config file in plain text: it goes to the OS keychain where one is reachable, and otherwise to an encrypted file readable only by you.\n\nWith --agent it mints an evaluation key instead of asking for one, so a coding agent can get working memory with no email, no verification code and no human. That key expires after seven days and nobody owns it until somebody claims it with --email.",
96
+ "usage": "memorysync init [--api-key <key> | --agent | --email <address>] [--user <id>] [--project <id>] [--force]",
97
97
  "flags": [
98
98
  {
99
99
  "name": "--api-key",
100
100
  "value": "key",
101
101
  "description": "Skip the prompt and use this key."
102
102
  },
103
+ {
104
+ "name": "--agent",
105
+ "description": "Mint an evaluation key with no signup. Expires in 7 days unless claimed."
106
+ },
107
+ {
108
+ "name": "--agent-caller",
109
+ "value": "name",
110
+ "description": "Which tool is minting the key, e.g. claude-code. Attribution only."
111
+ },
112
+ {
113
+ "name": "--email",
114
+ "value": "address",
115
+ "description": "Claim the current evaluation account. Sends a code, then pass it with --code."
116
+ },
117
+ {
118
+ "name": "--code",
119
+ "value": "code",
120
+ "description": "The claim code from the email."
121
+ },
122
+ {
123
+ "name": "--password",
124
+ "value": "password",
125
+ "description": "Set a dashboard password while claiming. Optional."
126
+ },
103
127
  {
104
128
  "name": "--user",
105
129
  "short": "-u",
@@ -124,7 +148,29 @@
124
148
  ],
125
149
  "examples": [
126
150
  "memorysync init",
127
- "memorysync init --api-key ms_live_xxx --user alice --force"
151
+ "memorysync init --api-key ms_live_xxx --user alice --force",
152
+ "memorysync init --agent --agent-caller claude-code",
153
+ "memorysync init --email you@example.com",
154
+ "memorysync init --email you@example.com --code K7MP-3XQR"
155
+ ],
156
+ "requires_auth": false
157
+ },
158
+ {
159
+ "name": "identify",
160
+ "summary": "Name the end user that later commands default to.",
161
+ "description": "An evaluation key starts with a generated end-user id like swift-otter-4821, because a key minted by an agent has nobody to name it after. This replaces it with something meaningful.\n\nOnly changes the local default: memories already stored under the old id keep that id, so pass --user explicitly to read them back.",
162
+ "usage": "memorysync identify <name> [--profile <name>]",
163
+ "flags": [
164
+ {
165
+ "name": "--profile",
166
+ "short": "-p",
167
+ "value": "name",
168
+ "description": "Profile to change. Default \"default\"."
169
+ }
170
+ ],
171
+ "examples": [
172
+ "memorysync identify alice",
173
+ "memorysync identify alice@example.com"
128
174
  ],
129
175
  "requires_auth": false
130
176
  },
@@ -1 +0,0 @@
1
- __version__ = "1.0.3"
@@ -1,109 +0,0 @@
1
- """init: store a credential and pick defaults.
2
-
3
- The key is verified against the API before it is written. Storing an unverified key
4
- means the next command fails with something that looks like a network problem, and
5
- the person has no reason to suspect the thing they just typed.
6
-
7
- It is never written to the config file in plain text. It goes to the OS keychain
8
- where one is reachable, and otherwise to an owner-only encrypted file, and `init`
9
- says which of the two happened rather than implying a keychain that was never
10
- there.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import getpass
16
- import os
17
- import sys
18
-
19
- from .. import config as config_module
20
- from .. import credentials
21
- from ..errors import auth_error, usage_error
22
- from ..http import ApiClient
23
- from ..output import style
24
-
25
-
26
- def init(ctx: dict) -> dict:
27
- flags, settings = ctx["flags"], ctx["settings"]
28
- profile_name = settings["profile_name"]
29
-
30
- existing = config_module.profile(profile_name)
31
- if existing and not flags.get("force"):
32
- stored = credentials.read_key(profile_name)
33
- if stored:
34
- raise usage_error(
35
- f'Profile "{profile_name}" already has a key.',
36
- "Pass --force to replace it, or --profile <name> to add another.",
37
- )
38
-
39
- api_key = flags.get("api_key") or os.environ.get("MEMORYSYNC_API_KEY")
40
- if not api_key:
41
- if not sys.stdin.isatty():
42
- raise usage_error(
43
- "No API key given and no terminal to prompt from.",
44
- "Pass --api-key, or set MEMORYSYNC_API_KEY.",
45
- )
46
- # getpass, so the key is not echoed into the terminal or the scrollback.
47
- api_key = getpass.getpass("MemorySync API key: ").strip()
48
-
49
- if not api_key:
50
- raise usage_error("No API key given.")
51
-
52
- # Verified before storing. A key that cannot read its own plan is not usable,
53
- # and finding that out now is far cheaper than on the next command.
54
- probe = ApiClient(
55
- base_url=settings["base_url"],
56
- api_key=api_key,
57
- timeout=settings["timeout"],
58
- )
59
- try:
60
- plan = probe.current_plan() or {}
61
- except Exception as error: # noqa: BLE001 - re-raised as an auth failure below
62
- raise auth_error(
63
- f"That key was refused: {error}",
64
- "Check it in the dashboard, or pass --base-url if you are not on production.",
65
- ) from None
66
-
67
- tier = credentials.write_key(api_key, profile_name)
68
-
69
- data = config_module.load()
70
- entry = data.setdefault("profiles", {}).setdefault(profile_name, {})
71
- if flags.get("user"):
72
- entry["user"] = flags["user"]
73
- if flags.get("project"):
74
- entry["project"] = flags["project"]
75
- if settings["base_url"] != config_module.DEFAULT_BASE_URL:
76
- entry["base_url"] = settings["base_url"]
77
- path = config_module.save(data)
78
-
79
- payload = {
80
- "profile": profile_name,
81
- "stored_in": tier,
82
- "config": str(path),
83
- "user": entry.get("user"),
84
- "project": entry.get("project"),
85
- "plan": plan.get("plan") or plan.get("name"),
86
- }
87
-
88
- def render() -> str:
89
- where = (
90
- "the OS keychain"
91
- if tier == "keychain"
92
- else "an owner-only encrypted file"
93
- )
94
- lines = [
95
- f"{style.green('Ready.')} Profile {style.bold(profile_name)} is set up.",
96
- f"{style.dim('key ')} stored in {where}",
97
- f"{style.dim('config ')} {path}",
98
- ]
99
- if entry.get("user"):
100
- lines.append(f"{style.dim('user ')} {entry['user']}")
101
- else:
102
- lines.append(
103
- style.dim("no default user; pass --user on each call or re-run with --user")
104
- )
105
- if payload["plan"]:
106
- lines.append(f"{style.dim('plan ')} {payload['plan']}")
107
- return "\n".join(lines)
108
-
109
- return {"data": payload, "text": render}
File without changes
File without changes