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
memorysync_cli/main.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Entry point and dispatch.
|
|
2
|
+
|
|
3
|
+
Mirrors ``sdk/cli/src/main.mjs`` step for step, because the order of these
|
|
4
|
+
decisions is observable. Resolving the format before validating flags, for
|
|
5
|
+
instance, is what lets a bad flag still come back as a JSON envelope when the
|
|
6
|
+
caller asked for agent mode - an agent that sent a malformed command line still
|
|
7
|
+
needs a parseable answer rather than prose on stderr.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from . import config as config_module
|
|
19
|
+
from . import registry
|
|
20
|
+
from .args import parse_args, suggest
|
|
21
|
+
from .commands import admin, init as init_command, memory, source as source_command, tooling
|
|
22
|
+
from .credentials import read_key
|
|
23
|
+
from .errors import CliError, Exit, auth_error, usage_error
|
|
24
|
+
from .http import ApiClient
|
|
25
|
+
from .output import (
|
|
26
|
+
emit,
|
|
27
|
+
error_envelope,
|
|
28
|
+
set_colour_enabled,
|
|
29
|
+
style,
|
|
30
|
+
success_envelope,
|
|
31
|
+
write,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
Handler = Callable[[dict], dict]
|
|
35
|
+
|
|
36
|
+
# command name -> handler. Kept beside the registry so a gap is obvious in review,
|
|
37
|
+
# which is the same reason the Node CLI keeps its HANDLERS map next to its own. A
|
|
38
|
+
# name in the registry with no entry here fails as "not available" rather than
|
|
39
|
+
# "unknown command", so `help --json` stays honest about what exists.
|
|
40
|
+
_HANDLERS: dict[str, Handler] = {
|
|
41
|
+
"init": init_command.init,
|
|
42
|
+
"add": memory.add,
|
|
43
|
+
"search": memory.search,
|
|
44
|
+
"list": memory.list_memories,
|
|
45
|
+
"get": memory.get,
|
|
46
|
+
"update": memory.update,
|
|
47
|
+
"delete": memory.delete_memories,
|
|
48
|
+
"import": memory.import_memories,
|
|
49
|
+
"export": memory.export_memories,
|
|
50
|
+
"quota": admin.quota,
|
|
51
|
+
"status": admin.status,
|
|
52
|
+
"doctor": admin.doctor,
|
|
53
|
+
"whoami": admin.whoami,
|
|
54
|
+
"project": admin.project,
|
|
55
|
+
"source": source_command.source,
|
|
56
|
+
"event": admin.event,
|
|
57
|
+
"config": admin.config_command,
|
|
58
|
+
"mcp": tooling.mcp,
|
|
59
|
+
"completion": tooling.completion,
|
|
60
|
+
"help": tooling.help_command,
|
|
61
|
+
"version": tooling.version,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _not_implemented(name: str) -> Handler:
|
|
66
|
+
"""A declared command with no implementation yet fails as itself.
|
|
67
|
+
|
|
68
|
+
Deliberately not an unknown-command error: the registry is shared, so the
|
|
69
|
+
command genuinely exists and `help --json` advertises it. Saying "not
|
|
70
|
+
available" is honest, where "unknown command" would read as a typo and send
|
|
71
|
+
someone looking for the wrong problem.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def handler(_ctx: dict) -> dict:
|
|
75
|
+
raise CliError(
|
|
76
|
+
f'"{name}" is not available in this build yet.',
|
|
77
|
+
exit_code=Exit.USAGE,
|
|
78
|
+
code="not_implemented",
|
|
79
|
+
hint="Run `memorysync help` to see what is.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return handler
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_format(flags: dict, agent_mode: bool) -> str:
|
|
86
|
+
"""Pick the format, returning an invalid one unchanged.
|
|
87
|
+
|
|
88
|
+
An unknown format is not rejected here on purpose. The caller validates it
|
|
89
|
+
inside the try block, so the resulting error is still wrapped in an envelope
|
|
90
|
+
when agent mode was requested.
|
|
91
|
+
"""
|
|
92
|
+
if agent_mode:
|
|
93
|
+
return "json"
|
|
94
|
+
requested = flags.get("output") or os.environ.get("MEMORYSYNC_OUTPUT")
|
|
95
|
+
if not requested:
|
|
96
|
+
return "quiet" if flags.get("quiet") else "text"
|
|
97
|
+
return requested
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_settings(flags: dict) -> dict:
|
|
101
|
+
"""Settings for this invocation: flag, then environment, then config, then default."""
|
|
102
|
+
return config_module.resolve(flags)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _scope_of(settings: dict) -> dict:
|
|
106
|
+
scope = {}
|
|
107
|
+
if settings.get("user"):
|
|
108
|
+
scope["user"] = settings["user"]
|
|
109
|
+
if settings.get("project"):
|
|
110
|
+
scope["project"] = settings["project"]
|
|
111
|
+
if settings.get("profile_name"):
|
|
112
|
+
scope["profile"] = settings["profile_name"]
|
|
113
|
+
return scope
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _allowed_flags(name: str, sub: str | None = None) -> set[str]:
|
|
117
|
+
"""Every flag this command accepts: its own, its subcommand's, and globals."""
|
|
118
|
+
allowed = {"help"}
|
|
119
|
+
for flag in registry.global_flags():
|
|
120
|
+
allowed.add(flag["name"].lstrip("-").replace("-", "_"))
|
|
121
|
+
for flag in registry.flags_for(name, sub):
|
|
122
|
+
allowed.add(flag["name"].lstrip("-").replace("-", "_"))
|
|
123
|
+
# `--no-x` parses to key `x`; both spellings map to the same key already.
|
|
124
|
+
allowed.add("agent")
|
|
125
|
+
allowed.add("color")
|
|
126
|
+
allowed.add("dedupe")
|
|
127
|
+
allowed.add("rerank")
|
|
128
|
+
return allowed
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _reject_unknown_flags(flags: dict, allowed: set[str]) -> None:
|
|
132
|
+
"""Refuse a flag this command does not take.
|
|
133
|
+
|
|
134
|
+
Ignoring it would be worse than failing: a misspelled ``--user`` would scope
|
|
135
|
+
memory to the wrong person and nobody would be told.
|
|
136
|
+
"""
|
|
137
|
+
unknown = sorted(key for key in flags if key not in allowed)
|
|
138
|
+
if unknown:
|
|
139
|
+
spelled = ", ".join(f"--{key.replace('_', '-')}" for key in unknown)
|
|
140
|
+
raise usage_error(
|
|
141
|
+
f"Unknown flag {spelled} for this command.",
|
|
142
|
+
"Run `memorysync help <command>` to see what it accepts.",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fail(*, command: str, error: CliError, agent_mode: bool, started: float, settings: dict | None = None) -> int:
|
|
147
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
148
|
+
if agent_mode:
|
|
149
|
+
write(
|
|
150
|
+
json.dumps(
|
|
151
|
+
error_envelope(
|
|
152
|
+
command=command,
|
|
153
|
+
error=error,
|
|
154
|
+
duration_ms=duration_ms,
|
|
155
|
+
scope=_scope_of(settings) if settings else None,
|
|
156
|
+
),
|
|
157
|
+
indent=2,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
print(style.red(f"error: {error.message}"), file=sys.stderr)
|
|
162
|
+
if error.hint:
|
|
163
|
+
print(style.dim(f" {error.hint}"), file=sys.stderr)
|
|
164
|
+
return error.exit_code
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def dispatch(argv: list[str]) -> int:
|
|
168
|
+
"""Run one invocation and return its exit code.
|
|
169
|
+
|
|
170
|
+
Returns rather than exits so tests can assert on codes without spawning a
|
|
171
|
+
process or trapping SystemExit.
|
|
172
|
+
"""
|
|
173
|
+
started = time.monotonic()
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
positionals, flags = parse_args(argv)
|
|
177
|
+
except CliError as error:
|
|
178
|
+
# Agent mode cannot be read from flags that failed to parse, so fall back
|
|
179
|
+
# to scanning raw argv. An agent asked for JSON and should get JSON even
|
|
180
|
+
# when its own command line was wrong.
|
|
181
|
+
agent_mode = "--json" in argv or "--agent" in argv
|
|
182
|
+
return _fail(command="memorysync", error=error, agent_mode=agent_mode, started=started)
|
|
183
|
+
|
|
184
|
+
agent_mode = bool(flags.get("json") or flags.get("agent"))
|
|
185
|
+
if flags.get("color") is False or agent_mode:
|
|
186
|
+
set_colour_enabled(False)
|
|
187
|
+
|
|
188
|
+
if flags.get("version") and not positionals:
|
|
189
|
+
write(tooling.VERSION)
|
|
190
|
+
return Exit.OK
|
|
191
|
+
|
|
192
|
+
command_name: str | None = positionals[0] if positionals else None
|
|
193
|
+
rest = positionals[1:]
|
|
194
|
+
|
|
195
|
+
if command_name is None:
|
|
196
|
+
command_name, rest = "help", []
|
|
197
|
+
elif flags.get("help"):
|
|
198
|
+
rest = [command_name]
|
|
199
|
+
command_name = "help"
|
|
200
|
+
|
|
201
|
+
known = registry.commands()
|
|
202
|
+
if command_name not in known:
|
|
203
|
+
hint = suggest(command_name, list(known))
|
|
204
|
+
error = usage_error(
|
|
205
|
+
f'Unknown command "{command_name}".',
|
|
206
|
+
f"Did you mean `{hint}`?" if hint else "Run `memorysync help` for the list.",
|
|
207
|
+
)
|
|
208
|
+
return _fail(command=command_name, error=error, agent_mode=agent_mode, started=started)
|
|
209
|
+
|
|
210
|
+
spec = known[command_name]
|
|
211
|
+
fmt = _resolve_format(flags, agent_mode)
|
|
212
|
+
settings = _resolve_settings(flags)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
if fmt not in registry.formats():
|
|
216
|
+
raise usage_error(
|
|
217
|
+
f'Unknown output format "{fmt}".',
|
|
218
|
+
f"One of: {', '.join(registry.formats())}.",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
sub = rest[0] if rest and rest[0] in registry.subcommands(command_name) else None
|
|
222
|
+
_reject_unknown_flags(flags, _allowed_flags(command_name, sub))
|
|
223
|
+
|
|
224
|
+
api_key = flags.get("api_key") or read_key(settings["profile_name"])
|
|
225
|
+
if spec.get("requires_auth") and not api_key:
|
|
226
|
+
raise auth_error("No API key found.")
|
|
227
|
+
|
|
228
|
+
# Built even for commands that do not require auth, because `init` and
|
|
229
|
+
# `doctor` both want to probe with a key when one happens to exist.
|
|
230
|
+
api = (
|
|
231
|
+
ApiClient(
|
|
232
|
+
base_url=settings["base_url"],
|
|
233
|
+
api_key=api_key,
|
|
234
|
+
user=settings.get("user"),
|
|
235
|
+
project=settings.get("project"),
|
|
236
|
+
timeout=settings["timeout"],
|
|
237
|
+
verbose=bool(flags.get("verbose")),
|
|
238
|
+
)
|
|
239
|
+
if api_key
|
|
240
|
+
else None
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
ctx = {
|
|
244
|
+
"positionals": rest,
|
|
245
|
+
"flags": flags,
|
|
246
|
+
"settings": settings,
|
|
247
|
+
"api": api,
|
|
248
|
+
"api_key": api_key,
|
|
249
|
+
"format": fmt,
|
|
250
|
+
"agent_mode": agent_mode,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
handler = _HANDLERS.get(command_name) or _not_implemented(command_name)
|
|
254
|
+
result = handler(ctx)
|
|
255
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
256
|
+
|
|
257
|
+
# `help` is a discovery contract, not a data result, so its JSON is the
|
|
258
|
+
# tree itself rather than an envelope around it. An agent bootstrapping
|
|
259
|
+
# from `help --json` should not have to reach into `.data[0]` for the
|
|
260
|
+
# command list.
|
|
261
|
+
if command_name == "help" and (agent_mode or fmt == "json"):
|
|
262
|
+
write(json.dumps(result["data"], indent=2))
|
|
263
|
+
return Exit.OK
|
|
264
|
+
|
|
265
|
+
if agent_mode:
|
|
266
|
+
write(
|
|
267
|
+
json.dumps(
|
|
268
|
+
success_envelope(
|
|
269
|
+
command=command_name,
|
|
270
|
+
data=result["data"],
|
|
271
|
+
duration_ms=duration_ms,
|
|
272
|
+
scope=_scope_of(settings),
|
|
273
|
+
),
|
|
274
|
+
indent=2,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
return result.get("exit_code", Exit.OK)
|
|
278
|
+
|
|
279
|
+
# `completion` and `mcp` emit text that must not be decorated or reshaped.
|
|
280
|
+
if result.get("raw") and fmt not in {"json", "yaml"}:
|
|
281
|
+
text = result["text"]()
|
|
282
|
+
if text:
|
|
283
|
+
write(text)
|
|
284
|
+
return result.get("exit_code", Exit.OK)
|
|
285
|
+
|
|
286
|
+
emit(fmt=fmt, data=result["data"], text=result["text"])
|
|
287
|
+
return result.get("exit_code", Exit.OK)
|
|
288
|
+
|
|
289
|
+
except CliError as error:
|
|
290
|
+
return _fail(
|
|
291
|
+
command=command_name,
|
|
292
|
+
error=error,
|
|
293
|
+
agent_mode=agent_mode,
|
|
294
|
+
started=started,
|
|
295
|
+
settings=settings,
|
|
296
|
+
)
|
|
297
|
+
except Exception as error: # noqa: BLE001 - last resort, still needs an envelope
|
|
298
|
+
wrapped = CliError(str(error) or error.__class__.__name__, exit_code=Exit.FAILURE)
|
|
299
|
+
return _fail(
|
|
300
|
+
command=command_name,
|
|
301
|
+
error=wrapped,
|
|
302
|
+
agent_mode=agent_mode,
|
|
303
|
+
started=started,
|
|
304
|
+
settings=settings,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def run(argv: list[str] | None = None) -> None:
|
|
309
|
+
"""Console-script entry point."""
|
|
310
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
311
|
+
try:
|
|
312
|
+
raise SystemExit(dispatch(args))
|
|
313
|
+
except KeyboardInterrupt:
|
|
314
|
+
print(style.dim("\ninterrupted"), file=sys.stderr)
|
|
315
|
+
raise SystemExit(Exit.INTERRUPTED)
|
|
316
|
+
except BrokenPipeError:
|
|
317
|
+
# `memorysync list | head` closes the pipe early. Not a failure.
|
|
318
|
+
try:
|
|
319
|
+
sys.stdout.close()
|
|
320
|
+
finally:
|
|
321
|
+
raise SystemExit(Exit.OK)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__": # pragma: no cover
|
|
325
|
+
run()
|
memorysync_cli/output.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Rendering: colour, tables, YAML, and the agent envelope.
|
|
2
|
+
|
|
3
|
+
Mirrors ``sdk/cli/src/output.mjs`` exactly. Commands never print; they return a
|
|
4
|
+
result and this module decides how it appears, which is what lets all five formats
|
|
5
|
+
work for all 21 commands. Mem0 supports a different format set per command, so a
|
|
6
|
+
script that works for one of theirs can fail for another.
|
|
7
|
+
|
|
8
|
+
Two JSON paths, and they are not the same thing:
|
|
9
|
+
|
|
10
|
+
``--output json`` the raw data, exactly as the command produced it
|
|
11
|
+
``--json``/--agent the data wrapped in an envelope, for a tool loop
|
|
12
|
+
|
|
13
|
+
Conflating them was the first parity bug in this port: enveloping ``--output
|
|
14
|
+
json`` would have broken any pipeline doing ``| jq '.[0]'`` against the Node CLI's
|
|
15
|
+
output. ``quiet`` likewise prints nothing at all - not ids - because its job is to
|
|
16
|
+
leave only the exit code.
|
|
17
|
+
|
|
18
|
+
``data`` is always an array in the envelope, whatever the command. Mem0's own docs
|
|
19
|
+
note their ``search`` returns a bare array while ``list`` returns an envelope, and
|
|
20
|
+
agents trip over the difference.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from typing import Any, Callable, Iterable, Sequence
|
|
30
|
+
|
|
31
|
+
_FORCE_COLOR = "MEMORYSYNC_FORCE_COLOR"
|
|
32
|
+
_SEXAGESIMAL = re.compile(r"^\d+(:\d+)+$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _colour_default() -> bool:
|
|
36
|
+
if os.environ.get("NO_COLOR"):
|
|
37
|
+
return False
|
|
38
|
+
if os.environ.get(_FORCE_COLOR):
|
|
39
|
+
return True
|
|
40
|
+
return bool(getattr(sys.stdout, "isatty", lambda: False)())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Style:
|
|
44
|
+
"""ANSI helpers that degrade to plain text when colour is off."""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self.enabled = _colour_default()
|
|
48
|
+
|
|
49
|
+
def set_enabled(self, enabled: bool) -> None:
|
|
50
|
+
self.enabled = enabled
|
|
51
|
+
|
|
52
|
+
def _wrap(self, code: str, text: str) -> str:
|
|
53
|
+
return text if not self.enabled else f"\033[{code}m{text}\033[0m"
|
|
54
|
+
|
|
55
|
+
def dim(self, text: str) -> str:
|
|
56
|
+
return self._wrap("2", text)
|
|
57
|
+
|
|
58
|
+
def bold(self, text: str) -> str:
|
|
59
|
+
return self._wrap("1", text)
|
|
60
|
+
|
|
61
|
+
def red(self, text: str) -> str:
|
|
62
|
+
return self._wrap("31", text)
|
|
63
|
+
|
|
64
|
+
def green(self, text: str) -> str:
|
|
65
|
+
return self._wrap("32", text)
|
|
66
|
+
|
|
67
|
+
def yellow(self, text: str) -> str:
|
|
68
|
+
return self._wrap("33", text)
|
|
69
|
+
|
|
70
|
+
def cyan(self, text: str) -> str:
|
|
71
|
+
return self._wrap("36", text)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
style = Style()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def set_colour_enabled(enabled: bool) -> None:
|
|
78
|
+
style.set_enabled(enabled)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Tables
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _display_width(text: str) -> int:
|
|
87
|
+
"""Length ignoring ANSI escapes, so coloured cells still align."""
|
|
88
|
+
out, index = 0, 0
|
|
89
|
+
while index < len(text):
|
|
90
|
+
if text[index] == "\033":
|
|
91
|
+
end = text.find("m", index)
|
|
92
|
+
if end == -1:
|
|
93
|
+
break
|
|
94
|
+
index = end + 1
|
|
95
|
+
continue
|
|
96
|
+
out += 1
|
|
97
|
+
index += 1
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _truncate(text: str, width: int) -> str:
|
|
102
|
+
if _display_width(text) <= width:
|
|
103
|
+
return text
|
|
104
|
+
if width <= 1:
|
|
105
|
+
return text[:width]
|
|
106
|
+
return text[: width - 1] + "\u2026"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_table(
|
|
110
|
+
headers: Sequence[str],
|
|
111
|
+
rows: Iterable[Sequence[Any]],
|
|
112
|
+
max_widths: Sequence[int] | None = None,
|
|
113
|
+
) -> str:
|
|
114
|
+
"""A fixed-width table.
|
|
115
|
+
|
|
116
|
+
Column caps are applied before widths are measured, so one 4,000 character
|
|
117
|
+
memory body cannot push every other column off the terminal.
|
|
118
|
+
"""
|
|
119
|
+
caps = list(max_widths or [])
|
|
120
|
+
materialised = [
|
|
121
|
+
[
|
|
122
|
+
_truncate("" if cell is None else str(cell), caps[i])
|
|
123
|
+
if i < len(caps) and caps[i]
|
|
124
|
+
else ("" if cell is None else str(cell))
|
|
125
|
+
for i, cell in enumerate(row)
|
|
126
|
+
]
|
|
127
|
+
for row in rows
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
if not materialised:
|
|
131
|
+
return style.dim("(no rows)")
|
|
132
|
+
|
|
133
|
+
widths = [
|
|
134
|
+
max(_display_width(headers[i]), *(_display_width(row[i]) for row in materialised))
|
|
135
|
+
for i in range(len(headers))
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
def line(cells: Sequence[str], dim: bool) -> str:
|
|
139
|
+
parts = []
|
|
140
|
+
for index, cell in enumerate(cells):
|
|
141
|
+
padding = " " * max(0, widths[index] - _display_width(cell))
|
|
142
|
+
parts.append(cell + padding)
|
|
143
|
+
joined = " ".join(parts).rstrip()
|
|
144
|
+
return style.dim(joined) if dim else joined
|
|
145
|
+
|
|
146
|
+
return "\n".join([line(list(headers), True), *(line(row, False) for row in materialised)])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# YAML
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
_YAML_NEEDS_QUOTING_PREFIX = tuple(">|*&!%@`\"'")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _yaml_scalar(value: Any) -> str:
|
|
157
|
+
if value is None:
|
|
158
|
+
return "null"
|
|
159
|
+
if value is True:
|
|
160
|
+
return "true"
|
|
161
|
+
if value is False:
|
|
162
|
+
return "false"
|
|
163
|
+
if isinstance(value, (int, float)):
|
|
164
|
+
return str(value)
|
|
165
|
+
|
|
166
|
+
text = str(value)
|
|
167
|
+
lowered = text.lower()
|
|
168
|
+
ambiguous = (
|
|
169
|
+
text == ""
|
|
170
|
+
or text[:1].isspace()
|
|
171
|
+
or text.startswith(_YAML_NEEDS_QUOTING_PREFIX)
|
|
172
|
+
or ": " in text
|
|
173
|
+
or "\n" in text
|
|
174
|
+
or "#" in text
|
|
175
|
+
# YAML 1.1 booleans, not just 1.2's. `yes`, `no`, `on` and `off` are
|
|
176
|
+
# booleans to a 1.1 parser, and PyYAML's safe_load still is one, so a
|
|
177
|
+
# memory whose text is "yes" would come back as True. The Node CLI carries
|
|
178
|
+
# the same list; they are fixed together so `-o yaml` cannot differ.
|
|
179
|
+
or lowered.startswith(("true", "false", "yes", "no", "on", "off", "null", "~"))
|
|
180
|
+
or text[:1].isdigit()
|
|
181
|
+
or (text.startswith("-") and len(text) > 1 and text[1].isdigit())
|
|
182
|
+
# Sexagesimal: `12:30` is a number to a 1.1 parser.
|
|
183
|
+
or _SEXAGESIMAL.match(text) is not None
|
|
184
|
+
)
|
|
185
|
+
return json.dumps(text) if ambiguous else text
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def to_yaml(value: Any, indent: int = 0) -> str:
|
|
189
|
+
"""A small YAML writer for the shapes this CLI emits.
|
|
190
|
+
|
|
191
|
+
Only scalars, lists and dicts appear in API responses, so a YAML library would
|
|
192
|
+
be a dependency bought for nothing.
|
|
193
|
+
"""
|
|
194
|
+
pad = " " * indent
|
|
195
|
+
|
|
196
|
+
if isinstance(value, dict):
|
|
197
|
+
if not value:
|
|
198
|
+
return f"{pad}{{}}"
|
|
199
|
+
lines = []
|
|
200
|
+
for key, item in value.items():
|
|
201
|
+
if isinstance(item, (dict, list)) and item:
|
|
202
|
+
lines.append(f"{pad}{key}:")
|
|
203
|
+
lines.append(to_yaml(item, indent + 1))
|
|
204
|
+
else:
|
|
205
|
+
lines.append(f"{pad}{key}: {_yaml_scalar(item)}")
|
|
206
|
+
return "\n".join(lines)
|
|
207
|
+
|
|
208
|
+
if isinstance(value, list):
|
|
209
|
+
if not value:
|
|
210
|
+
return f"{pad}[]"
|
|
211
|
+
lines = []
|
|
212
|
+
for item in value:
|
|
213
|
+
if isinstance(item, (dict, list)) and item:
|
|
214
|
+
nested = to_yaml(item, indent + 1)
|
|
215
|
+
first, _, rest = nested.partition("\n")
|
|
216
|
+
lines.append(f"{pad}- {first.strip()}")
|
|
217
|
+
if rest:
|
|
218
|
+
lines.append(rest)
|
|
219
|
+
else:
|
|
220
|
+
lines.append(f"{pad}- {_yaml_scalar(item)}")
|
|
221
|
+
return "\n".join(lines)
|
|
222
|
+
|
|
223
|
+
return f"{pad}{_yaml_scalar(value)}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# The agent envelope
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def success_envelope(
|
|
232
|
+
*,
|
|
233
|
+
command: str,
|
|
234
|
+
data: Any,
|
|
235
|
+
duration_ms: int,
|
|
236
|
+
scope: dict | None = None,
|
|
237
|
+
quota: dict | None = None,
|
|
238
|
+
request_id: str | None = None,
|
|
239
|
+
) -> dict:
|
|
240
|
+
"""Key order matches the Node CLI's, because the tests compare parsed JSON but
|
|
241
|
+
humans compare output side by side."""
|
|
242
|
+
envelope: dict[str, Any] = {
|
|
243
|
+
"status": "success",
|
|
244
|
+
"command": command,
|
|
245
|
+
"duration_ms": duration_ms,
|
|
246
|
+
}
|
|
247
|
+
if scope:
|
|
248
|
+
envelope["scope"] = scope
|
|
249
|
+
if isinstance(data, list):
|
|
250
|
+
envelope["count"] = len(data)
|
|
251
|
+
envelope["data"] = data if isinstance(data, list) else [v for v in [data] if v is not None]
|
|
252
|
+
if quota:
|
|
253
|
+
envelope["quota"] = quota
|
|
254
|
+
if request_id:
|
|
255
|
+
envelope["request_id"] = request_id
|
|
256
|
+
return envelope
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def error_envelope(
|
|
260
|
+
*,
|
|
261
|
+
command: str,
|
|
262
|
+
error: Any,
|
|
263
|
+
duration_ms: int,
|
|
264
|
+
scope: dict | None = None,
|
|
265
|
+
) -> dict:
|
|
266
|
+
envelope: dict[str, Any] = {
|
|
267
|
+
"status": "error",
|
|
268
|
+
"command": command,
|
|
269
|
+
"duration_ms": duration_ms,
|
|
270
|
+
"error": {
|
|
271
|
+
"code": getattr(error, "code", None) or "error",
|
|
272
|
+
"message": getattr(error, "message", None) or str(error),
|
|
273
|
+
"exit_code": getattr(error, "exit_code", 1),
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
hint = getattr(error, "hint", None)
|
|
277
|
+
if hint:
|
|
278
|
+
envelope["error"]["hint"] = hint
|
|
279
|
+
request_id = getattr(error, "request_id", None)
|
|
280
|
+
if request_id:
|
|
281
|
+
envelope["request_id"] = request_id
|
|
282
|
+
if scope:
|
|
283
|
+
envelope["scope"] = scope
|
|
284
|
+
envelope["data"] = []
|
|
285
|
+
return envelope
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Emission
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def write(text: str) -> None:
|
|
294
|
+
sys.stdout.write(f"{text}\n")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def emit(*, fmt: str, data: Any, text: Callable[[], str]) -> None:
|
|
298
|
+
"""Render one result.
|
|
299
|
+
|
|
300
|
+
``text`` stays lazy so a JSON caller never pays to build a table it discards.
|
|
301
|
+
"""
|
|
302
|
+
if fmt == "json":
|
|
303
|
+
write(json.dumps(data, indent=2))
|
|
304
|
+
return
|
|
305
|
+
if fmt == "yaml":
|
|
306
|
+
write(to_yaml(data))
|
|
307
|
+
return
|
|
308
|
+
if fmt == "quiet":
|
|
309
|
+
# Nothing at all. The exit code is the output.
|
|
310
|
+
return
|
|
311
|
+
write(text())
|