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.
@@ -0,0 +1,259 @@
1
+ """Where the API key lives.
2
+
3
+ Three tiers, in order: the environment, then the OS keychain, then an encrypted
4
+ file readable only by the owner. Same order as the Node CLI, and reached through
5
+ the same OS tools, so a key stored by one CLI is found by the other.
6
+
7
+ Stated plainly, because it affects what a customer should expect:
8
+
9
+ macOS `security` real keychain
10
+ Linux `secret-tool` real keyring, when libsecret is installed
11
+ Windows encrypted file tier 3
12
+
13
+ Windows has no scriptable Credential Manager path that does not require a
14
+ dependency, so it lands on tier 3. That is a genuine difference rather than a
15
+ hedge, and it is the same on the Node CLI.
16
+
17
+ Tier 3 is not security theatre and is not claimed to be. The file is 0600 and the
18
+ contents are obfuscated with a key derived from the machine and user, which stops
19
+ a casual `cat` or a backup scraper. Anyone who can already run code as this user
20
+ can read it. The keychain is better, which is why it is tried first.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import getpass
27
+ import hashlib
28
+ import os
29
+ import platform
30
+ import shutil
31
+ import subprocess
32
+ from pathlib import Path
33
+
34
+ from .config import config_dir
35
+
36
+ SERVICE = "memorysync-cli"
37
+ ENV_VAR = "MEMORYSYNC_API_KEY"
38
+
39
+
40
+ def _account(profile: str) -> str:
41
+ return f"{SERVICE}:{profile}"
42
+
43
+
44
+ def _keyfile() -> Path:
45
+ return config_dir() / "credentials"
46
+
47
+
48
+ def _obfuscation_key() -> bytes:
49
+ """A key derived from the machine and the user.
50
+
51
+ Not a password: there is nobody to prompt. It ties the file to this account so
52
+ that copying it to another machine yields nothing useful, which is the actual
53
+ threat for a CLI credential file.
54
+ """
55
+ try:
56
+ user = getpass.getuser()
57
+ except Exception: # noqa: BLE001 - no controlling terminal, or no passwd entry
58
+ user = os.environ.get("USERNAME") or os.environ.get("USER") or "unknown"
59
+ seed = f"{platform.node()}|{user}|{SERVICE}".encode("utf-8")
60
+ return hashlib.sha256(seed).digest()
61
+
62
+
63
+ def _xor(data: bytes, key: bytes) -> bytes:
64
+ return bytes(byte ^ key[index % len(key)] for index, byte in enumerate(data))
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Tier 2: the OS keychain
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def _keychain_tool() -> str | None:
73
+ if platform.system() == "Darwin" and shutil.which("security"):
74
+ return "security"
75
+ if platform.system() == "Linux" and shutil.which("secret-tool"):
76
+ return "secret-tool"
77
+ return None
78
+
79
+
80
+ def _keychain_get(profile: str) -> str | None:
81
+ tool = _keychain_tool()
82
+ if tool is None:
83
+ return None
84
+ try:
85
+ if tool == "security":
86
+ result = subprocess.run(
87
+ ["security", "find-generic-password", "-a", _account(profile), "-s", SERVICE, "-w"],
88
+ capture_output=True,
89
+ text=True,
90
+ timeout=10,
91
+ )
92
+ else:
93
+ result = subprocess.run(
94
+ ["secret-tool", "lookup", "service", SERVICE, "account", _account(profile)],
95
+ capture_output=True,
96
+ text=True,
97
+ timeout=10,
98
+ )
99
+ except (OSError, subprocess.SubprocessError):
100
+ return None
101
+
102
+ value = (result.stdout or "").strip()
103
+ return value if result.returncode == 0 and value else None
104
+
105
+
106
+ def _keychain_set(profile: str, key: str) -> bool:
107
+ tool = _keychain_tool()
108
+ if tool is None:
109
+ return False
110
+ try:
111
+ if tool == "security":
112
+ result = subprocess.run(
113
+ [
114
+ "security",
115
+ "add-generic-password",
116
+ "-a",
117
+ _account(profile),
118
+ "-s",
119
+ SERVICE,
120
+ "-w",
121
+ key,
122
+ # Replace an existing entry instead of failing, so `init
123
+ # --force` behaves the same on the second run.
124
+ "-U",
125
+ ],
126
+ capture_output=True,
127
+ text=True,
128
+ timeout=10,
129
+ )
130
+ else:
131
+ result = subprocess.run(
132
+ ["secret-tool", "store", "--label", SERVICE, "service", SERVICE, "account", _account(profile)],
133
+ input=key,
134
+ capture_output=True,
135
+ text=True,
136
+ timeout=10,
137
+ )
138
+ return result.returncode == 0
139
+ except (OSError, subprocess.SubprocessError):
140
+ return False
141
+
142
+
143
+ def _keychain_delete(profile: str) -> bool:
144
+ tool = _keychain_tool()
145
+ if tool is None:
146
+ return False
147
+ try:
148
+ if tool == "security":
149
+ result = subprocess.run(
150
+ ["security", "delete-generic-password", "-a", _account(profile), "-s", SERVICE],
151
+ capture_output=True,
152
+ text=True,
153
+ timeout=10,
154
+ )
155
+ else:
156
+ result = subprocess.run(
157
+ ["secret-tool", "clear", "service", SERVICE, "account", _account(profile)],
158
+ capture_output=True,
159
+ text=True,
160
+ timeout=10,
161
+ )
162
+ return result.returncode == 0
163
+ except (OSError, subprocess.SubprocessError):
164
+ return False
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Tier 3: an owner-only file
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ def _file_read(profile: str) -> str | None:
173
+ path = _keyfile()
174
+ try:
175
+ raw = path.read_text(encoding="utf-8")
176
+ except (FileNotFoundError, OSError):
177
+ return None
178
+
179
+ for line in raw.splitlines():
180
+ name, _, blob = line.partition("=")
181
+ if name != profile or not blob:
182
+ continue
183
+ try:
184
+ return _xor(base64.b64decode(blob), _obfuscation_key()).decode("utf-8")
185
+ except Exception: # noqa: BLE001 - a corrupt line is a miss, not a crash
186
+ return None
187
+ return None
188
+
189
+
190
+ def _file_write(profile: str, key: str | None) -> None:
191
+ path = _keyfile()
192
+ path.parent.mkdir(parents=True, exist_ok=True)
193
+
194
+ entries: dict[str, str] = {}
195
+ try:
196
+ for line in path.read_text(encoding="utf-8").splitlines():
197
+ name, _, blob = line.partition("=")
198
+ if name and blob:
199
+ entries[name] = blob
200
+ except (FileNotFoundError, OSError):
201
+ pass
202
+
203
+ if key is None:
204
+ entries.pop(profile, None)
205
+ else:
206
+ entries[profile] = base64.b64encode(_xor(key.encode("utf-8"), _obfuscation_key())).decode("ascii")
207
+
208
+ # Written 0600 before any content lands, so there is no window where the file
209
+ # exists with default permissions.
210
+ descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
211
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
212
+ for name, blob in entries.items():
213
+ handle.write(f"{name}={blob}\n")
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Public surface
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ def read_key(profile: str = "default") -> str | None:
222
+ """The key for a profile, from the highest-priority source that has one.
223
+
224
+ The environment wins so CI can inject a key without touching a keychain or
225
+ writing a file to a container image.
226
+ """
227
+ from_env = os.environ.get(ENV_VAR)
228
+ if from_env:
229
+ return from_env.strip()
230
+ return _keychain_get(profile) or _file_read(profile)
231
+
232
+
233
+ def write_key(key: str, profile: str = "default") -> str:
234
+ """Store a key and report which tier accepted it.
235
+
236
+ The tier is returned rather than logged so `init` can tell the user plainly
237
+ where their key went, instead of implying a keychain that was never reached.
238
+ """
239
+ if _keychain_set(profile, key):
240
+ return "keychain"
241
+ _file_write(profile, key)
242
+ return "file"
243
+
244
+
245
+ def delete_key(profile: str = "default") -> None:
246
+ _keychain_delete(profile)
247
+ _file_write(profile, None)
248
+
249
+
250
+ def describe_storage() -> str:
251
+ """Human description of where a key would be stored, for `doctor`."""
252
+ tool = _keychain_tool()
253
+ if tool == "security":
254
+ return "macOS keychain (security)"
255
+ if tool == "secret-tool":
256
+ return "Linux keyring (secret-tool)"
257
+ if platform.system() == "Windows":
258
+ return f"encrypted file, owner-only ({_keyfile()})"
259
+ return f"encrypted file, owner-only ({_keyfile()}) - no keychain tool found"
@@ -0,0 +1,110 @@
1
+ """Failure taxonomy and exit codes.
2
+
3
+ The exit codes are a contract. A CI job or an agent loop branches on them, so they
4
+ have to match the Node CLI exactly.
5
+
6
+ They are not retyped here. The numbers come from ``registry.json``, generated from
7
+ the Node CLI's ``EXIT``, because a first draft of this module did retype them and
8
+ got three of them wrong: QUOTA 5, NETWORK 6, NOT_FOUND 4 against the real 4, 5, 6.
9
+ Nothing would have looked broken - every failure still exits non-zero - while a
10
+ script branching on 4 for "over quota" would quietly take that branch on a network
11
+ error instead. Numbers are the easiest thing to transcribe wrongly and the hardest
12
+ to spot, so they are derived.
13
+
14
+ The quota code earns its own value. The API degrades silently at a plan limit,
15
+ returning an empty result rather than an error, because a language model must not
16
+ narrate billing state to an end user. A distinct exit code is therefore the only
17
+ local signal that nothing was stored, so a script does not read a silent skip as
18
+ an empty database.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from .registry import exit_codes
24
+
25
+ _CODES = exit_codes()
26
+
27
+
28
+ class Exit:
29
+ """Process exit codes, mirroring ``EXIT`` in the Node CLI.
30
+
31
+ Read from the generated registry rather than declared, so the two CLIs cannot
32
+ disagree about what 4 means.
33
+ """
34
+
35
+ OK: int = _CODES["OK"]
36
+ FAILURE: int = _CODES["FAILURE"]
37
+ USAGE: int = _CODES["USAGE"]
38
+ AUTH: int = _CODES["AUTH"]
39
+ QUOTA: int = _CODES["QUOTA"]
40
+ NETWORK: int = _CODES["NETWORK"]
41
+ NOT_FOUND: int = _CODES["NOT_FOUND"]
42
+ INTERRUPTED: int = _CODES["INTERRUPTED"]
43
+
44
+
45
+ class CliError(Exception):
46
+ """An error carrying an exit code, a stable identifier and a next step.
47
+
48
+ ``hint`` stays separate from ``message`` so agent mode can emit them as
49
+ distinct JSON fields instead of one blob a model has to split apart.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ message: str,
55
+ *,
56
+ exit_code: int = Exit.FAILURE,
57
+ code: str = "error",
58
+ hint: str | None = None,
59
+ request_id: str | None = None,
60
+ ) -> None:
61
+ super().__init__(message)
62
+ self.message = message
63
+ self.exit_code = exit_code
64
+ self.code = code
65
+ self.hint = hint
66
+ self.request_id = request_id
67
+
68
+ def to_dict(self) -> dict[str, object]:
69
+ payload: dict[str, object] = {"error": self.message, "code": self.code}
70
+ if self.hint:
71
+ payload["hint"] = self.hint
72
+ if self.request_id:
73
+ payload["request_id"] = self.request_id
74
+ return payload
75
+
76
+
77
+ def usage_error(message: str, hint: str | None = None) -> CliError:
78
+ """The command line was wrong. Never retryable."""
79
+ return CliError(message, exit_code=Exit.USAGE, code="usage_error", hint=hint)
80
+
81
+
82
+ def auth_error(message: str, hint: str | None = None) -> CliError:
83
+ return CliError(
84
+ message,
85
+ exit_code=Exit.AUTH,
86
+ code="auth_error",
87
+ hint=hint or "Run `memorysync init` to store a key, or set MEMORYSYNC_API_KEY.",
88
+ )
89
+
90
+
91
+ def quota_error(message: str, hint: str | None = None) -> CliError:
92
+ return CliError(
93
+ message,
94
+ exit_code=Exit.QUOTA,
95
+ code="quota_exceeded",
96
+ hint=hint or "Run `memorysync quota` to see usage and when the cycle resets.",
97
+ )
98
+
99
+
100
+ def network_error(message: str, hint: str | None = None) -> CliError:
101
+ return CliError(
102
+ message,
103
+ exit_code=Exit.NETWORK,
104
+ code="network_error",
105
+ hint=hint or "Run `memorysync doctor` to check connectivity.",
106
+ )
107
+
108
+
109
+ def not_found_error(message: str, hint: str | None = None) -> CliError:
110
+ return CliError(message, exit_code=Exit.NOT_FOUND, code="not_found", hint=hint)
memorysync_cli/http.py ADDED
@@ -0,0 +1,257 @@
1
+ """The API client.
2
+
3
+ Built on ``urllib.request`` so the package keeps zero dependencies. Mem0's Python
4
+ CLI pulls in httpx; every dependency is code we would be putting on a customer's
5
+ machine that they cannot audit on our behalf, which matters more for a closed
6
+ source tool because nobody else is reading our lockfile.
7
+
8
+ The job here is turning HTTP outcomes into the failure taxonomy in ``errors.py``,
9
+ so a caller can branch on cause. Two cases deserve their own handling:
10
+
11
+ * 401/403 become AUTH, with a hint pointing at ``init`` rather than the raw body.
12
+ * A silent quota skip is not an HTTP error at all. Over a plan limit the API
13
+ answers 200 with an empty result, deliberately, so a model never repeats
14
+ billing state to an end user. The client therefore reads usage separately and
15
+ the commands decide what to say; see ``looks_like_silent_skip``.
16
+
17
+ Mirrors ``sdk/cli/src/http.mjs``, including which status maps to which exit code.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ import sys
25
+ import time
26
+ import urllib.error
27
+ import urllib.parse
28
+ import urllib.request
29
+ from typing import Any
30
+
31
+ from ._version import __version__
32
+ from .errors import (
33
+ CliError,
34
+ Exit,
35
+ auth_error,
36
+ network_error,
37
+ not_found_error,
38
+ quota_error,
39
+ )
40
+
41
+ # Header the API requires from key-authenticated callers to scope memory.
42
+ END_USER_HEADER = "X-End-User-ID"
43
+
44
+
45
+ class ApiClient:
46
+ """A thin, synchronous client over the MemorySync API."""
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ base_url: str,
52
+ api_key: str,
53
+ user: str | None = None,
54
+ project: str | None = None,
55
+ timeout: int = 60000,
56
+ verbose: bool = False,
57
+ ) -> None:
58
+ self.base_url = base_url.rstrip("/")
59
+ self.api_key = api_key
60
+ self.user = user
61
+ self.project = project
62
+ self.timeout = timeout
63
+ self.verbose = verbose
64
+
65
+ def headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
66
+ headers = {
67
+ "X-API-Key": self.api_key,
68
+ "Accept": "application/json",
69
+ # From the package version, never a literal. The Node CLI shipped
70
+ # 1.0.1 announcing itself as 1.0.0 because this was hardcoded.
71
+ "User-Agent": f"memorysync-cli-py/{__version__}",
72
+ }
73
+ # The API rejects key-authenticated memory calls without this, because
74
+ # memory is always scoped to (tenant, project, user) and storing
75
+ # everything under the key owner is not allowed. Commands validate it up
76
+ # front so the failure is a usage error, not a 400 from the server.
77
+ if self.user:
78
+ headers[END_USER_HEADER] = self.user
79
+ if self.project:
80
+ headers["X-Project-ID"] = self.project
81
+ if extra:
82
+ headers.update(extra)
83
+ return headers
84
+
85
+ def request(
86
+ self,
87
+ method: str,
88
+ path: str,
89
+ *,
90
+ body: Any = None,
91
+ query: dict[str, Any] | None = None,
92
+ timeout: int | None = None,
93
+ ) -> Any:
94
+ url = self.base_url + path
95
+ if query:
96
+ pairs = {
97
+ key: str(value)
98
+ for key, value in query.items()
99
+ if value is not None and value != ""
100
+ }
101
+ if pairs:
102
+ url = f"{url}?{urllib.parse.urlencode(pairs)}"
103
+
104
+ payload = None
105
+ headers = self.headers()
106
+ if body is not None:
107
+ payload = json.dumps(body).encode("utf-8")
108
+ headers["Content-Type"] = "application/json"
109
+
110
+ limit_ms = timeout or self.timeout
111
+ started = time.monotonic()
112
+ request = urllib.request.Request(url, data=payload, headers=headers, method=method)
113
+
114
+ try:
115
+ with urllib.request.urlopen(request, timeout=limit_ms / 1000) as response:
116
+ text = response.read().decode("utf-8", errors="replace")
117
+ status = response.status
118
+ except urllib.error.HTTPError as error:
119
+ text = error.read().decode("utf-8", errors="replace")
120
+ status = error.code
121
+ except TimeoutError:
122
+ raise network_error(
123
+ f"Request to {path} timed out after {limit_ms}ms.",
124
+ "Raise it with --timeout, or check your connection with `memorysync doctor`.",
125
+ ) from None
126
+ except urllib.error.URLError as error:
127
+ reason = getattr(error, "reason", error)
128
+ if isinstance(reason, TimeoutError):
129
+ raise network_error(
130
+ f"Request to {path} timed out after {limit_ms}ms.",
131
+ "Raise it with --timeout, or check your connection with `memorysync doctor`.",
132
+ ) from None
133
+ raise network_error(f"Could not reach {self.base_url}: {reason}") from None
134
+
135
+ if self.verbose:
136
+ elapsed = int((time.monotonic() - started) * 1000)
137
+ print(f"{method} {path} -> {status} in {elapsed}ms", file=sys.stderr)
138
+
139
+ parsed: Any = None
140
+ if text:
141
+ try:
142
+ parsed = json.loads(text)
143
+ except ValueError:
144
+ parsed = {"raw": text}
145
+
146
+ if 200 <= status < 300:
147
+ return parsed
148
+
149
+ raise self._to_error(status, parsed)
150
+
151
+ def _to_error(self, status: int, payload: Any) -> CliError:
152
+ """Map a failed response to the taxonomy.
153
+
154
+ The status alone is not enough for 429: a plan limit and a rate limit both
155
+ arrive as 429 but mean different things to a script. Only the first is
156
+ about the plan, and only the first is pointless to retry.
157
+ """
158
+ detail = payload.get("detail") if isinstance(payload, dict) else None
159
+ inner = detail if isinstance(detail, dict) else None
160
+ message = (
161
+ (inner or {}).get("message")
162
+ or (detail if isinstance(detail, str) else None)
163
+ or (payload.get("message") if isinstance(payload, dict) else None)
164
+ or f"Request failed with status {status}."
165
+ )
166
+ code = (inner or {}).get("code")
167
+
168
+ if status in (401, 403):
169
+ return auth_error(message)
170
+ if status == 404:
171
+ return not_found_error(message)
172
+ if status == 429:
173
+ is_plan_limit = code == "limit_exceeded" or bool(
174
+ re.search(r"monthly limit", message, re.IGNORECASE)
175
+ )
176
+ if is_plan_limit:
177
+ return quota_error(message)
178
+ return CliError(
179
+ message,
180
+ exit_code=Exit.NETWORK,
181
+ code="rate_limited",
182
+ hint="Too many requests. Wait and retry.",
183
+ )
184
+ if status >= 500:
185
+ return CliError(
186
+ message,
187
+ exit_code=Exit.NETWORK,
188
+ code="server_error",
189
+ hint="The API failed. Retry, and check status if it persists.",
190
+ )
191
+ return CliError(message, exit_code=Exit.FAILURE, code=code or "api_error")
192
+
193
+ # -----------------------------------------------------------------------
194
+ # Named endpoints, so commands never carry raw paths
195
+ # -----------------------------------------------------------------------
196
+
197
+ def add_memory(self, body: dict) -> Any:
198
+ return self.request("POST", "/memory/add", body=body)
199
+
200
+ def query_memory(self, body: dict) -> Any:
201
+ return self.request("POST", "/memory/query", body=body)
202
+
203
+ def list_memories(self, tenant_id: str, user_id: str, limit: int | None = None) -> Any:
204
+ tenant = urllib.parse.quote(str(tenant_id), safe="")
205
+ user = urllib.parse.quote(str(user_id), safe="")
206
+ return self.request(
207
+ "GET",
208
+ f"/v1/memory/{tenant}/{user}/list",
209
+ query={"limit": limit} if limit else None,
210
+ )
211
+
212
+ def memory_status(self, numeric_id: str) -> Any:
213
+ return self.request("GET", f"/v1/memory/status/{urllib.parse.quote(str(numeric_id), safe='')}")
214
+
215
+ def forget(self, body: dict) -> Any:
216
+ return self.request("DELETE", "/memory/forget", body=body)
217
+
218
+ # There is deliberately no purge helper here.
219
+ #
220
+ # DELETE /memory/user/purge sits under /memory/ and reads as if it clears one
221
+ # end user's memories. It does neither: it ignores the end-user header and
222
+ # erases the account behind the credential, cascading to the password hash,
223
+ # every API key, memberships, auth providers and MFA. The Node CLI exposed it
224
+ # as purgeUser() and `delete --all` called it, which deleted a live account
225
+ # during a cleanup step.
226
+ #
227
+ # The CLI has no feature that needs account erasure, so the method is absent
228
+ # rather than merely unused - an available helper with a memory-shaped name is
229
+ # what caused the incident. Clearing a scope goes through forget() above.
230
+
231
+ def projects(self) -> Any:
232
+ return self.request("GET", "/org/projects")
233
+
234
+ def usage_summary(self) -> Any:
235
+ return self.request("GET", "/org/billing/usage-summary")
236
+
237
+ def current_plan(self) -> Any:
238
+ return self.request("GET", "/org/billing/current-plan")
239
+
240
+
241
+ def looks_like_silent_skip(payload: Any) -> bool:
242
+ """Whether a 200 response is really a quota refusal.
243
+
244
+ The API degrades silently at a plan limit: `add` returns success having stored
245
+ nothing and `search` returns an empty list, on purpose, so an assistant never
246
+ narrates billing state to an end user. That design makes a refusal
247
+ indistinguishable from a genuinely empty result unless usage is read
248
+ separately, which is why the commands check rather than the client guessing.
249
+ """
250
+ if not isinstance(payload, dict):
251
+ return False
252
+ if payload.get("status") == "skipped":
253
+ return True
254
+ memory_ids = payload.get("memory_ids")
255
+ if isinstance(memory_ids, list) and not memory_ids and "candidates_extracted" in payload:
256
+ return True
257
+ return False