memorysync-cli 1.1.0__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.0 → 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.0 → memorysync_cli-1.1.2}/src/memorysync_cli/args.py +32 -21
- memorysync_cli-1.1.2/src/memorysync_cli/commands/admin.py +586 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/init.py +22 -12
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/config.py +44 -11
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/credentials.py +29 -12
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/errors.py +10 -18
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/http.py +11 -1
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/main.py +49 -5
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/output.py +19 -5
- memorysync_cli-1.1.0/src/memorysync_cli/_version.py +0 -1
- memorysync_cli-1.1.0/src/memorysync_cli/commands/admin.py +0 -363
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/.gitignore +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/LICENSE +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/README.md +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/pyproject.toml +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/__init__.py +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/__main__.py +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/__init__.py +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/memory.py +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/source.py +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/commands/tooling.py +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/completions.py +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/evaluation.py +0 -0
- {memorysync_cli-1.1.0 → memorysync_cli-1.1.2}/src/memorysync_cli/registry.json +0 -0
- {memorysync_cli-1.1.0 → 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",
|
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""quota, status, doctor, whoami, project, event, config.
|
|
2
|
+
|
|
3
|
+
``quota`` and ``doctor`` are the two none of the competitors have. Both exist
|
|
4
|
+
because of one API behaviour: over a plan limit the API returns success with an
|
|
5
|
+
empty result rather than an error, so that an assistant never narrates billing
|
|
6
|
+
state to an end user. That is right for the API and unhelpful in a terminal, where
|
|
7
|
+
"nothing came back" and "you are out of quota" need to be distinguishable before
|
|
8
|
+
someone spends an afternoon debugging an empty search.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any, Mapping
|
|
21
|
+
|
|
22
|
+
from .. import config as config_module
|
|
23
|
+
from .. import credentials
|
|
24
|
+
from .._version import __version__
|
|
25
|
+
from ..errors import usage_error
|
|
26
|
+
from ..evaluation import is_evaluation_key
|
|
27
|
+
from ..output import render_table, style
|
|
28
|
+
|
|
29
|
+
#: Keys ``config set`` / ``config unset`` accept. Identical to the Node CLI's
|
|
30
|
+
#: ``SETTABLE``; ``output`` was previously missing here, so `config set output table`
|
|
31
|
+
#: worked under one CLI and was refused by the other.
|
|
32
|
+
#:
|
|
33
|
+
#: ``api_key`` is deliberately absent. Keys go to the keychain through ``init``, and
|
|
34
|
+
#: accepting one here would write a credential into a file people paste into bug
|
|
35
|
+
#: reports.
|
|
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)
|
|
40
|
+
|
|
41
|
+
|
|
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
|
|
73
|
+
|
|
74
|
+
|
|
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]:
|
|
107
|
+
"""Reshape ``/evaluation/usage`` into what ``quota`` already renders.
|
|
108
|
+
|
|
109
|
+
Same keys the billing summary returns, so the renderer and ``--json`` consumers
|
|
110
|
+
do not have to care which kind of key they are looking at. Mirrors the Node
|
|
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.
|
|
114
|
+
"""
|
|
115
|
+
usage = api.evaluation_usage() or {}
|
|
116
|
+
add = usage.get("add_requests") or {}
|
|
117
|
+
retrieval = usage.get("retrieval_requests") or {}
|
|
118
|
+
cycle_start = usage.get("cycle_start")
|
|
119
|
+
summary = {
|
|
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")),
|
|
124
|
+
"metrics": [
|
|
125
|
+
{
|
|
126
|
+
"metric": "add_requests",
|
|
127
|
+
"label": "Memories added",
|
|
128
|
+
"used": add.get("used"),
|
|
129
|
+
"limit": add.get("limit"),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"metric": "retrieval_requests",
|
|
133
|
+
"label": "Retrievals",
|
|
134
|
+
"used": retrieval.get("used"),
|
|
135
|
+
"limit": retrieval.get("limit"),
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
}
|
|
139
|
+
extra = {
|
|
140
|
+
"claimed": usage.get("claimed") is True,
|
|
141
|
+
"expires_at": usage.get("expires_at"),
|
|
142
|
+
"claim_command": usage.get("claim_command"),
|
|
143
|
+
}
|
|
144
|
+
return summary, extra
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def quota(ctx: dict) -> dict:
|
|
148
|
+
"""Plan usage, with headroom made explicit.
|
|
149
|
+
|
|
150
|
+
The one command that turns the API's deliberate silence into a number. None of
|
|
151
|
+
Mem0, Supermemory or Zep expose this from their CLI.
|
|
152
|
+
"""
|
|
153
|
+
api = ctx["api"]
|
|
154
|
+
|
|
155
|
+
extra: dict[str, Any] = {}
|
|
156
|
+
if is_evaluation_key(ctx.get("api_key")):
|
|
157
|
+
# ``/org/billing/usage-summary`` needs the ``billing.view`` capability and
|
|
158
|
+
# the ``billing:read`` scope, and an evaluation key is granted neither on
|
|
159
|
+
# purpose: ``billing:read`` also opens ``GET /org/billing/profit``, which
|
|
160
|
+
# returns cost and margin from our internal pricing model. The server
|
|
161
|
+
# exposes the same counters without the priced fields, so route there.
|
|
162
|
+
summary, extra = _evaluation_usage_summary(api)
|
|
163
|
+
else:
|
|
164
|
+
summary = api.usage_summary() or {}
|
|
165
|
+
|
|
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
|
+
}
|
|
175
|
+
|
|
176
|
+
def render() -> str:
|
|
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}"
|
|
188
|
+
)
|
|
189
|
+
|
|
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("")
|
|
199
|
+
if 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%.")
|
|
210
|
+
)
|
|
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
|
+
)
|
|
220
|
+
)
|
|
221
|
+
return "\n".join(lines)
|
|
222
|
+
|
|
223
|
+
return {"data": data, "text": render}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def status(ctx: dict) -> dict:
|
|
227
|
+
"""Reachability and identity in one call."""
|
|
228
|
+
api = ctx["api"]
|
|
229
|
+
started = time.monotonic()
|
|
230
|
+
plan = api.current_plan() or {}
|
|
231
|
+
latency = int((time.monotonic() - started) * 1000)
|
|
232
|
+
|
|
233
|
+
payload = {
|
|
234
|
+
"api": ctx["settings"]["base_url"],
|
|
235
|
+
"reachable": True,
|
|
236
|
+
"latency_ms": latency,
|
|
237
|
+
"plan": plan.get("plan") or plan.get("name"),
|
|
238
|
+
"cycle_ends": plan.get("current_period_end") or plan.get("cycle_ends"),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
def render() -> str:
|
|
242
|
+
return "\n".join(
|
|
243
|
+
[
|
|
244
|
+
f"{style.dim('api ')} {payload['api']}",
|
|
245
|
+
f"{style.dim('reachable ')} {style.green('yes')} ({latency}ms)",
|
|
246
|
+
f"{style.dim('plan ')} {payload['plan'] or '-'}",
|
|
247
|
+
f"{style.dim('cycle ends ')} {payload['cycle_ends'] or '-'}",
|
|
248
|
+
]
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return {"data": payload, "text": render}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def whoami(ctx: dict) -> dict:
|
|
255
|
+
"""Who this credential is, without printing the credential."""
|
|
256
|
+
settings = ctx["settings"]
|
|
257
|
+
api = ctx["api"]
|
|
258
|
+
plan = api.current_plan() or {}
|
|
259
|
+
|
|
260
|
+
payload = {
|
|
261
|
+
"profile": settings["profile_name"],
|
|
262
|
+
"base_url": settings["base_url"],
|
|
263
|
+
"user": settings.get("user"),
|
|
264
|
+
"project": settings.get("project"),
|
|
265
|
+
"plan": plan.get("plan") or plan.get("name"),
|
|
266
|
+
"credential_source": credentials.describe_storage(settings["profile_name"]),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
def render() -> str:
|
|
270
|
+
return "\n".join(
|
|
271
|
+
f"{style.dim(key.ljust(18))} {value if value not in (None, '') else '-'}"
|
|
272
|
+
for key, value in payload.items()
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return {"data": payload, "text": render}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def doctor(ctx: dict) -> dict:
|
|
279
|
+
"""Diagnose a broken setup and exit 0 either way.
|
|
280
|
+
|
|
281
|
+
Exiting 0 even when checks fail is deliberate: this is a diagnostic, and a
|
|
282
|
+
non-zero exit would make it useless inside `set -e` scripts, which is exactly
|
|
283
|
+
where someone reaches for it.
|
|
284
|
+
"""
|
|
285
|
+
settings = ctx["settings"]
|
|
286
|
+
api = ctx.get("api")
|
|
287
|
+
checks: list[dict[str, Any]] = []
|
|
288
|
+
|
|
289
|
+
def add(name: str, ok: bool | None, detail: str, hint: str | None = None) -> None:
|
|
290
|
+
checks.append(
|
|
291
|
+
{
|
|
292
|
+
"check": name,
|
|
293
|
+
"status": "pass" if ok else ("warn" if ok is None else "fail"),
|
|
294
|
+
"detail": detail,
|
|
295
|
+
**({"hint": hint} if hint else {}),
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
version_ok = sys.version_info >= (3, 9)
|
|
300
|
+
add(
|
|
301
|
+
"python",
|
|
302
|
+
version_ok,
|
|
303
|
+
platform.python_version(),
|
|
304
|
+
None if version_ok else "MemorySync needs Python 3.9 or newer.",
|
|
305
|
+
)
|
|
306
|
+
add("cli", True, f"memorysync-cli {__version__}")
|
|
307
|
+
add("config", True, str(config_module.config_path()))
|
|
308
|
+
add("credential store", True, credentials.describe_storage(settings["profile_name"]))
|
|
309
|
+
|
|
310
|
+
key = credentials.read_key(settings["profile_name"])
|
|
311
|
+
add(
|
|
312
|
+
"api key",
|
|
313
|
+
bool(key),
|
|
314
|
+
f"found ({key[:8]}...)" if key else "not found",
|
|
315
|
+
# Names the cross-CLI case explicitly. Someone who ran `init` moments ago
|
|
316
|
+
# and is told "not found" will otherwise reasonably conclude the CLI is
|
|
317
|
+
# broken, when on Windows the key is simply in the other CLI's store.
|
|
318
|
+
None
|
|
319
|
+
if key
|
|
320
|
+
else (
|
|
321
|
+
"Run `memorysync init`, or set MEMORYSYNC_API_KEY. If you stored a key "
|
|
322
|
+
"with the Node CLI on Windows, it is not shared: the two use different "
|
|
323
|
+
"ciphers, so run init here as well."
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
add(
|
|
327
|
+
"end user",
|
|
328
|
+
bool(settings.get("user")) or None,
|
|
329
|
+
settings.get("user") or "not set",
|
|
330
|
+
None if settings.get("user") else "Memory calls need --user or a stored default.",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if api is not None:
|
|
334
|
+
started = time.monotonic()
|
|
335
|
+
try:
|
|
336
|
+
api.current_plan()
|
|
337
|
+
add("api reachable", True, f"{settings['base_url']} in {int((time.monotonic() - started) * 1000)}ms")
|
|
338
|
+
except Exception as error: # noqa: BLE001 - a diagnostic reports, never raises
|
|
339
|
+
add("api reachable", False, str(error), "Check the network, the base URL and the key.")
|
|
340
|
+
else:
|
|
341
|
+
add("api reachable", None, "skipped, no credential", "Store a key first.")
|
|
342
|
+
|
|
343
|
+
def render() -> str:
|
|
344
|
+
marks = {"pass": style.green("ok "), "warn": style.yellow("warn"), "fail": style.red("fail")}
|
|
345
|
+
lines = []
|
|
346
|
+
for entry in checks:
|
|
347
|
+
lines.append(f"{marks[entry['status']]} {entry['check'].ljust(18)} {entry['detail']}")
|
|
348
|
+
if entry.get("hint"):
|
|
349
|
+
lines.append(f" {style.dim(entry['hint'])}")
|
|
350
|
+
failures = sum(1 for entry in checks if entry["status"] == "fail")
|
|
351
|
+
summary = (
|
|
352
|
+
style.green("Everything checks out.")
|
|
353
|
+
if failures == 0
|
|
354
|
+
else style.yellow(f"{failures} check(s) need attention.")
|
|
355
|
+
)
|
|
356
|
+
return "\n".join([*lines, "", summary])
|
|
357
|
+
|
|
358
|
+
return {"data": checks, "text": render}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def project(ctx: dict) -> dict:
|
|
362
|
+
"""List projects, or show the one in scope."""
|
|
363
|
+
positionals, api = ctx["positionals"], ctx["api"]
|
|
364
|
+
sub = positionals[0] if positionals else "list"
|
|
365
|
+
|
|
366
|
+
if sub not in {"list", "show"}:
|
|
367
|
+
raise usage_error(f'Unknown subcommand "{sub}".', "Try list or show.")
|
|
368
|
+
|
|
369
|
+
raw = api.projects()
|
|
370
|
+
entries = raw if isinstance(raw, list) else (raw or {}).get("projects") or []
|
|
371
|
+
projects = [
|
|
372
|
+
{
|
|
373
|
+
"id": entry.get("id") or entry.get("project_id"),
|
|
374
|
+
"name": entry.get("name"),
|
|
375
|
+
"tenant_id": entry.get("tenant_id"),
|
|
376
|
+
"is_default": entry.get("is_default"),
|
|
377
|
+
}
|
|
378
|
+
for entry in entries
|
|
379
|
+
if isinstance(entry, dict)
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
if sub == "show":
|
|
383
|
+
current = ctx["settings"].get("project")
|
|
384
|
+
match = next((p for p in projects if p["id"] == current), None) or (
|
|
385
|
+
projects[0] if projects else None
|
|
386
|
+
)
|
|
387
|
+
return {
|
|
388
|
+
"data": match,
|
|
389
|
+
"text": lambda: (
|
|
390
|
+
"\n".join(f"{style.dim(k.ljust(12))} {v}" for k, v in (match or {}).items())
|
|
391
|
+
if match
|
|
392
|
+
else style.dim("No projects found.")
|
|
393
|
+
),
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
def render() -> str:
|
|
397
|
+
if not projects:
|
|
398
|
+
return style.dim("No projects found.")
|
|
399
|
+
return render_table(
|
|
400
|
+
["id", "name", "tenant", "default"],
|
|
401
|
+
[[p["id"], p["name"], p["tenant_id"], "yes" if p["is_default"] else ""] for p in projects],
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
return {"data": projects, "text": render}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def event(ctx: dict) -> dict:
|
|
408
|
+
"""Ingestion state for one memory.
|
|
409
|
+
|
|
410
|
+
Named `event` to match the Node CLI and Mem0, both of which use it for
|
|
411
|
+
background processing state.
|
|
412
|
+
"""
|
|
413
|
+
from .memory import _numeric_id
|
|
414
|
+
|
|
415
|
+
positionals, api = ctx["positionals"], ctx["api"]
|
|
416
|
+
sub = positionals[0] if positionals else None
|
|
417
|
+
|
|
418
|
+
if sub != "status":
|
|
419
|
+
raise usage_error(
|
|
420
|
+
"Which event?",
|
|
421
|
+
"memorysync event status m_60632",
|
|
422
|
+
)
|
|
423
|
+
if len(positionals) < 2:
|
|
424
|
+
raise usage_error("Which memory?", "memorysync event status m_60632")
|
|
425
|
+
|
|
426
|
+
payload = api.memory_status(_numeric_id(positionals[1])) or {}
|
|
427
|
+
|
|
428
|
+
def render() -> str:
|
|
429
|
+
return "\n".join(
|
|
430
|
+
f"{style.dim(key.ljust(24))} {'-' if value is None else value}"
|
|
431
|
+
for key, value in payload.items()
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
return {"data": payload, "text": render}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def config_command(ctx: dict) -> dict:
|
|
438
|
+
"""Read and write the local config file.
|
|
439
|
+
|
|
440
|
+
``set`` never accepts an api_key: keys go to the keychain through `init`, and
|
|
441
|
+
accepting one here would write a credential into a file people paste into bug
|
|
442
|
+
reports.
|
|
443
|
+
"""
|
|
444
|
+
positionals = ctx["positionals"]
|
|
445
|
+
sub = positionals[0] if positionals else "show"
|
|
446
|
+
|
|
447
|
+
if sub == "show":
|
|
448
|
+
data = config_module.redacted()
|
|
449
|
+
return {
|
|
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.
|
|
454
|
+
"text": lambda: "\n".join(
|
|
455
|
+
[
|
|
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),
|
|
461
|
+
]
|
|
462
|
+
),
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if sub == "profiles":
|
|
466
|
+
cfg = config_module.load()
|
|
467
|
+
current = cfg.get("current_profile", "default")
|
|
468
|
+
profiles = cfg.get("profiles", {})
|
|
469
|
+
rows = [
|
|
470
|
+
[
|
|
471
|
+
style.green("*") if name == current else " ",
|
|
472
|
+
name,
|
|
473
|
+
entry.get("user") or "-",
|
|
474
|
+
entry.get("project") or "-",
|
|
475
|
+
entry.get("base_url") or "(default)",
|
|
476
|
+
]
|
|
477
|
+
for name, entry in profiles.items()
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
def render_profiles() -> str:
|
|
481
|
+
if not rows:
|
|
482
|
+
return style.dim("No profiles. Run `memorysync init`.")
|
|
483
|
+
return render_table([" ", "profile", "user", "project", "base url"], rows)
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
"data": {"current": current, "profiles": profiles},
|
|
487
|
+
"text": render_profiles,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if sub == "use-profile":
|
|
491
|
+
if len(positionals) < 2:
|
|
492
|
+
raise usage_error("Which profile?")
|
|
493
|
+
name = positionals[1]
|
|
494
|
+
cfg = config_module.load()
|
|
495
|
+
if name not in cfg.get("profiles", {}):
|
|
496
|
+
raise usage_error(
|
|
497
|
+
f'No profile named "{name}".',
|
|
498
|
+
"Run `memorysync config profiles` to see what exists, or "
|
|
499
|
+
f"`memorysync init --profile {name}` to create it.",
|
|
500
|
+
)
|
|
501
|
+
cfg["current_profile"] = name
|
|
502
|
+
config_module.save(cfg)
|
|
503
|
+
return {
|
|
504
|
+
"data": {"current_profile": name},
|
|
505
|
+
"text": lambda: f"{style.green('Switched to')} {name}",
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if sub == "delete-profile":
|
|
509
|
+
if len(positionals) < 2:
|
|
510
|
+
raise usage_error("Which profile?")
|
|
511
|
+
name = positionals[1]
|
|
512
|
+
cfg = config_module.load()
|
|
513
|
+
if name not in cfg.get("profiles", {}):
|
|
514
|
+
raise usage_error(f'No profile named "{name}".')
|
|
515
|
+
del cfg["profiles"][name]
|
|
516
|
+
if cfg.get("current_profile") == name:
|
|
517
|
+
remaining = list(cfg.get("profiles", {}))
|
|
518
|
+
cfg["current_profile"] = remaining[0] if remaining else "default"
|
|
519
|
+
config_module.save(cfg)
|
|
520
|
+
# The stored credential goes with the profile. Leaving it behind would keep
|
|
521
|
+
# a usable key in the keychain for a profile the user just removed.
|
|
522
|
+
credentials.delete_key(name)
|
|
523
|
+
return {
|
|
524
|
+
"data": {"deleted": name},
|
|
525
|
+
"text": lambda: f"{style.green('Deleted profile')} {name}",
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if sub == "get":
|
|
529
|
+
if len(positionals) < 2:
|
|
530
|
+
raise usage_error("Which key?", "memorysync config get user")
|
|
531
|
+
key = positionals[1]
|
|
532
|
+
# Reads the stored profile, not the resolved value. Matches the Node CLI:
|
|
533
|
+
# `config get` answers "what is written down", so an environment variable
|
|
534
|
+
# does not make it look as though something was saved.
|
|
535
|
+
cfg = config_module.load()
|
|
536
|
+
entry = cfg.get("profiles", {}).get(cfg.get("current_profile", "default"), {})
|
|
537
|
+
value = entry.get(key)
|
|
538
|
+
return {"data": {key: value}, "text": lambda: "" if value is None else str(value)}
|
|
539
|
+
|
|
540
|
+
if sub in {"set", "unset"}:
|
|
541
|
+
if len(positionals) < 2:
|
|
542
|
+
raise usage_error(
|
|
543
|
+
"Which key?", f"Settable: {', '.join(sorted(_SETTABLE))}"
|
|
544
|
+
)
|
|
545
|
+
key = positionals[1]
|
|
546
|
+
if key in {"api_key", "apikey", "key"}:
|
|
547
|
+
raise usage_error(
|
|
548
|
+
"Keys are not stored in the config file.",
|
|
549
|
+
"Run `memorysync init --api-key <key>`; it goes to the OS keychain.",
|
|
550
|
+
)
|
|
551
|
+
if key not in _SETTABLE:
|
|
552
|
+
raise usage_error(
|
|
553
|
+
f'"{key}" is not settable.',
|
|
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 "
|
|
557
|
+
"never stored here; use `memorysync init`.",
|
|
558
|
+
)
|
|
559
|
+
if sub == "set" and len(positionals) < 3:
|
|
560
|
+
raise usage_error(f"config set {key} <value>")
|
|
561
|
+
value = positionals[2] if sub == "set" else None
|
|
562
|
+
|
|
563
|
+
cfg = config_module.load()
|
|
564
|
+
name = cfg.get("current_profile", "default")
|
|
565
|
+
entry = dict(cfg.get("profiles", {}).get(name, {}))
|
|
566
|
+
if sub == "set":
|
|
567
|
+
entry[key] = value
|
|
568
|
+
else:
|
|
569
|
+
entry.pop(key, None)
|
|
570
|
+
cfg.setdefault("profiles", {})[name] = entry
|
|
571
|
+
config_module.save(cfg)
|
|
572
|
+
return {
|
|
573
|
+
"data": {"profile": name, key: value},
|
|
574
|
+
"text": lambda: (
|
|
575
|
+
f"{style.green('Set' if sub == 'set' else 'Unset')} {key} "
|
|
576
|
+
f"on profile {name}"
|
|
577
|
+
),
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if sub == "path":
|
|
581
|
+
path = str(config_module.config_path())
|
|
582
|
+
return {"data": {"path": path}, "text": lambda: path}
|
|
583
|
+
|
|
584
|
+
raise usage_error(
|
|
585
|
+
f'Unknown subcommand "{sub}".', "Try `memorysync config show`."
|
|
586
|
+
)
|