memorysync-cli 1.1.1__tar.gz → 1.1.2__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.1 → memorysync_cli-1.1.2}/PKG-INFO +1 -1
  2. memorysync_cli-1.1.2/src/memorysync_cli/_version.py +1 -0
  3. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/args.py +32 -21
  4. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/admin.py +145 -65
  5. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/init.py +14 -9
  6. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/config.py +23 -10
  7. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/credentials.py +29 -12
  8. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/errors.py +10 -18
  9. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/main.py +49 -5
  10. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/output.py +19 -5
  11. memorysync_cli-1.1.1/src/memorysync_cli/_version.py +0 -1
  12. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/.gitignore +0 -0
  13. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/LICENSE +0 -0
  14. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/README.md +0 -0
  15. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/pyproject.toml +0 -0
  16. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/__init__.py +0 -0
  17. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/__main__.py +0 -0
  18. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/__init__.py +0 -0
  19. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/memory.py +0 -0
  20. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/source.py +0 -0
  21. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/tooling.py +0 -0
  22. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/completions.py +0 -0
  23. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/evaluation.py +0 -0
  24. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/http.py +0 -0
  25. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/registry.json +0 -0
  26. {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/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.1
3
+ Version: 1.1.2
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.2"
@@ -27,29 +27,40 @@ from __future__ import annotations
27
27
 
28
28
  from typing import Any
29
29
 
30
+ from . import registry
30
31
  from .errors import usage_error
31
32
 
32
- # Flags that take a value. Everything else is boolean. Kept in step with the
33
- # Node CLI's VALUED set; the parity test asserts every registry flag is known
34
- # here, so a flag added there cannot go unhandled.
35
- VALUED = {
36
- "profile",
37
- "api-key",
38
- "base-url",
39
- "user",
40
- "project",
41
- "output",
42
- "timeout",
43
- "metadata",
44
- "file",
45
- "source",
46
- "limit",
47
- "importance",
48
- "batch-size",
49
- "out",
50
- "format",
51
- "query",
52
- }
33
+
34
+ def _collect_valued() -> set[str]:
35
+ """Flags that take a value. Everything else is boolean.
36
+
37
+ Derived from the shared registry rather than hand-listed, matching
38
+ ``sdk/cli/src/args.mjs``. The registry already marks a value-taking flag with
39
+ ``value``, and keeping a second copy of that fact here meant the two could
40
+ disagree - which they did: ``--email``, ``--code``, ``--password`` and
41
+ ``--agent-caller`` were declared with a ``value`` but missing from the set, so
42
+ ``--email you@example.com`` parsed as ``{"email": True}`` and sent the string
43
+ "true" to the API. ``--password`` was worse: the account password became
44
+ "true" and the real one was left in shell history as a positional.
45
+
46
+ A flag is valued if any declaration of it says so, including on a subcommand.
47
+ """
48
+ valued: set[str] = set()
49
+
50
+ def visit(flags: list[dict[str, Any]] | None) -> None:
51
+ for flag in flags or []:
52
+ if flag.get("value"):
53
+ valued.add(flag["name"].lstrip("-"))
54
+
55
+ visit(registry.global_flags())
56
+ for spec in registry.commands().values():
57
+ visit(spec.get("flags"))
58
+ for sub in spec.get("subcommands", []):
59
+ visit(sub.get("flags"))
60
+ return valued
61
+
62
+
63
+ VALUED = _collect_valued()
53
64
 
54
65
  SHORT = {
55
66
  "p": "profile",
@@ -10,11 +10,14 @@ someone spends an afternoon debugging an empty search.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import json
14
+ import math
13
15
  import os
14
16
  import platform
15
17
  import sys
16
18
  import time
17
- from typing import Any
19
+ from datetime import datetime, timezone
20
+ from typing import Any, Mapping
18
21
 
19
22
  from .. import config as config_module
20
23
  from .. import credentials
@@ -30,27 +33,94 @@ from ..output import render_table, style
30
33
  #: ``api_key`` is deliberately absent. Keys go to the keychain through ``init``, and
31
34
  #: accepting one here would write a credential into a file people paste into bug
32
35
  #: reports.
33
- _SETTABLE = frozenset({"user", "project", "base_url", "output", "timeout"})
36
+ #: Declaration order, for the error message. A frozenset has no order, and
37
+ #: sorting it put the keys in a different order from the Node CLI's message.
38
+ _SETTABLE_ORDER = ("user", "project", "base_url", "output", "timeout")
39
+ _SETTABLE = frozenset(_SETTABLE_ORDER)
34
40
 
35
41
 
36
- def _percent(used: Any, limit: Any) -> str:
37
- if not isinstance(used, (int, float)) or not isinstance(limit, (int, float)) or limit <= 0:
38
- return "-"
39
- return f"{min(100, round(used / limit * 100))}%"
42
+ def _quota_rows(usage: Mapping[str, Any]) -> list[dict[str, Any]]:
43
+ """Per-metric usage with a percentage. Mirrors the Node CLI's ``quotaRows``.
44
+
45
+ ``percent`` is a number rounded to one decimal, not a preformatted string. It
46
+ used to be ``"1%"`` here and ``0.5`` in the Node CLI, so an agent reading
47
+ ``--json`` from the two got different types for the same field.
48
+ """
49
+ rows = []
50
+ for entry in usage.get("metrics") or []:
51
+ if not isinstance(entry, dict):
52
+ continue
53
+ limit = entry.get("limit")
54
+ used = entry.get("used")
55
+ if isinstance(limit, (int, float)) and limit and isinstance(used, (int, float)):
56
+ percent = min(100, round(used / limit * 1000) / 10)
57
+ # Match JavaScript, where 0.5 prints as "0.5" and 12.0 prints as "12".
58
+ if percent == int(percent):
59
+ percent = int(percent)
60
+ else:
61
+ limit = None
62
+ percent = 0
63
+ rows.append(
64
+ {
65
+ "metric": entry.get("metric"),
66
+ "label": entry.get("label"),
67
+ "used": used,
68
+ "limit": limit,
69
+ "percent": percent,
70
+ }
71
+ )
72
+ return rows
40
73
 
41
74
 
42
- def _evaluation_usage_summary(api: Any) -> dict:
75
+ def _bar(percent: float, width: int = 24) -> str:
76
+ """The usage bar, identical to the Node CLI's ``bar``.
77
+
78
+ Green under 80%, yellow from 80, red at 100. The filled portion is coloured and
79
+ the remainder dimmed, so the shape reads the same without colour.
80
+ """
81
+ filled = max(0, min(width, round(percent / 100 * width)))
82
+ if percent >= 100:
83
+ painter = style.red
84
+ elif percent >= 80:
85
+ painter = style.yellow
86
+ else:
87
+ painter = style.green
88
+ return painter("\u2588" * filled) + style.dim("\u2591" * (width - filled))
89
+
90
+
91
+ def _days_until(iso: Any) -> int | None:
92
+ """Whole days from now until *iso*, or ``None`` when it cannot be read."""
93
+ if not iso:
94
+ return None
95
+ try:
96
+ text = str(iso).replace("Z", "+00:00")
97
+ end = datetime.fromisoformat(text)
98
+ except ValueError:
99
+ return None
100
+ if end.tzinfo is None:
101
+ end = end.replace(tzinfo=timezone.utc)
102
+ seconds = (end - datetime.now(timezone.utc)).total_seconds()
103
+ return max(0, math.ceil(seconds / 86400))
104
+
105
+
106
+ def _evaluation_usage_summary(api: Any) -> tuple[dict, dict]:
43
107
  """Reshape ``/evaluation/usage`` into what ``quota`` already renders.
44
108
 
45
109
  Same keys the billing summary returns, so the renderer and ``--json`` consumers
46
110
  do not have to care which kind of key they are looking at. Mirrors the Node
47
- CLI's ``evaluationQuota``.
111
+ CLI's ``evaluationQuota``, including the cycle fields this used to drop - so
112
+ ``days_until_reset`` and ``resets_at`` came back null under this CLI and
113
+ populated under the Node one.
48
114
  """
49
115
  usage = api.evaluation_usage() or {}
50
116
  add = usage.get("add_requests") or {}
51
117
  retrieval = usage.get("retrieval_requests") or {}
52
- return {
118
+ cycle_start = usage.get("cycle_start")
119
+ summary = {
53
120
  "plan_id": usage.get("plan"),
121
+ "billing_period": str(cycle_start)[:7] if cycle_start else None,
122
+ "current_period_end": usage.get("cycle_end"),
123
+ "days_until_reset": _days_until(usage.get("cycle_end")),
54
124
  "metrics": [
55
125
  {
56
126
  "metric": "add_requests",
@@ -65,10 +135,13 @@ def _evaluation_usage_summary(api: Any) -> dict:
65
135
  "limit": retrieval.get("limit"),
66
136
  },
67
137
  ],
138
+ }
139
+ extra = {
68
140
  "claimed": usage.get("claimed") is True,
69
141
  "expires_at": usage.get("expires_at"),
70
142
  "claim_command": usage.get("claim_command"),
71
143
  }
144
+ return summary, extra
72
145
 
73
146
 
74
147
  def quota(ctx: dict) -> dict:
@@ -79,72 +152,75 @@ def quota(ctx: dict) -> dict:
79
152
  """
80
153
  api = ctx["api"]
81
154
 
155
+ extra: dict[str, Any] = {}
82
156
  if is_evaluation_key(ctx.get("api_key")):
83
157
  # ``/org/billing/usage-summary`` needs the ``billing.view`` capability and
84
158
  # the ``billing:read`` scope, and an evaluation key is granted neither on
85
159
  # purpose: ``billing:read`` also opens ``GET /org/billing/profit``, which
86
160
  # returns cost and margin from our internal pricing model. The server
87
161
  # exposes the same counters without the priced fields, so route there.
88
- summary = _evaluation_usage_summary(api)
162
+ summary, extra = _evaluation_usage_summary(api)
89
163
  else:
90
164
  summary = api.usage_summary() or {}
91
165
 
92
- metrics = [m for m in (summary.get("metrics") or []) if isinstance(m, dict)]
93
-
94
- rows = [
95
- {
96
- "metric": entry.get("metric"),
97
- "used": entry.get("used"),
98
- "limit": entry.get("limit"),
99
- "remaining": (
100
- entry["limit"] - entry["used"]
101
- if isinstance(entry.get("limit"), (int, float))
102
- and isinstance(entry.get("used"), (int, float))
103
- else None
104
- ),
105
- "percent": _percent(entry.get("used"), entry.get("limit")),
106
- }
107
- for entry in metrics
108
- ]
166
+ rows = _quota_rows(summary)
167
+ data = {
168
+ "billing_period": summary.get("billing_period"),
169
+ "plan_id": summary.get("plan_id"),
170
+ "days_until_reset": summary.get("days_until_reset"),
171
+ "resets_at": summary.get("current_period_end"),
172
+ "metrics": rows,
173
+ **extra,
174
+ }
109
175
 
110
176
  def render() -> str:
111
- if not rows:
112
- return style.dim("No usage reported for this billing cycle.")
113
- table = render_table(
114
- ["metric", "used", "limit", "remaining", "used %"],
115
- [
116
- [
117
- row["metric"],
118
- row["used"],
119
- "unlimited" if row["limit"] in (None, 0) else row["limit"],
120
- "-" if row["remaining"] is None else row["remaining"],
121
- row["percent"],
122
- ]
123
- for row in rows
124
- ],
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]}."
177
+ lines = [
178
+ f"{style.bold('Plan')} {summary.get('plan_id')} "
179
+ f"{style.dim('period ' + str(summary.get('billing_period')))}",
180
+ "",
181
+ ]
182
+ for row in rows:
183
+ limit = "unlimited" if row["limit"] is None else f"{row['limit']:,}"
184
+ suffix = "" if row["limit"] is None else f" {row['percent']}%"
185
+ label = str(row["label"] or "").ljust(20)
186
+ lines.append(
187
+ f"{label} {_bar(row['percent'])} {str(row['used']).rjust(7)} / {limit}{suffix}"
133
188
  )
134
189
 
135
- exhausted = [r["metric"] for r in rows if r["remaining"] == 0]
190
+ exhausted = [
191
+ r for r in rows if r["limit"] is not None and (r["used"] or 0) >= r["limit"]
192
+ ]
193
+ near = [
194
+ r
195
+ for r in rows
196
+ if r["limit"] is not None and r["percent"] >= 80 and (r["used"] or 0) < r["limit"]
197
+ ]
198
+ lines.append("")
136
199
  if exhausted:
137
- warning = style.yellow(
138
- "At the limit: " + ", ".join(str(m) for m in exhausted) + "."
200
+ lines.append(
201
+ style.red(
202
+ " and ".join(str(r["label"]) for r in exhausted)
203
+ + " exhausted. Calls now return empty results instead of errors, "
204
+ "so nothing is being stored or retrieved."
205
+ )
206
+ )
207
+ elif near:
208
+ lines.append(
209
+ style.yellow(" and ".join(str(r["label"]) for r in near) + " above 80%.")
139
210
  )
140
- note = style.dim(
141
- "Writes and reads return empty rather than failing, so check here "
142
- "before assuming memory is missing."
211
+ if summary.get("days_until_reset") is not None:
212
+ lines.append(style.dim(f"Resets in {summary['days_until_reset']} day(s)."))
213
+ if extra and not extra.get("claimed") and extra.get("expires_at"):
214
+ # An evaluation allowance does not reset on the cycle: the key stops
215
+ # working at expiry, so the date that matters is that one.
216
+ lines.append(
217
+ style.yellow(
218
+ f"Unclaimed evaluation key, expires {str(extra['expires_at'])[:10]}."
219
+ )
143
220
  )
144
- return f"{table}\n\n{warning}\n{note}{expiry_note}"
145
- return f"{table}{expiry_note}"
221
+ return "\n".join(lines)
146
222
 
147
- return {"data": rows, "text": render}
223
+ return {"data": data, "text": render}
148
224
 
149
225
 
150
226
  def status(ctx: dict) -> dict:
@@ -372,14 +448,16 @@ def config_command(ctx: dict) -> dict:
372
448
  data = config_module.redacted()
373
449
  return {
374
450
  "data": data,
451
+ # Three labelled lines then the profiles as JSON, matching the Node CLI.
452
+ # This used to print a `path` line and a one-line-per-profile summary,
453
+ # which told the user less and matched nothing.
375
454
  "text": lambda: "\n".join(
376
455
  [
377
- f"{style.dim('path')} {data['path']}",
378
- *(
379
- f"{style.dim((' ' + name).ljust(18))} "
380
- + ", ".join(f"{k}={v}" for k, v in entry.items())
381
- for name, entry in data["profiles"].items()
382
- ),
456
+ f"{style.dim('file ')} {config_module.config_path()}",
457
+ f"{style.dim('active profile ')} {data.get('current_profile')}",
458
+ f"{style.dim('key storage ')} {credentials.describe_storage()}",
459
+ "",
460
+ json.dumps(data.get("profiles", {}), indent=2),
383
461
  ]
384
462
  ),
385
463
  }
@@ -473,7 +551,9 @@ def config_command(ctx: dict) -> dict:
473
551
  if key not in _SETTABLE:
474
552
  raise usage_error(
475
553
  f'"{key}" is not settable.',
476
- f"Settable keys: {', '.join(sorted(_SETTABLE))}. The API key is "
554
+ # Declaration order, not sorted: the Node CLI lists them in the
555
+ # order they are declared and the two messages have to match.
556
+ f"Settable keys: {', '.join(_SETTABLE_ORDER)}. The API key is "
477
557
  "never stored here; use `memorysync init`.",
478
558
  )
479
559
  if sub == "set" and len(positionals) < 3:
@@ -75,21 +75,22 @@ def _init_as_agent(ctx: dict, profile_name: str) -> dict:
75
75
  "claim_command": minted.get("claim_command") or CLAIM_HINT,
76
76
  "limits": minted.get("limits"),
77
77
  "mcp_url": minted.get("mcp_url"),
78
- "stored_in": tier,
79
- "config": str(path),
78
+ # `base_url` and `credential_storage`, matching the Node CLI. This used to
79
+ # report `stored_in` ("file" / "keychain") and `config` (the config file
80
+ # path), so an agent reading the envelope from one CLI could not find the
81
+ # fields it found in the other.
82
+ "base_url": settings["base_url"],
83
+ "credential_storage": credentials.describe_storage(profile_name),
80
84
  }
81
85
 
82
86
  def render() -> str:
83
- where = (
84
- "the OS keychain" if tier == "keychain" else "an owner-only encrypted file"
85
- )
86
87
  return "\n".join(
87
88
  [
88
89
  style.green("Agent mode active."),
89
90
  f"{style.dim('default user')} {payload['default_user_id'] or '-'}",
90
91
  f"{style.dim('plan ')} {payload['plan'] or '-'}",
91
92
  f"{style.dim('expires ')} {payload['expires_at'] or '-'}",
92
- f"{style.dim('key stored ')} in {where}",
93
+ f"{style.dim('key stored ')} {credentials.describe_storage(profile_name)}",
93
94
  "",
94
95
  style.dim('Try: memorysync add "I prefer TypeScript"'),
95
96
  "",
@@ -100,7 +101,9 @@ def _init_as_agent(ctx: dict, profile_name: str) -> dict:
100
101
  ]
101
102
  )
102
103
 
103
- return {"data": payload, "text": render}
104
+ # The profile was just rewritten, so the envelope's scope must be re-read
105
+ # rather than reported from the settings resolved before this ran.
106
+ return {"data": payload, "text": render, "rescope": True}
104
107
 
105
108
 
106
109
  def _init_by_claiming(ctx: dict, profile_name: str) -> dict:
@@ -224,7 +227,9 @@ def identify(ctx: dict) -> dict:
224
227
  lines.append(style.dim("Nothing was stored under a different id."))
225
228
  return "\n".join(lines)
226
229
 
227
- return {"data": payload, "text": render}
230
+ # `identify` exists to change the default end user, so the scope it reports
231
+ # has to be the new one.
232
+ return {"data": payload, "text": render, "rescope": True}
228
233
 
229
234
 
230
235
  def init(ctx: dict) -> dict:
@@ -330,4 +335,4 @@ def init(ctx: dict) -> dict:
330
335
  lines.append(f"{style.dim('plan ')} {payload['plan']}")
331
336
  return "\n".join(lines)
332
337
 
333
- return {"data": payload, "text": render}
338
+ return {"data": payload, "text": render, "rescope": True}
@@ -55,7 +55,9 @@ def config_path() -> Path:
55
55
 
56
56
 
57
57
  def _empty() -> dict[str, Any]:
58
- return {"version": CONFIG_VERSION, "profiles": {}, "current_profile": "default"}
58
+ # Key order matches the Node CLI's EMPTY, so the config file each writes is
59
+ # byte-identical for the same content and `config show --json` agrees.
60
+ return {"version": CONFIG_VERSION, "current_profile": "default", "profiles": {}}
59
61
 
60
62
 
61
63
  def load() -> dict[str, Any]:
@@ -77,19 +79,22 @@ def load() -> dict[str, Any]:
77
79
 
78
80
  return {
79
81
  "version": parsed.get("version", CONFIG_VERSION),
80
- "profiles": parsed.get("profiles") if isinstance(parsed.get("profiles"), dict) else {},
81
82
  # Preserved, not dropped. This key was being discarded on read, so the
82
83
  # Python CLI had no concept of an active profile at all: `config
83
84
  # use-profile work` wrote it and every later command still used "default",
84
85
  # and a profile switched under the Node CLI was ignored here. The two are
85
86
  # documented as interchangeable and share ~/.memorysync, so that was a real
86
87
  # divergence rather than a missing nicety.
88
+ #
89
+ # Ordered before `profiles` to match the Node CLI, so the file both write
90
+ # and the JSON both print come out in the same order.
87
91
  "current_profile": (
88
92
  parsed.get("current_profile")
89
93
  if isinstance(parsed.get("current_profile"), str)
90
94
  and parsed.get("current_profile")
91
95
  else "default"
92
96
  ),
97
+ "profiles": parsed.get("profiles") if isinstance(parsed.get("profiles"), dict) else {},
93
98
  }
94
99
 
95
100
 
@@ -190,11 +195,19 @@ def redacted(config: dict[str, Any] | None = None) -> dict[str, Any]:
190
195
  future field cannot be added and printed by accident.
191
196
  """
192
197
  data = config or load()
193
- return {
194
- "version": data.get("version", CONFIG_VERSION),
195
- "path": str(config_path()),
196
- "profiles": {
197
- name: {key: value for key, value in entry.items() if key != "api_key"}
198
- for name, entry in data.get("profiles", {}).items()
199
- },
200
- }
198
+
199
+ profiles: dict[str, Any] = {}
200
+ for name, entry in data.get("profiles", {}).items():
201
+ profiles[name] = dict(entry)
202
+ # An older version may have written a key here. Never echo it, and say so
203
+ # rather than dropping the field silently, which left the user with no clue
204
+ # that a credential was sitting in a plain-text file.
205
+ if profiles[name].get("api_key"):
206
+ profiles[name]["api_key"] = (
207
+ "(stored insecurely - run `memorysync init` to move it)"
208
+ )
209
+
210
+ # Every other key passes through, so `current_profile` reaches `config show`.
211
+ # This used to return a fixed set that omitted it and added `path`, so
212
+ # `config show --json` disagreed with the Node CLI on both counts.
213
+ return {**data, "version": data.get("version", CONFIG_VERSION), "profiles": profiles}
@@ -12,11 +12,25 @@ was not true on any platform:
12
12
  * keychain (macOS/Linux) shared. Both use service ``memorysync-cli`` with the
13
13
  profile name as the account. The account strings used
14
14
  to differ, so neither found the other's entry.
15
- * file (Windows) NOT shared, and cannot be. Node encrypts with
16
- AES-256-GCM from ``node:crypto``; Python's standard
17
- library has no AES and this package takes no
18
- dependencies, so the two cannot read each other's
19
- blob. Running ``init`` under each is required.
15
+ * file (Windows) shared, as of CLI 1.1.2. Node's own file is
16
+ ``<profile>.enc``, AES-256-GCM, which Python's standard
17
+ library cannot read - it has no AES and this package
18
+ ships no dependencies. So the Node CLI now writes this
19
+ module's ``<profile>.pyenc`` alongside its own and
20
+ reads it as a fallback. Its AES file stays primary, so
21
+ nobody has to migrate.
22
+
23
+ Giving up AES for the shared copy costs nothing that
24
+ was being protected: both keys are derived from
25
+ hostname and username, which any process running as
26
+ this user can read. Neither format resists a local
27
+ attacker; both keep the key out of plain text and inert
28
+ on another machine.
29
+
30
+ Before this, ``init`` under npm followed by ``add``
31
+ under pipx answered "No API key found" - and on
32
+ Windows, where neither CLI reaches a keychain, that hit
33
+ everyone who installed both.
20
34
 
21
35
  The file also moved from ``<config>/credentials`` to
22
36
  ``<config>/credentials/<profile>.pyenc``. Now that both CLIs share
@@ -270,15 +284,18 @@ def delete_key(profile: str = "default") -> None:
270
284
 
271
285
 
272
286
  def describe_storage(profile: str = "default") -> str:
273
- """Human description of where a key would be stored, for `doctor`.
287
+ """Human description of where a key would be stored.
274
288
 
275
- Says "this CLI only" on the file tier so a user who stored a key with the Node
276
- CLI is told why it is not visible here, rather than being left to conclude the
277
- key was never saved.
289
+ Matches the Node CLI's ``storageDescription()`` word for word. It used to read
290
+ "this CLI only", which was accurate at the time and is not any more: the Node
291
+ CLI now writes this same ``.pyenc`` file alongside its own, so a key stored by
292
+ either CLI is found by both.
278
293
  """
294
+ if os.environ.get(ENV_VAR):
295
+ return f"environment variable {ENV_VAR}"
279
296
  tool = _keychain_tool()
280
297
  if tool == "security":
281
- return "macOS keychain (security), shared with the Node CLI"
298
+ return "macOS keychain"
282
299
  if tool == "secret-tool":
283
- return "Linux keyring (secret-tool), shared with the Node CLI"
284
- return f"encrypted file, owner-only, this CLI only ({_keyfile(profile)})"
300
+ return "Linux secret service"
301
+ return "encrypted file, readable only by this user on this machine"
@@ -20,8 +20,6 @@ an empty database.
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
- import platform
24
-
25
23
  from .registry import exit_codes
26
24
 
27
25
  _CODES = exit_codes()
@@ -82,23 +80,17 @@ def usage_error(message: str, hint: str | None = None) -> CliError:
82
80
 
83
81
 
84
82
  def _default_auth_hint() -> str:
85
- """Why "no key" can be wrong-looking rather than wrong.
86
-
87
- On Windows the two CLIs cannot share a stored key: Node encrypts with
88
- AES-256-GCM and this package has no AES available without taking a dependency.
89
- Someone who ran `memorysync init` seconds ago -- possibly reaching the *other*
90
- CLI, since both install a binary of that name -- is otherwise told "No API key
91
- found" by a machine that visibly has one, with nothing to act on.
83
+ """The hint shown when no credential can be found.
84
+
85
+ Identical to the Node CLI's. It used to add a Windows-only paragraph saying a
86
+ key stored by one CLI is invisible to the other, which was true when it was
87
+ written and is not any more: as of 1.1.2 the Node CLI writes this package's
88
+ `.pyenc` file alongside its own and reads it back, so either CLI finds a key
89
+ stored by either. Keeping the paragraph would now send people to re-run `init`
90
+ for a problem that no longer exists - and for an evaluation key that costs a
91
+ mint against the five-per-day cap.
92
92
  """
93
- base = "Run `memorysync init` to store a key, or set MEMORYSYNC_API_KEY."
94
- if platform.system() == "Windows":
95
- return (
96
- base
97
- + " On Windows the Node and Python CLIs keep separate key files, so a"
98
- + " key stored by one is not visible to the other; run init here too,"
99
- + " or set MEMORYSYNC_API_KEY, which both read."
100
- )
101
- return base
93
+ return "Run `memorysync init` to store a key, or set MEMORYSYNC_API_KEY."
102
94
 
103
95
 
104
96
  def auth_error(message: str, hint: str | None = None) -> CliError:
@@ -154,8 +154,8 @@ def _reject_unknown_flags(flags: dict, allowed: set[str]) -> None:
154
154
  if unknown:
155
155
  spelled = ", ".join(f"--{key.replace('_', '-')}" for key in unknown)
156
156
  raise usage_error(
157
- f"Unknown flag {spelled} for this command.",
158
- "Run `memorysync help <command>` to see what it accepts.",
157
+ f"Unknown flag {spelled}.",
158
+ "Run the command with --help to see its flags.",
159
159
  )
160
160
 
161
161
 
@@ -174,9 +174,16 @@ def _fail(*, command: str, error: CliError, agent_mode: bool, started: float, se
174
174
  )
175
175
  )
176
176
  else:
177
- print(style.red(f"error: {error.message}"), file=sys.stderr)
177
+ # Three plain lines, matching the Node CLI byte for byte. This used to
178
+ # print `error: ` before the message, indent the hint by two spaces, colour
179
+ # both, and omit the request id entirely - so the two CLIs disagreed on
180
+ # every failure, and the id needed to look up a failure in our logs was
181
+ # only available from one of them.
182
+ print(error.message, file=sys.stderr)
178
183
  if error.hint:
179
- print(style.dim(f" {error.hint}"), file=sys.stderr)
184
+ print(error.hint, file=sys.stderr)
185
+ if error.request_id:
186
+ print(f"Request id: {error.request_id}", file=sys.stderr)
180
187
  return error.exit_code
181
188
 
182
189
 
@@ -286,7 +293,16 @@ def dispatch(argv: list[str]) -> int:
286
293
  command=command_name,
287
294
  data=result["data"],
288
295
  duration_ms=duration_ms,
289
- scope=_scope_of(settings),
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
+ ),
290
306
  ),
291
307
  indent=2,
292
308
  )
@@ -327,8 +343,36 @@ def dispatch(argv: list[str]) -> int:
327
343
  )
328
344
 
329
345
 
346
+ def _force_utf8_streams() -> None:
347
+ """Write UTF-8 regardless of the platform's default encoding.
348
+
349
+ On Windows, an interactive console is UTF-8 capable but a *redirected* stdout
350
+ falls back to the ANSI code page, usually cp1252. That code page cannot encode
351
+ the box-drawing and block characters the tables and quota bars are built from,
352
+ so ``memorysync quota > usage.txt`` died with a UnicodeEncodeError and exit 1
353
+ while the same command printed fine to the terminal - a failure that only
354
+ appears once someone pipes or redirects, which is exactly what a script does.
355
+
356
+ Node has no equivalent problem because it writes UTF-8 to stdout unconditionally,
357
+ so this is also what keeps the two CLIs producing the same bytes.
358
+
359
+ ``errors="replace"`` is a backstop: a console that genuinely cannot represent a
360
+ character should degrade to a placeholder rather than abort a command that has
361
+ already written to the API.
362
+ """
363
+ for stream in (sys.stdout, sys.stderr):
364
+ reconfigure = getattr(stream, "reconfigure", None)
365
+ if reconfigure is None: # pragma: no cover - non-standard stream
366
+ continue
367
+ try:
368
+ reconfigure(encoding="utf-8", errors="replace")
369
+ except (ValueError, OSError): # pragma: no cover - detached stream
370
+ pass
371
+
372
+
330
373
  def run(argv: list[str] | None = None) -> None:
331
374
  """Console-script entry point."""
375
+ _force_utf8_streams()
332
376
  args = list(sys.argv[1:] if argv is None else argv)
333
377
  try:
334
378
  raise SystemExit(dispatch(args))
@@ -143,15 +143,29 @@ def render_table(
143
143
  for i in range(len(headers))
144
144
  ]
145
145
 
146
- def line(cells: Sequence[str], dim: bool) -> str:
146
+ def line(cells: Sequence[str], painter: Any = None) -> str:
147
+ """One row. The painter colours the text but never the padding, so the
148
+ columns line up whether or not colour is on."""
147
149
  parts = []
148
150
  for index, cell in enumerate(cells):
149
151
  padding = " " * max(0, widths[index] - _display_width(cell))
150
- parts.append(cell + padding)
151
- joined = " ".join(parts).rstrip()
152
- return style.dim(joined) if dim else joined
152
+ parts.append((painter(cell) if painter else cell) + padding)
153
+ return " ".join(parts).rstrip()
153
154
 
154
- return "\n".join([line(list(headers), True), *(line(row, False) for row in materialised)])
155
+ # Header, hairline divider, then rows - byte for byte what the Node CLI's
156
+ # renderTable emits. The divider was missing here and the header was dimmed
157
+ # rather than bold, so every table in this CLI looked different from the same
158
+ # table in the Node one. The two are documented as interchangeable, so a
159
+ # script that diffs their output has to see the same bytes.
160
+ divider = " ".join("\u2500" * width for width in widths)
161
+
162
+ return "\n".join(
163
+ [
164
+ line(list(headers), style.bold),
165
+ style.dim(divider),
166
+ *(line(row) for row in materialised),
167
+ ]
168
+ )
155
169
 
156
170
 
157
171
  # ---------------------------------------------------------------------------
@@ -1 +0,0 @@
1
- __version__ = "1.1.1"
File without changes
File without changes