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.
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/PKG-INFO +1 -1
- memorysync_cli-1.1.2/src/memorysync_cli/_version.py +1 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/args.py +32 -21
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/admin.py +145 -65
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/init.py +14 -9
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/config.py +23 -10
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/credentials.py +29 -12
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/errors.py +10 -18
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/main.py +49 -5
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/output.py +19 -5
- memorysync_cli-1.1.1/src/memorysync_cli/_version.py +0 -1
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/.gitignore +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/LICENSE +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/README.md +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/pyproject.toml +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/__init__.py +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/__main__.py +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/__init__.py +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/memory.py +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/source.py +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/tooling.py +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/completions.py +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/evaluation.py +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/http.py +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/registry.json +0 -0
- {memorysync_cli-1.1.1 → memorysync_cli-1.1.2}/src/memorysync_cli/registry.py +0 -0
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 = [
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
return f"{table}{expiry_note}"
|
|
221
|
+
return "\n".join(lines)
|
|
146
222
|
|
|
147
|
-
return {"data":
|
|
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('
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
"
|
|
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 ')}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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)
|
|
16
|
-
AES-256-GCM
|
|
17
|
-
library has no AES and this package
|
|
18
|
-
dependencies
|
|
19
|
-
|
|
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
|
|
287
|
+
"""Human description of where a key would be stored.
|
|
274
288
|
|
|
275
|
-
|
|
276
|
-
CLI
|
|
277
|
-
key
|
|
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
|
|
298
|
+
return "macOS keychain"
|
|
282
299
|
if tool == "secret-tool":
|
|
283
|
-
return "Linux
|
|
284
|
-
return
|
|
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
|
-
"""
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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}
|
|
158
|
-
"Run
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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],
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|