memorysync-cli 1.1.3__tar.gz → 1.2.0__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.
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/PKG-INFO +1 -1
- memorysync_cli-1.2.0/src/memorysync_cli/_version.py +1 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/commands/admin.py +2 -2
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/commands/init.py +18 -1
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/commands/memory.py +182 -41
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/http.py +9 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/main.py +21 -18
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/output.py +27 -2
- memorysync_cli-1.1.3/src/memorysync_cli/_version.py +0 -1
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/.gitignore +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/LICENSE +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/README.md +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/pyproject.toml +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/__init__.py +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/__main__.py +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/args.py +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/commands/__init__.py +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/commands/source.py +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/commands/tooling.py +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/completions.py +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/config.py +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/credentials.py +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/errors.py +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/evaluation.py +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/registry.json +0 -0
- {memorysync_cli-1.1.3 → memorysync_cli-1.2.0}/src/memorysync_cli/registry.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.0"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -510,6 +510,27 @@ def import_memories(ctx: dict) -> dict:
|
|
|
510
510
|
records: list[dict] = []
|
|
511
511
|
errors: list[str] = []
|
|
512
512
|
|
|
513
|
+
def _validate(number: int, entry: Any) -> None:
|
|
514
|
+
"""Record one candidate as either usable or an error, in line order."""
|
|
515
|
+
if not isinstance(entry, dict):
|
|
516
|
+
errors.append(f"line {number}: expected an object")
|
|
517
|
+
return
|
|
518
|
+
text = entry.get("text") or entry.get("memory") or entry.get("content")
|
|
519
|
+
# Has to be a non-blank string, not merely truthy. ``{"text": 123}``
|
|
520
|
+
# would otherwise be stringified into a memory here and refused by the
|
|
521
|
+
# Node CLI, and the two must agree on what the file means.
|
|
522
|
+
if not isinstance(text, str) or not text.strip():
|
|
523
|
+
errors.append(f"line {number}: no text, memory or content field")
|
|
524
|
+
return
|
|
525
|
+
record: dict[str, Any] = {
|
|
526
|
+
"text": text.strip(),
|
|
527
|
+
"source": entry.get("source") or "cli-import",
|
|
528
|
+
"line": number,
|
|
529
|
+
}
|
|
530
|
+
if isinstance(entry.get("metadata"), dict):
|
|
531
|
+
record["metadata"] = entry["metadata"]
|
|
532
|
+
records.append(record)
|
|
533
|
+
|
|
513
534
|
stripped = raw.strip()
|
|
514
535
|
if stripped.startswith("["):
|
|
515
536
|
try:
|
|
@@ -518,66 +539,183 @@ def import_memories(ctx: dict) -> dict:
|
|
|
518
539
|
raise usage_error(f"{source_path} is not valid JSON: {error}") from None
|
|
519
540
|
if not isinstance(parsed, list):
|
|
520
541
|
raise usage_error(f"{source_path} must contain a JSON array.")
|
|
521
|
-
|
|
542
|
+
for number, entry in enumerate(parsed, start=1):
|
|
543
|
+
_validate(number, entry)
|
|
522
544
|
else:
|
|
523
|
-
|
|
545
|
+
# One ordered pass. Collecting parse failures first and field failures
|
|
546
|
+
# afterwards reported a bad line 3 ahead of a bad line 2, which the Node
|
|
547
|
+
# CLI does not do and which reads as though the file were shuffled.
|
|
524
548
|
for number, line in enumerate(stripped.splitlines(), start=1):
|
|
525
549
|
if not line.strip():
|
|
526
550
|
continue
|
|
527
551
|
try:
|
|
528
|
-
|
|
552
|
+
entry = json.loads(line)
|
|
529
553
|
except ValueError as error:
|
|
530
554
|
errors.append(f"line {number}: {error}")
|
|
555
|
+
continue
|
|
556
|
+
_validate(number, entry)
|
|
531
557
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
558
|
+
# The dry run reports problems; it does not refuse because of them. Checking
|
|
559
|
+
# this before the error gate is the whole point of the flag: someone running
|
|
560
|
+
# --dry-run on a file they suspect is malformed wants the list of what is
|
|
561
|
+
# wrong, not a single error telling them to fix it first.
|
|
562
|
+
if flags.get("dry_run"):
|
|
563
|
+
preview = list(errors[:10])
|
|
564
|
+
trailer = len(errors) - len(preview)
|
|
565
|
+
return {
|
|
566
|
+
"data": {
|
|
567
|
+
"file": source_path,
|
|
568
|
+
"valid": len(records),
|
|
569
|
+
"invalid": len(errors),
|
|
570
|
+
"errors": errors,
|
|
571
|
+
},
|
|
572
|
+
"text": lambda: "\n".join(
|
|
573
|
+
line
|
|
574
|
+
for line in [
|
|
575
|
+
style.yellow("Dry run. Nothing was sent."),
|
|
576
|
+
f"{style.dim('valid ')} {len(records)}",
|
|
577
|
+
f"{style.dim('invalid ')} {len(errors)}",
|
|
578
|
+
*[style.dim(f" {message}") for message in preview],
|
|
579
|
+
style.dim(f" ... and {trailer} more") if trailer > 0 else "",
|
|
580
|
+
style.yellow(
|
|
581
|
+
"A real run would refuse this file. Pass --continue-on-error "
|
|
582
|
+
"to skip bad rows."
|
|
583
|
+
)
|
|
584
|
+
if errors and not flags.get("continue_on_error")
|
|
585
|
+
else "",
|
|
586
|
+
]
|
|
587
|
+
if line
|
|
588
|
+
),
|
|
589
|
+
}
|
|
544
590
|
|
|
545
|
-
if errors:
|
|
591
|
+
if errors and not flags.get("continue_on_error"):
|
|
546
592
|
raise usage_error(
|
|
547
|
-
f"{
|
|
548
|
-
"
|
|
593
|
+
f"{len(errors)} record(s) in {source_path} are not usable.",
|
|
594
|
+
f"First problem: {errors[0]}. Run again with --dry-run to see them all, "
|
|
595
|
+
"or --continue-on-error to skip bad rows.",
|
|
549
596
|
)
|
|
550
597
|
if not records:
|
|
551
598
|
raise usage_error(f"{source_path} contained no records.")
|
|
552
599
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
600
|
+
batch_size = _resolve_batch_size(flags)
|
|
601
|
+
batches = [records[i : i + batch_size] for i in range(0, len(records), batch_size)]
|
|
602
|
+
totals = {
|
|
603
|
+
"total": len(records),
|
|
604
|
+
"imported": 0,
|
|
605
|
+
"memory_ids": [],
|
|
606
|
+
"skipped": 0,
|
|
607
|
+
"failed": 0,
|
|
608
|
+
"unreported": 0,
|
|
609
|
+
"failures": [],
|
|
610
|
+
"batches": len(batches),
|
|
611
|
+
"batch_size": batch_size,
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
for batch in batches:
|
|
615
|
+
before = totals["failed"]
|
|
616
|
+
items = [
|
|
617
|
+
{
|
|
618
|
+
key: value
|
|
619
|
+
for key, value in record.items()
|
|
620
|
+
if key in {"text", "source", "metadata"}
|
|
621
|
+
}
|
|
622
|
+
for record in batch
|
|
623
|
+
]
|
|
624
|
+
try:
|
|
625
|
+
response = api.bulk_add_memories({"items": items}) or {}
|
|
626
|
+
except Exception as error: # noqa: BLE001 — reported, not raised
|
|
627
|
+
# The whole batch never reached the server, so every record in it is
|
|
628
|
+
# unaccounted for. Reported as a line range because that is what the
|
|
629
|
+
# operator needs to retry.
|
|
630
|
+
totals["failed"] += len(batch)
|
|
631
|
+
totals["failures"].append(
|
|
632
|
+
f"lines {batch[0]['line']}-{batch[-1]['line']}: {error}"
|
|
633
|
+
)
|
|
634
|
+
if not flags.get("continue_on_error"):
|
|
635
|
+
break
|
|
636
|
+
continue
|
|
560
637
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
638
|
+
per_item = response.get("results") if isinstance(response, dict) else None
|
|
639
|
+
if not isinstance(per_item, list):
|
|
640
|
+
# The server accepted the batch without per-item detail. Counted as
|
|
641
|
+
# unreported rather than imported: claiming these were stored would
|
|
642
|
+
# be a guess, and a wrong one whenever the batch was not written.
|
|
643
|
+
totals["unreported"] += len(batch)
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
for item in per_item:
|
|
647
|
+
index = item.get("index")
|
|
648
|
+
record = batch[index] if isinstance(index, int) and 0 <= index < len(batch) else None
|
|
649
|
+
where = f"line {record['line']}" if record else f"item {index}"
|
|
650
|
+
status = item.get("status")
|
|
651
|
+
if status == "created":
|
|
652
|
+
totals["imported"] += 1
|
|
653
|
+
totals["memory_ids"].extend(item.get("memory_ids") or [])
|
|
654
|
+
elif status == "skipped":
|
|
655
|
+
totals["skipped"] += 1
|
|
656
|
+
else:
|
|
657
|
+
totals["failed"] += 1
|
|
658
|
+
totals["failures"].append(f"{where}: {item.get('reason') or 'rejected'}")
|
|
659
|
+
|
|
660
|
+
if totals["failed"] > before and not flags.get("continue_on_error"):
|
|
661
|
+
break
|
|
569
662
|
|
|
570
663
|
return {
|
|
571
|
-
"data": {"
|
|
664
|
+
"data": {"file": source_path, "user": user, **totals},
|
|
572
665
|
"text": lambda: "\n".join(
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
style.
|
|
666
|
+
line
|
|
667
|
+
for line in [
|
|
668
|
+
f"{style.green('Imported')} {totals['imported']} of {totals['total']}",
|
|
669
|
+
style.dim(f" {totals['skipped']} skipped as duplicate or low value")
|
|
670
|
+
if totals["skipped"]
|
|
671
|
+
else "",
|
|
672
|
+
style.dim(
|
|
673
|
+
f" {totals['unreported']} accepted without a per-record result"
|
|
674
|
+
)
|
|
675
|
+
if totals["unreported"]
|
|
676
|
+
else "",
|
|
677
|
+
style.red(f" {totals['failed']} failed") if totals["failed"] else "",
|
|
678
|
+
*[style.dim(f" {f}") for f in totals["failures"][:5]],
|
|
679
|
+
style.dim(f" ... and {len(totals['failures']) - 5} more")
|
|
680
|
+
if len(totals["failures"]) > 5
|
|
681
|
+
else "",
|
|
576
682
|
]
|
|
577
|
-
|
|
683
|
+
if line
|
|
684
|
+
),
|
|
578
685
|
}
|
|
579
686
|
|
|
580
687
|
|
|
688
|
+
# Records the server accepts in one ``/memory/bulk-add`` request. The route
|
|
689
|
+
# rejects a larger payload outright, so this is a ceiling and not a preference: a
|
|
690
|
+
# higher ``--batch-size`` is clamped rather than sent and refused.
|
|
691
|
+
SERVER_BULK_MAX = 50
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _resolve_batch_size(flags: dict) -> int:
|
|
695
|
+
"""Effective batch size for this run.
|
|
696
|
+
|
|
697
|
+
Defaults to the server maximum, which is what the flag documents. A value
|
|
698
|
+
above the maximum is clamped instead of rejected — batch size is a transport
|
|
699
|
+
detail, and failing a 10,000-row import because someone asked for 500 per
|
|
700
|
+
request would be pedantry. The effective value is reported back in the result
|
|
701
|
+
so the clamp is visible rather than silent.
|
|
702
|
+
"""
|
|
703
|
+
requested = flags.get("batch_size")
|
|
704
|
+
if requested is None:
|
|
705
|
+
return SERVER_BULK_MAX
|
|
706
|
+
try:
|
|
707
|
+
value = int(str(requested).strip())
|
|
708
|
+
except (TypeError, ValueError):
|
|
709
|
+
value = 0
|
|
710
|
+
if value < 1:
|
|
711
|
+
raise usage_error(
|
|
712
|
+
"--batch-size must be a whole number of at least 1.",
|
|
713
|
+
f"Got {requested!r}. The server accepts at most "
|
|
714
|
+
f"{SERVER_BULK_MAX} records per request.",
|
|
715
|
+
)
|
|
716
|
+
return min(value, SERVER_BULK_MAX)
|
|
717
|
+
|
|
718
|
+
|
|
581
719
|
def export_memories(ctx: dict) -> dict:
|
|
582
720
|
user = _require_user(ctx)
|
|
583
721
|
flags, api = ctx["flags"], ctx["api"]
|
|
@@ -608,12 +746,15 @@ def export_memories(ctx: dict) -> dict:
|
|
|
608
746
|
|
|
609
747
|
|
|
610
748
|
def _serialize_export(memories: list[dict], fmt: str) -> str:
|
|
749
|
+
# Through the shared helper: an export is a file a customer keeps, and
|
|
750
|
+
# escaping every accent to ``\\uXXXX`` made this CLI's export a different
|
|
751
|
+
# document from the Node CLI's for the same memories.
|
|
611
752
|
if fmt == "json":
|
|
612
|
-
return
|
|
753
|
+
return dumps(memories) + "\n"
|
|
613
754
|
if fmt == "jsonl":
|
|
614
755
|
if not memories:
|
|
615
756
|
return ""
|
|
616
|
-
return "\n".join(
|
|
757
|
+
return "\n".join(dumps(item, indent=None) for item in memories) + "\n"
|
|
617
758
|
|
|
618
759
|
# CSV, quoted by hand rather than through the csv module so the output is
|
|
619
760
|
# identical to the Node CLI's, which also writes it directly.
|
|
@@ -368,6 +368,15 @@ class ApiClient:
|
|
|
368
368
|
"""
|
|
369
369
|
return self.request("GET", "/evaluation/usage")
|
|
370
370
|
|
|
371
|
+
def bulk_add_memories(self, body: dict) -> Any:
|
|
372
|
+
"""Ingest many records in one request.
|
|
373
|
+
|
|
374
|
+
Answers ``207`` with a per-item outcome for each record, so a batch is
|
|
375
|
+
routinely part success. The caller has to read ``results`` rather than
|
|
376
|
+
the status code to know what happened to any given record.
|
|
377
|
+
"""
|
|
378
|
+
return self.request("POST", "/memory/bulk-add", body=body)
|
|
379
|
+
|
|
371
380
|
def add_memory(self, body: dict) -> Any:
|
|
372
381
|
return self.request("POST", "/memory/add", body=body)
|
|
373
382
|
|
|
@@ -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
|
-
|
|
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(
|
|
286
|
-
_note_unclaimed_key(api_key,
|
|
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
|
-
|
|
304
|
+
dumps(
|
|
292
305
|
success_envelope(
|
|
293
306
|
command=command_name,
|
|
294
307
|
data=result["data"],
|
|
295
308
|
duration_ms=duration_ms,
|
|
296
|
-
|
|
297
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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.3"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|