memorysync-cli 1.0.2__py3-none-any.whl
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/__init__.py +13 -0
- memorysync_cli/__main__.py +9 -0
- memorysync_cli/_version.py +1 -0
- memorysync_cli/args.py +220 -0
- memorysync_cli/commands/__init__.py +6 -0
- memorysync_cli/commands/admin.py +354 -0
- memorysync_cli/commands/init.py +109 -0
- memorysync_cli/commands/memory.py +629 -0
- memorysync_cli/commands/source.py +132 -0
- memorysync_cli/commands/tooling.py +238 -0
- memorysync_cli/completions.py +150 -0
- memorysync_cli/config.py +147 -0
- memorysync_cli/credentials.py +259 -0
- memorysync_cli/errors.py +110 -0
- memorysync_cli/http.py +257 -0
- memorysync_cli/main.py +325 -0
- memorysync_cli/output.py +311 -0
- memorysync_cli/registry.json +612 -0
- memorysync_cli/registry.py +101 -0
- memorysync_cli-1.0.2.dist-info/METADATA +158 -0
- memorysync_cli-1.0.2.dist-info/RECORD +24 -0
- memorysync_cli-1.0.2.dist-info/WHEEL +4 -0
- memorysync_cli-1.0.2.dist-info/entry_points.txt +3 -0
- memorysync_cli-1.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""MemorySync command-line interface.
|
|
2
|
+
|
|
3
|
+
A Python implementation of the same CLI as the npm package ``memorysync-cli``,
|
|
4
|
+
with the same commands, flags, output formats and exit codes. The two are
|
|
5
|
+
interchangeable: whichever is on PATH behaves identically, so you only need one.
|
|
6
|
+
|
|
7
|
+
Both read their command surface from one generated declaration, so ``help --json``
|
|
8
|
+
is byte-identical between them rather than kept aligned by review.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.2"
|
memorysync_cli/args.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Argument parsing.
|
|
2
|
+
|
|
3
|
+
Hand-rolled rather than using argparse, and deliberately so.
|
|
4
|
+
|
|
5
|
+
The grammar is small, but it has to match the Node CLI token for token, because
|
|
6
|
+
the two CLIs are interchangeable and a script must not behave differently
|
|
7
|
+
depending on which one is on PATH. argparse has its own opinions - about prefix
|
|
8
|
+
matching, about how it reports errors, about exiting with code 2 on its own terms
|
|
9
|
+
- and bending it into agreement would be more work than owning ninety lines.
|
|
10
|
+
|
|
11
|
+
Owning it also means `--help`, `help --json` and the completion scripts all come
|
|
12
|
+
from the shared registry rather than from a library's internal state, which is how
|
|
13
|
+
they stay consistent with what the parser actually accepts.
|
|
14
|
+
|
|
15
|
+
The grammar, matching ``sdk/cli/src/args.mjs``:
|
|
16
|
+
|
|
17
|
+
--flag value --flag=value -f value -f=value --no-flag
|
|
18
|
+
Bundled booleans: -qv. A valued short flag may only come last, as in getopt.
|
|
19
|
+
Everything after ``--`` is positional, so text containing dashes survives.
|
|
20
|
+
Unknown flags are refused, never ignored.
|
|
21
|
+
|
|
22
|
+
That last rule earns its keep: silently dropping a misspelled ``--user`` would
|
|
23
|
+
scope memory to the wrong person, and the caller would not find out.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .errors import usage_error
|
|
31
|
+
|
|
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
|
+
}
|
|
53
|
+
|
|
54
|
+
SHORT = {
|
|
55
|
+
"p": "profile",
|
|
56
|
+
"u": "user",
|
|
57
|
+
"o": "output",
|
|
58
|
+
"y": "yes",
|
|
59
|
+
"q": "quiet",
|
|
60
|
+
"v": "verbose",
|
|
61
|
+
"h": "help",
|
|
62
|
+
"m": "metadata",
|
|
63
|
+
"f": "file",
|
|
64
|
+
"k": "limit",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _key(name: str) -> str:
|
|
69
|
+
"""Flag name to dict key.
|
|
70
|
+
|
|
71
|
+
``--dry-run`` becomes ``dry_run``. The Node CLI uses ``dryRun``; the surface
|
|
72
|
+
both expose is the flag itself, so each side reads naturally in its own
|
|
73
|
+
language. Only the command-line spelling has to agree.
|
|
74
|
+
"""
|
|
75
|
+
return name.replace("-", "_")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_args(argv: list[str]) -> tuple[list[str], dict[str, Any]]:
|
|
79
|
+
"""Split argv into positionals and flags.
|
|
80
|
+
|
|
81
|
+
Returns them separately rather than as one namespace so the dispatcher can
|
|
82
|
+
tell ``memorysync add --user x`` from ``memorysync --user x add`` without
|
|
83
|
+
re-parsing.
|
|
84
|
+
"""
|
|
85
|
+
positionals: list[str] = []
|
|
86
|
+
flags: dict[str, Any] = {}
|
|
87
|
+
passthrough = False
|
|
88
|
+
|
|
89
|
+
index = 0
|
|
90
|
+
while index < len(argv):
|
|
91
|
+
token = argv[index]
|
|
92
|
+
|
|
93
|
+
if passthrough:
|
|
94
|
+
positionals.append(token)
|
|
95
|
+
index += 1
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
if token == "--":
|
|
99
|
+
passthrough = True
|
|
100
|
+
index += 1
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
if token.startswith("--"):
|
|
104
|
+
name = token[2:]
|
|
105
|
+
value: str | None = None
|
|
106
|
+
|
|
107
|
+
if "=" in name:
|
|
108
|
+
name, _, value = name.partition("=")
|
|
109
|
+
|
|
110
|
+
if name.startswith("no-") and value is None:
|
|
111
|
+
positive = name[3:]
|
|
112
|
+
if positive in VALUED:
|
|
113
|
+
raise usage_error(
|
|
114
|
+
f"--{name} is not a flag. --{positive} takes a value."
|
|
115
|
+
)
|
|
116
|
+
flags[_key(positive)] = False
|
|
117
|
+
index += 1
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
if name in VALUED:
|
|
121
|
+
if value is None:
|
|
122
|
+
following = argv[index + 1] if index + 1 < len(argv) else None
|
|
123
|
+
if following is None or following.startswith("-"):
|
|
124
|
+
raise usage_error(f"--{name} needs a value.")
|
|
125
|
+
value = following
|
|
126
|
+
index += 1
|
|
127
|
+
flags[_key(name)] = value
|
|
128
|
+
index += 1
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if value is not None:
|
|
132
|
+
# `--verbose=true` is accepted so a script generating flags
|
|
133
|
+
# programmatically does not have to special-case booleans.
|
|
134
|
+
flags[_key(name)] = value not in {"false", "0"}
|
|
135
|
+
index += 1
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
flags[_key(name)] = True
|
|
139
|
+
index += 1
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if len(token) > 1 and token.startswith("-"):
|
|
143
|
+
body = token[1:]
|
|
144
|
+
letters, _, inline = body.partition("=")
|
|
145
|
+
has_inline = "=" in body
|
|
146
|
+
|
|
147
|
+
for position, letter in enumerate(letters):
|
|
148
|
+
long = SHORT.get(letter)
|
|
149
|
+
if long is None:
|
|
150
|
+
raise usage_error(f"Unknown flag -{letter}.")
|
|
151
|
+
|
|
152
|
+
is_last = position == len(letters) - 1
|
|
153
|
+
|
|
154
|
+
if long in VALUED:
|
|
155
|
+
if not is_last:
|
|
156
|
+
raise usage_error(
|
|
157
|
+
f"-{letter} takes a value, so it must come last in -{letters}."
|
|
158
|
+
)
|
|
159
|
+
if has_inline:
|
|
160
|
+
value = inline
|
|
161
|
+
else:
|
|
162
|
+
following = argv[index + 1] if index + 1 < len(argv) else None
|
|
163
|
+
if following is None or following.startswith("-"):
|
|
164
|
+
raise usage_error(f"-{letter} needs a value.")
|
|
165
|
+
value = following
|
|
166
|
+
index += 1
|
|
167
|
+
if value == "":
|
|
168
|
+
raise usage_error(f"-{letter} needs a value.")
|
|
169
|
+
flags[_key(long)] = value
|
|
170
|
+
else:
|
|
171
|
+
flags[_key(long)] = True
|
|
172
|
+
|
|
173
|
+
index += 1
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
positionals.append(token)
|
|
177
|
+
index += 1
|
|
178
|
+
|
|
179
|
+
return positionals, flags
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def levenshtein(left: str, right: str) -> int:
|
|
183
|
+
"""Edit distance, for "did you mean". Inputs are command names, so the naive
|
|
184
|
+
row-by-row version is more than fast enough."""
|
|
185
|
+
if left == right:
|
|
186
|
+
return 0
|
|
187
|
+
if not left:
|
|
188
|
+
return len(right)
|
|
189
|
+
if not right:
|
|
190
|
+
return len(left)
|
|
191
|
+
|
|
192
|
+
previous = list(range(len(right) + 1))
|
|
193
|
+
for i, lc in enumerate(left, start=1):
|
|
194
|
+
current = [i]
|
|
195
|
+
for j, rc in enumerate(right, start=1):
|
|
196
|
+
current.append(
|
|
197
|
+
min(
|
|
198
|
+
previous[j] + 1,
|
|
199
|
+
current[j - 1] + 1,
|
|
200
|
+
previous[j - 1] + (0 if lc == rc else 1),
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
previous = current
|
|
204
|
+
return previous[-1]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def suggest(unknown: str, candidates: list[str]) -> str | None:
|
|
208
|
+
"""Closest candidate, when one is close enough to be worth offering.
|
|
209
|
+
|
|
210
|
+
A third of the length is the threshold: close enough to catch a typo, far
|
|
211
|
+
enough that ``memorysync foo`` is not answered with a confident wrong guess.
|
|
212
|
+
"""
|
|
213
|
+
best, best_distance = None, None
|
|
214
|
+
for candidate in candidates:
|
|
215
|
+
distance = levenshtein(unknown, candidate)
|
|
216
|
+
if best_distance is None or distance < best_distance:
|
|
217
|
+
best, best_distance = candidate, distance
|
|
218
|
+
if best is None or best_distance is None:
|
|
219
|
+
return None
|
|
220
|
+
return best if best_distance <= max(1, len(unknown) // 3) else None
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Command implementations, grouped the same way the Node CLI groups them.
|
|
2
|
+
|
|
3
|
+
The split mirrors ``sdk/cli/src/commands/`` file for file - memory, admin, init,
|
|
4
|
+
source, tooling - so that a change on one side has an obvious counterpart on the
|
|
5
|
+
other. Parity is easier to keep when the two trees look alike in review.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,354 @@
|
|
|
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 os
|
|
14
|
+
import platform
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .. import config as config_module
|
|
20
|
+
from .. import credentials
|
|
21
|
+
from .._version import __version__
|
|
22
|
+
from ..errors import usage_error
|
|
23
|
+
from ..output import render_table, style
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _percent(used: Any, limit: Any) -> str:
|
|
27
|
+
if not isinstance(used, (int, float)) or not isinstance(limit, (int, float)) or limit <= 0:
|
|
28
|
+
return "-"
|
|
29
|
+
return f"{min(100, round(used / limit * 100))}%"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def quota(ctx: dict) -> dict:
|
|
33
|
+
"""Plan usage, with headroom made explicit.
|
|
34
|
+
|
|
35
|
+
The one command that turns the API's deliberate silence into a number. None of
|
|
36
|
+
Mem0, Supermemory or Zep expose this from their CLI.
|
|
37
|
+
"""
|
|
38
|
+
api = ctx["api"]
|
|
39
|
+
summary = api.usage_summary() or {}
|
|
40
|
+
metrics = [m for m in (summary.get("metrics") or []) if isinstance(m, dict)]
|
|
41
|
+
|
|
42
|
+
rows = [
|
|
43
|
+
{
|
|
44
|
+
"metric": entry.get("metric"),
|
|
45
|
+
"used": entry.get("used"),
|
|
46
|
+
"limit": entry.get("limit"),
|
|
47
|
+
"remaining": (
|
|
48
|
+
entry["limit"] - entry["used"]
|
|
49
|
+
if isinstance(entry.get("limit"), (int, float))
|
|
50
|
+
and isinstance(entry.get("used"), (int, float))
|
|
51
|
+
else None
|
|
52
|
+
),
|
|
53
|
+
"percent": _percent(entry.get("used"), entry.get("limit")),
|
|
54
|
+
}
|
|
55
|
+
for entry in metrics
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def render() -> str:
|
|
59
|
+
if not rows:
|
|
60
|
+
return style.dim("No usage reported for this billing cycle.")
|
|
61
|
+
table = render_table(
|
|
62
|
+
["metric", "used", "limit", "remaining", "used %"],
|
|
63
|
+
[
|
|
64
|
+
[
|
|
65
|
+
row["metric"],
|
|
66
|
+
row["used"],
|
|
67
|
+
"unlimited" if row["limit"] in (None, 0) else row["limit"],
|
|
68
|
+
"-" if row["remaining"] is None else row["remaining"],
|
|
69
|
+
row["percent"],
|
|
70
|
+
]
|
|
71
|
+
for row in rows
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
exhausted = [r["metric"] for r in rows if r["remaining"] == 0]
|
|
75
|
+
if exhausted:
|
|
76
|
+
warning = style.yellow(
|
|
77
|
+
"At the limit: " + ", ".join(str(m) for m in exhausted) + "."
|
|
78
|
+
)
|
|
79
|
+
note = style.dim(
|
|
80
|
+
"Writes and reads return empty rather than failing, so check here "
|
|
81
|
+
"before assuming memory is missing."
|
|
82
|
+
)
|
|
83
|
+
return f"{table}\n\n{warning}\n{note}"
|
|
84
|
+
return table
|
|
85
|
+
|
|
86
|
+
return {"data": rows, "text": render}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def status(ctx: dict) -> dict:
|
|
90
|
+
"""Reachability and identity in one call."""
|
|
91
|
+
api = ctx["api"]
|
|
92
|
+
started = time.monotonic()
|
|
93
|
+
plan = api.current_plan() or {}
|
|
94
|
+
latency = int((time.monotonic() - started) * 1000)
|
|
95
|
+
|
|
96
|
+
payload = {
|
|
97
|
+
"api": ctx["settings"]["base_url"],
|
|
98
|
+
"reachable": True,
|
|
99
|
+
"latency_ms": latency,
|
|
100
|
+
"plan": plan.get("plan") or plan.get("name"),
|
|
101
|
+
"cycle_ends": plan.get("current_period_end") or plan.get("cycle_ends"),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def render() -> str:
|
|
105
|
+
return "\n".join(
|
|
106
|
+
[
|
|
107
|
+
f"{style.dim('api ')} {payload['api']}",
|
|
108
|
+
f"{style.dim('reachable ')} {style.green('yes')} ({latency}ms)",
|
|
109
|
+
f"{style.dim('plan ')} {payload['plan'] or '-'}",
|
|
110
|
+
f"{style.dim('cycle ends ')} {payload['cycle_ends'] or '-'}",
|
|
111
|
+
]
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return {"data": payload, "text": render}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def whoami(ctx: dict) -> dict:
|
|
118
|
+
"""Who this credential is, without printing the credential."""
|
|
119
|
+
settings = ctx["settings"]
|
|
120
|
+
api = ctx["api"]
|
|
121
|
+
plan = api.current_plan() or {}
|
|
122
|
+
|
|
123
|
+
payload = {
|
|
124
|
+
"profile": settings["profile_name"],
|
|
125
|
+
"base_url": settings["base_url"],
|
|
126
|
+
"user": settings.get("user"),
|
|
127
|
+
"project": settings.get("project"),
|
|
128
|
+
"plan": plan.get("plan") or plan.get("name"),
|
|
129
|
+
"credential_source": credentials.describe_storage(),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def render() -> str:
|
|
133
|
+
return "\n".join(
|
|
134
|
+
f"{style.dim(key.ljust(18))} {value if value not in (None, '') else '-'}"
|
|
135
|
+
for key, value in payload.items()
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return {"data": payload, "text": render}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def doctor(ctx: dict) -> dict:
|
|
142
|
+
"""Diagnose a broken setup and exit 0 either way.
|
|
143
|
+
|
|
144
|
+
Exiting 0 even when checks fail is deliberate: this is a diagnostic, and a
|
|
145
|
+
non-zero exit would make it useless inside `set -e` scripts, which is exactly
|
|
146
|
+
where someone reaches for it.
|
|
147
|
+
"""
|
|
148
|
+
settings = ctx["settings"]
|
|
149
|
+
api = ctx.get("api")
|
|
150
|
+
checks: list[dict[str, Any]] = []
|
|
151
|
+
|
|
152
|
+
def add(name: str, ok: bool | None, detail: str, hint: str | None = None) -> None:
|
|
153
|
+
checks.append(
|
|
154
|
+
{
|
|
155
|
+
"check": name,
|
|
156
|
+
"status": "pass" if ok else ("warn" if ok is None else "fail"),
|
|
157
|
+
"detail": detail,
|
|
158
|
+
**({"hint": hint} if hint else {}),
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
version_ok = sys.version_info >= (3, 9)
|
|
163
|
+
add(
|
|
164
|
+
"python",
|
|
165
|
+
version_ok,
|
|
166
|
+
platform.python_version(),
|
|
167
|
+
None if version_ok else "MemorySync needs Python 3.9 or newer.",
|
|
168
|
+
)
|
|
169
|
+
add("cli", True, f"memorysync-cli {__version__}")
|
|
170
|
+
add("config", True, str(config_module.config_path()))
|
|
171
|
+
add("credential store", True, credentials.describe_storage())
|
|
172
|
+
|
|
173
|
+
key = credentials.read_key(settings["profile_name"])
|
|
174
|
+
add(
|
|
175
|
+
"api key",
|
|
176
|
+
bool(key),
|
|
177
|
+
f"found ({key[:8]}...)" if key else "not found",
|
|
178
|
+
None if key else "Run `memorysync init`, or set MEMORYSYNC_API_KEY.",
|
|
179
|
+
)
|
|
180
|
+
add(
|
|
181
|
+
"end user",
|
|
182
|
+
bool(settings.get("user")) or None,
|
|
183
|
+
settings.get("user") or "not set",
|
|
184
|
+
None if settings.get("user") else "Memory calls need --user or a stored default.",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if api is not None:
|
|
188
|
+
started = time.monotonic()
|
|
189
|
+
try:
|
|
190
|
+
api.current_plan()
|
|
191
|
+
add("api reachable", True, f"{settings['base_url']} in {int((time.monotonic() - started) * 1000)}ms")
|
|
192
|
+
except Exception as error: # noqa: BLE001 - a diagnostic reports, never raises
|
|
193
|
+
add("api reachable", False, str(error), "Check the network, the base URL and the key.")
|
|
194
|
+
else:
|
|
195
|
+
add("api reachable", None, "skipped, no credential", "Store a key first.")
|
|
196
|
+
|
|
197
|
+
def render() -> str:
|
|
198
|
+
marks = {"pass": style.green("ok "), "warn": style.yellow("warn"), "fail": style.red("fail")}
|
|
199
|
+
lines = []
|
|
200
|
+
for entry in checks:
|
|
201
|
+
lines.append(f"{marks[entry['status']]} {entry['check'].ljust(18)} {entry['detail']}")
|
|
202
|
+
if entry.get("hint"):
|
|
203
|
+
lines.append(f" {style.dim(entry['hint'])}")
|
|
204
|
+
failures = sum(1 for entry in checks if entry["status"] == "fail")
|
|
205
|
+
summary = (
|
|
206
|
+
style.green("Everything checks out.")
|
|
207
|
+
if failures == 0
|
|
208
|
+
else style.yellow(f"{failures} check(s) need attention.")
|
|
209
|
+
)
|
|
210
|
+
return "\n".join([*lines, "", summary])
|
|
211
|
+
|
|
212
|
+
return {"data": checks, "text": render}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def project(ctx: dict) -> dict:
|
|
216
|
+
"""List projects, or show the one in scope."""
|
|
217
|
+
positionals, api = ctx["positionals"], ctx["api"]
|
|
218
|
+
sub = positionals[0] if positionals else "list"
|
|
219
|
+
|
|
220
|
+
if sub not in {"list", "show"}:
|
|
221
|
+
raise usage_error(f'Unknown subcommand "{sub}".', "Try list or show.")
|
|
222
|
+
|
|
223
|
+
raw = api.projects()
|
|
224
|
+
entries = raw if isinstance(raw, list) else (raw or {}).get("projects") or []
|
|
225
|
+
projects = [
|
|
226
|
+
{
|
|
227
|
+
"id": entry.get("id") or entry.get("project_id"),
|
|
228
|
+
"name": entry.get("name"),
|
|
229
|
+
"tenant_id": entry.get("tenant_id"),
|
|
230
|
+
"is_default": entry.get("is_default"),
|
|
231
|
+
}
|
|
232
|
+
for entry in entries
|
|
233
|
+
if isinstance(entry, dict)
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
if sub == "show":
|
|
237
|
+
current = ctx["settings"].get("project")
|
|
238
|
+
match = next((p for p in projects if p["id"] == current), None) or (
|
|
239
|
+
projects[0] if projects else None
|
|
240
|
+
)
|
|
241
|
+
return {
|
|
242
|
+
"data": match,
|
|
243
|
+
"text": lambda: (
|
|
244
|
+
"\n".join(f"{style.dim(k.ljust(12))} {v}" for k, v in (match or {}).items())
|
|
245
|
+
if match
|
|
246
|
+
else style.dim("No projects found.")
|
|
247
|
+
),
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
def render() -> str:
|
|
251
|
+
if not projects:
|
|
252
|
+
return style.dim("No projects found.")
|
|
253
|
+
return render_table(
|
|
254
|
+
["id", "name", "tenant", "default"],
|
|
255
|
+
[[p["id"], p["name"], p["tenant_id"], "yes" if p["is_default"] else ""] for p in projects],
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return {"data": projects, "text": render}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def event(ctx: dict) -> dict:
|
|
262
|
+
"""Ingestion state for one memory.
|
|
263
|
+
|
|
264
|
+
Named `event` to match the Node CLI and Mem0, both of which use it for
|
|
265
|
+
background processing state.
|
|
266
|
+
"""
|
|
267
|
+
from .memory import _numeric_id
|
|
268
|
+
|
|
269
|
+
positionals, api = ctx["positionals"], ctx["api"]
|
|
270
|
+
sub = positionals[0] if positionals else None
|
|
271
|
+
|
|
272
|
+
if sub != "status":
|
|
273
|
+
raise usage_error(
|
|
274
|
+
"Which event?",
|
|
275
|
+
"memorysync event status m_60632",
|
|
276
|
+
)
|
|
277
|
+
if len(positionals) < 2:
|
|
278
|
+
raise usage_error("Which memory?", "memorysync event status m_60632")
|
|
279
|
+
|
|
280
|
+
payload = api.memory_status(_numeric_id(positionals[1])) or {}
|
|
281
|
+
|
|
282
|
+
def render() -> str:
|
|
283
|
+
return "\n".join(
|
|
284
|
+
f"{style.dim(key.ljust(24))} {'-' if value is None else value}"
|
|
285
|
+
for key, value in payload.items()
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return {"data": payload, "text": render}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def config_command(ctx: dict) -> dict:
|
|
292
|
+
"""Read and write the local config file.
|
|
293
|
+
|
|
294
|
+
``set`` never accepts an api_key: keys go to the keychain through `init`, and
|
|
295
|
+
accepting one here would write a credential into a file people paste into bug
|
|
296
|
+
reports.
|
|
297
|
+
"""
|
|
298
|
+
positionals = ctx["positionals"]
|
|
299
|
+
sub = positionals[0] if positionals else "show"
|
|
300
|
+
|
|
301
|
+
if sub == "show":
|
|
302
|
+
data = config_module.redacted()
|
|
303
|
+
return {
|
|
304
|
+
"data": data,
|
|
305
|
+
"text": lambda: "\n".join(
|
|
306
|
+
[
|
|
307
|
+
f"{style.dim('path')} {data['path']}",
|
|
308
|
+
*(
|
|
309
|
+
f"{style.dim((' ' + name).ljust(18))} "
|
|
310
|
+
+ ", ".join(f"{k}={v}" for k, v in entry.items())
|
|
311
|
+
for name, entry in data["profiles"].items()
|
|
312
|
+
),
|
|
313
|
+
]
|
|
314
|
+
),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if sub == "get":
|
|
318
|
+
if len(positionals) < 2:
|
|
319
|
+
raise usage_error("Which key?", "memorysync config get user")
|
|
320
|
+
key = positionals[1]
|
|
321
|
+
settings = config_module.resolve(ctx["flags"])
|
|
322
|
+
value = settings.get(key)
|
|
323
|
+
return {"data": {key: value}, "text": lambda: "" if value is None else str(value)}
|
|
324
|
+
|
|
325
|
+
if sub == "set":
|
|
326
|
+
if len(positionals) < 3:
|
|
327
|
+
raise usage_error("What to set?", "memorysync config set user alice")
|
|
328
|
+
key, value = positionals[1], positionals[2]
|
|
329
|
+
if key in {"api_key", "apikey", "key"}:
|
|
330
|
+
raise usage_error(
|
|
331
|
+
"Keys are not stored in the config file.",
|
|
332
|
+
"Run `memorysync init --api-key <key>`; it goes to the OS keychain.",
|
|
333
|
+
)
|
|
334
|
+
allowed = {"user", "project", "base_url", "timeout"}
|
|
335
|
+
if key not in allowed:
|
|
336
|
+
raise usage_error(
|
|
337
|
+
f'Cannot set "{key}".',
|
|
338
|
+
f"One of: {', '.join(sorted(allowed))}.",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
profile_name = ctx["settings"]["profile_name"]
|
|
342
|
+
data = config_module.load()
|
|
343
|
+
data.setdefault("profiles", {}).setdefault(profile_name, {})[key] = value
|
|
344
|
+
path = config_module.save(data)
|
|
345
|
+
return {
|
|
346
|
+
"data": {"profile": profile_name, key: value, "path": str(path)},
|
|
347
|
+
"text": lambda: f"{style.green('Set')} {key} = {value} in profile {profile_name}",
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if sub == "path":
|
|
351
|
+
path = str(config_module.config_path())
|
|
352
|
+
return {"data": {"path": path}, "text": lambda: path}
|
|
353
|
+
|
|
354
|
+
raise usage_error(f'Unknown subcommand "{sub}".', "Try show, get, set or path.")
|