memorysync-cli 1.1.2__tar.gz → 1.1.4__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 (26) hide show
  1. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/PKG-INFO +1 -1
  2. memorysync_cli-1.1.4/src/memorysync_cli/_version.py +1 -0
  3. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/commands/admin.py +2 -2
  4. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/commands/init.py +18 -1
  5. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/commands/memory.py +6 -3
  6. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/http.py +149 -16
  7. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/main.py +21 -18
  8. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/output.py +27 -2
  9. memorysync_cli-1.1.2/src/memorysync_cli/_version.py +0 -1
  10. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/.gitignore +0 -0
  11. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/LICENSE +0 -0
  12. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/README.md +0 -0
  13. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/pyproject.toml +0 -0
  14. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/__init__.py +0 -0
  15. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/__main__.py +0 -0
  16. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/args.py +0 -0
  17. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/commands/__init__.py +0 -0
  18. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/commands/source.py +0 -0
  19. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/commands/tooling.py +0 -0
  20. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/completions.py +0 -0
  21. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/config.py +0 -0
  22. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/credentials.py +0 -0
  23. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/errors.py +0 -0
  24. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/evaluation.py +0 -0
  25. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/src/memorysync_cli/registry.json +0 -0
  26. {memorysync_cli-1.1.2 → memorysync_cli-1.1.4}/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.1.2
3
+ Version: 1.1.4
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.4"
@@ -24,7 +24,7 @@ from .. import credentials
24
24
  from .._version import __version__
25
25
  from ..errors import usage_error
26
26
  from ..evaluation import is_evaluation_key
27
- from ..output import render_table, style
27
+ from ..output import dumps, render_table, style
28
28
 
29
29
  #: Keys ``config set`` / ``config unset`` accept. Identical to the Node CLI's
30
30
  #: ``SETTABLE``; ``output`` was previously missing here, so `config set output table`
@@ -457,7 +457,7 @@ def config_command(ctx: dict) -> dict:
457
457
  f"{style.dim('active profile ')} {data.get('current_profile')}",
458
458
  f"{style.dim('key storage ')} {credentials.describe_storage()}",
459
459
  "",
460
- json.dumps(data.get("profiles", {}), indent=2),
460
+ dumps(data.get("profiles", {})),
461
461
  ]
462
462
  ),
463
463
  }
@@ -147,10 +147,27 @@ def _init_by_claiming(ctx: dict, profile_name: str) -> dict:
147
147
  "Your existing key keeps working and no longer expires. "
148
148
  "Everything stored during the evaluation is still there."
149
149
  ),
150
+ "",
151
+ # Owning the account and being able to open the dashboard are
152
+ # two different things, and this used to stop before saying
153
+ # so. Without ``--password`` there is no credential yet, and
154
+ # the only way in is a link labelled "Forgot password" —
155
+ # which nobody should have to guess at for a password they
156
+ # never had. The server says which case this is; print its
157
+ # sentence rather than deciding here, so the two CLIs and the
158
+ # API cannot drift on the instruction.
159
+ style.yellow(claimed["next_step"])
160
+ if claimed.get("next_step")
161
+ else style.dim(
162
+ "Sign in at https://app.memorysync.io/login with this address."
163
+ ),
150
164
  ]
151
165
  )
152
166
 
153
- return {"data": payload, "text": render_complete}
167
+ # The profile now records that the account is owned, so the reminder must
168
+ # be re-evaluated from disk rather than from the settings resolved before
169
+ # this ran.
170
+ return {"data": payload, "text": render_complete, "rescope": True}
154
171
 
155
172
  # The value ``main`` already resolved from --api-key, the environment, then the
156
173
  # stored profile. Resolving it again here would be a second precedence order
@@ -22,7 +22,7 @@ from typing import Any
22
22
 
23
23
  from ..errors import quota_error, usage_error
24
24
  from ..http import ApiClient
25
- from ..output import render_table, style
25
+ from ..output import dumps, render_table, style
26
26
 
27
27
 
28
28
  def _require_user(ctx: dict) -> str:
@@ -608,12 +608,15 @@ def export_memories(ctx: dict) -> dict:
608
608
 
609
609
 
610
610
  def _serialize_export(memories: list[dict], fmt: str) -> str:
611
+ # Through the shared helper: an export is a file a customer keeps, and
612
+ # escaping every accent to ``\\uXXXX`` made this CLI's export a different
613
+ # document from the Node CLI's for the same memories.
611
614
  if fmt == "json":
612
- return json.dumps(memories, indent=2) + "\n"
615
+ return dumps(memories) + "\n"
613
616
  if fmt == "jsonl":
614
617
  if not memories:
615
618
  return ""
616
- return "\n".join(json.dumps(item) for item in memories) + "\n"
619
+ return "\n".join(dumps(item, indent=None) for item in memories) + "\n"
617
620
 
618
621
  # CSV, quoted by hand rather than through the csv module so the output is
619
622
  # identical to the Node CLI's, which also writes it directly.
@@ -121,13 +121,25 @@ class ApiClient:
121
121
  started = time.monotonic()
122
122
  request = urllib.request.Request(url, data=payload, headers=headers, method=method)
123
123
 
124
+ # Headers are kept, not discarded: the API returns the request id in
125
+ # `x-request-id`, and it is the only handle support has on a failure. The
126
+ # Node CLI printed it and this one did not.
127
+ response_headers: Any = None
128
+ status_reason = ""
129
+
124
130
  try:
125
131
  with urllib.request.urlopen(request, timeout=limit_ms / 1000) as response:
126
132
  text = response.read().decode("utf-8", errors="replace")
127
133
  status = response.status
134
+ response_headers = response.headers
128
135
  except urllib.error.HTTPError as error:
129
136
  text = error.read().decode("utf-8", errors="replace")
130
137
  status = error.code
138
+ response_headers = error.headers
139
+ # The status phrase, so a body with no usable message reads
140
+ # "400 Bad Request" as it does under the Node CLI, rather than this
141
+ # CLI's own wording.
142
+ status_reason = str(getattr(error, "reason", "") or "")
131
143
  except TimeoutError:
132
144
  raise network_error(
133
145
  f"Request to {path} timed out after {limit_ms}ms.",
@@ -156,25 +168,129 @@ class ApiClient:
156
168
  if 200 <= status < 300:
157
169
  return parsed
158
170
 
159
- raise self._to_error(status, parsed)
171
+ raise self._to_error(status, parsed, response_headers, status_reason)
172
+
173
+ @staticmethod
174
+ def _request_id(payload: Any, headers: Any) -> str | None:
175
+ from_header = headers.get("x-request-id") if headers is not None else None
176
+ if from_header:
177
+ return from_header
178
+ if isinstance(payload, dict) and isinstance(payload.get("request_id"), str):
179
+ return payload["request_id"]
180
+ return None
181
+
182
+ def _from_oauth_error(
183
+ self, status: int, body: dict, request_id: str | None, headers: Any
184
+ ) -> CliError:
185
+ """An ``{error, error_description}`` body, as ``/evaluation/*`` returns.
186
+
187
+ The exit code comes from the code rather than the HTTP status, because the
188
+ status cannot separate these: a refused claim and an already-claimed
189
+ account are both 409, and only one of them is worth retrying with
190
+ different input. Mirrors ``#fromOAuthError`` in the Node CLI.
191
+ """
192
+ code = body["error"]
193
+ message = body["error_description"]
194
+
195
+ if code == "rate_limited":
196
+ retry_after = headers.get("retry-after") if headers is not None else None
197
+ return CliError(
198
+ message,
199
+ exit_code=Exit.NETWORK,
200
+ code=code,
201
+ hint=(
202
+ f"Wait {retry_after} seconds and try again."
203
+ if retry_after
204
+ else "Wait and try again."
205
+ ),
206
+ request_id=request_id,
207
+ )
208
+
209
+ # Wrong input the caller can correct, or a state it cannot argue with.
210
+ # Both are exit 2: the fix is a different command, not a retry.
211
+ correctable = {
212
+ "invalid_email",
213
+ "email_already_registered",
214
+ "invalid_code",
215
+ "code_expired",
216
+ "too_many_attempts",
217
+ "not_an_evaluation_key",
218
+ "already_claimed",
219
+ "unclaimable",
220
+ }
221
+ if code in correctable:
222
+ return CliError(
223
+ message, exit_code=Exit.USAGE, code=code, request_id=request_id
224
+ )
160
225
 
161
- def _to_error(self, status: int, payload: Any) -> CliError:
226
+ if status in (401, 403):
227
+ return auth_error(message)
228
+ if status == 404:
229
+ return not_found_error(message)
230
+ return CliError(
231
+ message, exit_code=Exit.FAILURE, code=code, request_id=request_id
232
+ )
233
+
234
+ def _to_error(
235
+ self,
236
+ status: int,
237
+ payload: Any,
238
+ headers: Any = None,
239
+ reason: str = "",
240
+ ) -> CliError:
162
241
  """Map a failed response to the taxonomy.
163
242
 
243
+ Mirrors ``#toError`` in ``sdk/cli/src/http.mjs`` step for step. The
244
+ unwrapping below is the part that had drifted: this CLI treated ``detail``
245
+ itself as the carrier and never looked inside ``detail.error``, so the
246
+ nested shape the API uses on several routes lost both its sentence and its
247
+ code and fell through to a generic "Request failed with status 400."
248
+
164
249
  The status alone is not enough for 429: a plan limit and a rate limit both
165
250
  arrive as 429 but mean different things to a script. Only the first is
166
251
  about the plan, and only the first is pointless to retry.
167
252
  """
168
- detail = payload.get("detail") if isinstance(payload, dict) else None
169
- inner = detail if isinstance(detail, dict) else None
170
- message = (
171
- (inner or {}).get("message")
172
- or (detail if isinstance(detail, str) else None)
173
- or (payload.get("message") if isinstance(payload, dict) else None)
174
- or f"Request failed with status {status}."
175
- )
176
- code = (inner or {}).get("code")
253
+ request_id = self._request_id(payload, headers)
254
+
255
+ # Node: `payload?.detail ?? payload`
256
+ detail: Any = payload
257
+ if isinstance(payload, dict) and payload.get("detail") is not None:
258
+ detail = payload["detail"]
259
+
260
+ # `/evaluation/*` answers in the OAuth error shape. Without this branch the
261
+ # sentence in `error_description` was dropped entirely and this CLI
262
+ # reported "Request failed with status 409." for a refused claim, while the
263
+ # Node CLI reported the bare code `email_already_registered`. Neither told
264
+ # the user that the address already has an account.
265
+ if (
266
+ isinstance(detail, dict)
267
+ and isinstance(detail.get("error"), str)
268
+ and isinstance(detail.get("error_description"), str)
269
+ ):
270
+ return self._from_oauth_error(status, detail, request_id, headers)
271
+
272
+ # Node: `detail?.error ?? detail`
273
+ inner: Any = detail
274
+ if isinstance(detail, dict) and detail.get("error") is not None:
275
+ inner = detail["error"]
276
+
277
+ message = inner if isinstance(inner, str) else None
278
+ if message is None and isinstance(inner, dict):
279
+ message = inner.get("message")
280
+ if not message and isinstance(payload, dict):
281
+ message = payload.get("message")
282
+ if not message:
283
+ message = f"{status} {reason}".strip() if reason else (
284
+ f"Request failed with status {status}."
285
+ )
177
286
 
287
+ code = inner.get("code") if isinstance(inner, dict) else None
288
+
289
+ # Every branch below matches the Node CLI's `#toError`, including exit
290
+ # codes. They had drifted: a 500 exited 5 here and 1 there, so a script
291
+ # retrying on "network" retried a server error under one CLI and gave up
292
+ # under the other. The fallback code was `api_error` here and
293
+ # `http_<status>` there, and the request id was dropped here entirely.
178
294
  if status in (401, 403):
179
295
  return auth_error(message)
180
296
  if status == 404:
@@ -188,17 +304,34 @@ class ApiClient:
188
304
  return CliError(
189
305
  message,
190
306
  exit_code=Exit.NETWORK,
191
- code="rate_limited",
307
+ code=code or "rate_limited",
192
308
  hint="Too many requests. Wait and retry.",
309
+ request_id=request_id,
310
+ )
311
+ if code == "MISSING_END_USER_ID":
312
+ return CliError(
313
+ "This command needs an end user.",
314
+ exit_code=Exit.USAGE,
315
+ code=code,
316
+ hint="Pass --user <id>, or set a default with `memorysync init --user <id>`.",
317
+ request_id=request_id,
193
318
  )
194
319
  if status >= 500:
195
320
  return CliError(
196
- message,
197
- exit_code=Exit.NETWORK,
321
+ f"The API returned {status}: {message}",
322
+ exit_code=Exit.FAILURE,
198
323
  code="server_error",
199
- hint="The API failed. Retry, and check status if it persists.",
324
+ hint=(
325
+ f"Quote request id {request_id} to support." if request_id else None
326
+ ),
327
+ request_id=request_id,
200
328
  )
201
- return CliError(message, exit_code=Exit.FAILURE, code=code or "api_error")
329
+ return CliError(
330
+ message,
331
+ exit_code=Exit.FAILURE,
332
+ code=code or f"http_{status}",
333
+ request_id=request_id,
334
+ )
202
335
 
203
336
  # -----------------------------------------------------------------------
204
337
  # Named endpoints, so commands never carry raw paths
@@ -24,6 +24,7 @@ from .errors import CliError, Exit, auth_error, usage_error
24
24
  from .evaluation import claim_reminder
25
25
  from .http import ApiClient
26
26
  from .output import (
27
+ dumps,
27
28
  emit,
28
29
  error_envelope,
29
30
  set_colour_enabled,
@@ -163,7 +164,7 @@ def _fail(*, command: str, error: CliError, agent_mode: bool, started: float, se
163
164
  duration_ms = int((time.monotonic() - started) * 1000)
164
165
  if agent_mode:
165
166
  write(
166
- json.dumps(
167
+ dumps(
167
168
  error_envelope(
168
169
  command=command,
169
170
  error=error,
@@ -277,37 +278,39 @@ def dispatch(argv: list[str]) -> int:
277
278
  result = handler(ctx)
278
279
  duration_ms = int((time.monotonic() - started) * 1000)
279
280
 
281
+ # A command that rewrote the profile has invalidated the settings resolved
282
+ # before it ran, so re-read them once and use them for everything below.
283
+ #
284
+ # Two things depend on this. `init --agent` stores a new default end-user
285
+ # id, and reporting the previous one in `scope` told an agent to keep
286
+ # writing under the identity it had just replaced. And
287
+ # `init --email ... --code` records that the account is now owned, without
288
+ # which the CLI printed "This is an unclaimed evaluation key and expires"
289
+ # immediately after a successful claim — nagging about the exact thing the
290
+ # user had just done.
291
+ effective = _resolve_settings(flags) if result.get("rescope") else settings
292
+
280
293
  # `help` is a discovery contract, not a data result, so its JSON is the
281
294
  # tree itself rather than an envelope around it. An agent bootstrapping
282
295
  # from `help --json` should not have to reach into `.data[0]` for the
283
296
  # command list.
284
297
  if command_name == "help" and (agent_mode or fmt == "json"):
285
- write(json.dumps(result["data"], indent=2))
286
- _note_unclaimed_key(api_key, settings)
298
+ write(dumps(result["data"]))
299
+ _note_unclaimed_key(api_key, effective)
287
300
  return Exit.OK
288
301
 
289
302
  if agent_mode:
290
303
  write(
291
- json.dumps(
304
+ dumps(
292
305
  success_envelope(
293
306
  command=command_name,
294
307
  data=result["data"],
295
308
  duration_ms=duration_ms,
296
- # A command that rewrote the profile has just invalidated
297
- # the settings resolved before it ran. `init --agent` is the
298
- # case that matters: it mints an account and stores a new
299
- # default end-user id, and reporting the previous one told
300
- # an agent to keep writing under the identity it just
301
- # replaced. Memories then split across two ids, which is the
302
- # one failure this whole flow exists to avoid.
303
- scope=_scope_of(
304
- _resolve_settings(flags) if result.get("rescope") else settings
305
- ),
306
- ),
307
- indent=2,
309
+ scope=_scope_of(effective),
310
+ )
308
311
  )
309
312
  )
310
- _note_unclaimed_key(api_key, settings)
313
+ _note_unclaimed_key(api_key, effective)
311
314
  return result.get("exit_code", Exit.OK)
312
315
 
313
316
  # `completion` and `mcp` emit text that must not be decorated or reshaped.
@@ -321,7 +324,7 @@ def dispatch(argv: list[str]) -> int:
321
324
  return result.get("exit_code", Exit.OK)
322
325
 
323
326
  emit(fmt=fmt, data=result["data"], text=result["text"])
324
- _note_unclaimed_key(api_key, settings)
327
+ _note_unclaimed_key(api_key, effective)
325
328
  return result.get("exit_code", Exit.OK)
326
329
 
327
330
  except CliError as error:
@@ -204,7 +204,9 @@ def _yaml_scalar(value: Any) -> str:
204
204
  # Sexagesimal: `12:30` is a number to a 1.1 parser.
205
205
  or _SEXAGESIMAL.match(text) is not None
206
206
  )
207
- return json.dumps(text) if ambiguous else text
207
+ # Quoted through the shared helper so a value needing quotes in YAML is not
208
+ # also ASCII-escaped, which the Node CLI does not do.
209
+ return dumps(text, indent=None) if ambiguous else text
208
210
 
209
211
 
210
212
  def to_yaml(value: Any, indent: int = 0) -> str:
@@ -316,13 +318,36 @@ def write(text: str) -> None:
316
318
  sys.stdout.write(f"{text}\n")
317
319
 
318
320
 
321
+ def dumps(data: Any, *, indent: int | None = 2) -> str:
322
+ """JSON exactly as the Node CLI's ``JSON.stringify`` writes it.
323
+
324
+ ``ensure_ascii=False`` is the whole point. Python escapes non-ASCII by
325
+ default, so an em-dash came out as ``\\u2014`` here and as the character
326
+ itself there — the same document in different bytes, from two CLIs published
327
+ as interchangeable. It showed up on a claim response whose sentence contains
328
+ one, and it would have applied to every ``--json`` payload carrying any
329
+ non-ASCII text, which for a memory store is most of them: any customer
330
+ memory containing an accent, a dash or an emoji.
331
+
332
+ Separators are pinned too, because Python's defaults leave a trailing space
333
+ after ``,`` when no indent is given and JavaScript does not.
334
+
335
+ Every user-facing JSON write goes through here so the two cannot drift again.
336
+ Request bodies deliberately do not: those are read by our own API, which
337
+ decodes both spellings to the same string, and no user ever sees them.
338
+ """
339
+ if indent is None:
340
+ return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
341
+ return json.dumps(data, indent=indent, ensure_ascii=False)
342
+
343
+
319
344
  def emit(*, fmt: str, data: Any, text: Callable[[], str]) -> None:
320
345
  """Render one result.
321
346
 
322
347
  ``text`` stays lazy so a JSON caller never pays to build a table it discards.
323
348
  """
324
349
  if fmt == "json":
325
- write(json.dumps(data, indent=2))
350
+ write(dumps(data))
326
351
  return
327
352
  if fmt == "yaml":
328
353
  write(to_yaml(data))
@@ -1 +0,0 @@
1
- __version__ = "1.1.2"
File without changes
File without changes