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/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()
@@ -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())