memorysync-cli 1.0.2__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/PKG-INFO +27 -1
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/README.md +26 -0
- memorysync_cli-1.1.0/src/memorysync_cli/_version.py +1 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/commands/admin.py +12 -3
- memorysync_cli-1.1.0/src/memorysync_cli/commands/init.py +328 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/config.py +52 -19
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/credentials.py +67 -42
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/errors.py +23 -1
- memorysync_cli-1.1.0/src/memorysync_cli/evaluation.py +54 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/http.py +31 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/main.py +22 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/output.py +8 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/registry.json +49 -3
- memorysync_cli-1.0.2/src/memorysync_cli/_version.py +0 -1
- memorysync_cli-1.0.2/src/memorysync_cli/commands/init.py +0 -109
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/.gitignore +0 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/LICENSE +0 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/pyproject.toml +0 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/__init__.py +0 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/__main__.py +0 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/args.py +0 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/commands/__init__.py +0 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/commands/memory.py +0 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/commands/source.py +0 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/commands/tooling.py +0 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/completions.py +0 -0
- {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memorysync-cli
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: MemorySync from your terminal. Zero dependencies.
|
|
5
5
|
Project-URL: Documentation, https://docs.memorysync.io/cli
|
|
6
6
|
Project-URL: Homepage, https://memorysync.io/cli
|
|
@@ -42,6 +42,32 @@ externally-managed Pythons — Homebrew and most Linux distributions — with
|
|
|
42
42
|
|
|
43
43
|
Requires Python 3.9 or newer.
|
|
44
44
|
|
|
45
|
+
### `memorysync: command not found` after installing
|
|
46
|
+
|
|
47
|
+
The install worked; your shell cannot see the directory `pipx` puts binaries in.
|
|
48
|
+
Run `pipx ensurepath`, then reopen the terminal. Or invoke it by full path —
|
|
49
|
+
`pipx list` prints the location.
|
|
50
|
+
|
|
51
|
+
To skip `PATH` entirely:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pipx run --spec memorysync-cli memorysync <command>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`--spec` is needed because `pipx run memorysync-cli` looks for a console script
|
|
58
|
+
named after the package, and this package's scripts are `memorysync` and `msync`.
|
|
59
|
+
|
|
60
|
+
### Both CLIs installed? Only one `memorysync` can win
|
|
61
|
+
|
|
62
|
+
The npm package installs the same two command names, so whichever directory comes
|
|
63
|
+
first on `PATH` answers. To run *this* one specifically, use the full path from
|
|
64
|
+
`pipx list`, or `pipx run --spec` as above.
|
|
65
|
+
|
|
66
|
+
`python -m memorysync_cli` works only when the package is installed into that
|
|
67
|
+
interpreter with `pip install`. After a `pipx` install it will report
|
|
68
|
+
`No module named memorysync_cli`, because pipx deliberately isolates the package
|
|
69
|
+
in its own virtualenv.
|
|
70
|
+
|
|
45
71
|
## Getting started
|
|
46
72
|
|
|
47
73
|
```bash
|
|
@@ -17,6 +17,32 @@ externally-managed Pythons — Homebrew and most Linux distributions — with
|
|
|
17
17
|
|
|
18
18
|
Requires Python 3.9 or newer.
|
|
19
19
|
|
|
20
|
+
### `memorysync: command not found` after installing
|
|
21
|
+
|
|
22
|
+
The install worked; your shell cannot see the directory `pipx` puts binaries in.
|
|
23
|
+
Run `pipx ensurepath`, then reopen the terminal. Or invoke it by full path —
|
|
24
|
+
`pipx list` prints the location.
|
|
25
|
+
|
|
26
|
+
To skip `PATH` entirely:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pipx run --spec memorysync-cli memorysync <command>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`--spec` is needed because `pipx run memorysync-cli` looks for a console script
|
|
33
|
+
named after the package, and this package's scripts are `memorysync` and `msync`.
|
|
34
|
+
|
|
35
|
+
### Both CLIs installed? Only one `memorysync` can win
|
|
36
|
+
|
|
37
|
+
The npm package installs the same two command names, so whichever directory comes
|
|
38
|
+
first on `PATH` answers. To run *this* one specifically, use the full path from
|
|
39
|
+
`pipx list`, or `pipx run --spec` as above.
|
|
40
|
+
|
|
41
|
+
`python -m memorysync_cli` works only when the package is installed into that
|
|
42
|
+
interpreter with `pip install`. After a `pipx` install it will report
|
|
43
|
+
`No module named memorysync_cli`, because pipx deliberately isolates the package
|
|
44
|
+
in its own virtualenv.
|
|
45
|
+
|
|
20
46
|
## Getting started
|
|
21
47
|
|
|
22
48
|
```bash
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.1.0"
|
|
@@ -126,7 +126,7 @@ def whoami(ctx: dict) -> dict:
|
|
|
126
126
|
"user": settings.get("user"),
|
|
127
127
|
"project": settings.get("project"),
|
|
128
128
|
"plan": plan.get("plan") or plan.get("name"),
|
|
129
|
-
"credential_source": credentials.describe_storage(),
|
|
129
|
+
"credential_source": credentials.describe_storage(settings["profile_name"]),
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
def render() -> str:
|
|
@@ -168,14 +168,23 @@ def doctor(ctx: dict) -> dict:
|
|
|
168
168
|
)
|
|
169
169
|
add("cli", True, f"memorysync-cli {__version__}")
|
|
170
170
|
add("config", True, str(config_module.config_path()))
|
|
171
|
-
add("credential store", True, credentials.describe_storage())
|
|
171
|
+
add("credential store", True, credentials.describe_storage(settings["profile_name"]))
|
|
172
172
|
|
|
173
173
|
key = credentials.read_key(settings["profile_name"])
|
|
174
174
|
add(
|
|
175
175
|
"api key",
|
|
176
176
|
bool(key),
|
|
177
177
|
f"found ({key[:8]}...)" if key else "not found",
|
|
178
|
-
|
|
178
|
+
# Names the cross-CLI case explicitly. Someone who ran `init` moments ago
|
|
179
|
+
# and is told "not found" will otherwise reasonably conclude the CLI is
|
|
180
|
+
# broken, when on Windows the key is simply in the other CLI's store.
|
|
181
|
+
None
|
|
182
|
+
if key
|
|
183
|
+
else (
|
|
184
|
+
"Run `memorysync init`, or set MEMORYSYNC_API_KEY. If you stored a key "
|
|
185
|
+
"with the Node CLI on Windows, it is not shared: the two use different "
|
|
186
|
+
"ciphers, so run init here as well."
|
|
187
|
+
),
|
|
179
188
|
)
|
|
180
189
|
add(
|
|
181
190
|
"end user",
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""init: store a credential and pick defaults.
|
|
2
|
+
|
|
3
|
+
The key is verified against the API before it is written. Storing an unverified key
|
|
4
|
+
means the next command fails with something that looks like a network problem, and
|
|
5
|
+
the person has no reason to suspect the thing they just typed.
|
|
6
|
+
|
|
7
|
+
It is never written to the config file in plain text. It goes to the OS keychain
|
|
8
|
+
where one is reachable, and otherwise to an owner-only encrypted file, and `init`
|
|
9
|
+
says which of the two happened rather than implying a keychain that was never
|
|
10
|
+
there.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import getpass
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from .. import config as config_module
|
|
20
|
+
from .. import credentials
|
|
21
|
+
from ..errors import auth_error, usage_error
|
|
22
|
+
from ..evaluation import CLAIM_HINT
|
|
23
|
+
from ..http import ApiClient
|
|
24
|
+
from ..output import style
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _init_as_agent(ctx: dict, profile_name: str) -> dict:
|
|
28
|
+
"""``init --agent`` — mint an evaluation key with no signup.
|
|
29
|
+
|
|
30
|
+
Stores the key exactly the way the interactive path does, so every later
|
|
31
|
+
command works with no further setup, and keeps the server's generated
|
|
32
|
+
end-user id as the profile default: an agent forced to invent one will invent
|
|
33
|
+
a different one next run and then wonder where its memories went.
|
|
34
|
+
"""
|
|
35
|
+
flags, settings = ctx["flags"], ctx["settings"]
|
|
36
|
+
|
|
37
|
+
# No credential yet, by definition.
|
|
38
|
+
client = ApiClient(
|
|
39
|
+
base_url=settings["base_url"], api_key=None, timeout=settings["timeout"]
|
|
40
|
+
)
|
|
41
|
+
body = {}
|
|
42
|
+
if flags.get("agent_caller"):
|
|
43
|
+
body["agent_caller"] = flags["agent_caller"]
|
|
44
|
+
minted = client.mint_evaluation_key(body) or {}
|
|
45
|
+
|
|
46
|
+
api_key = minted.get("api_key")
|
|
47
|
+
if not api_key:
|
|
48
|
+
raise auth_error(
|
|
49
|
+
"The server did not return an evaluation key.",
|
|
50
|
+
'Try again, or run "memorysync init" with a key from https://memorysync.io.',
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
tier = credentials.write_key(api_key, profile_name)
|
|
54
|
+
|
|
55
|
+
data = config_module.load()
|
|
56
|
+
entry = data.setdefault("profiles", {}).setdefault(profile_name, {})
|
|
57
|
+
user = flags.get("user") or minted.get("default_user_id")
|
|
58
|
+
if user:
|
|
59
|
+
entry["user"] = user
|
|
60
|
+
if minted.get("project_id"):
|
|
61
|
+
entry["project"] = minted["project_id"]
|
|
62
|
+
if settings["base_url"] != config_module.DEFAULT_BASE_URL:
|
|
63
|
+
entry["base_url"] = settings["base_url"]
|
|
64
|
+
path = config_module.save(data)
|
|
65
|
+
|
|
66
|
+
payload = {
|
|
67
|
+
"profile": profile_name,
|
|
68
|
+
"mode": "agent",
|
|
69
|
+
"api_key": api_key,
|
|
70
|
+
"default_user_id": minted.get("default_user_id"),
|
|
71
|
+
"project_id": minted.get("project_id"),
|
|
72
|
+
"plan": minted.get("plan"),
|
|
73
|
+
"claimed": False,
|
|
74
|
+
"expires_at": minted.get("expires_at"),
|
|
75
|
+
"claim_command": minted.get("claim_command") or CLAIM_HINT,
|
|
76
|
+
"limits": minted.get("limits"),
|
|
77
|
+
"mcp_url": minted.get("mcp_url"),
|
|
78
|
+
"stored_in": tier,
|
|
79
|
+
"config": str(path),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def render() -> str:
|
|
83
|
+
where = (
|
|
84
|
+
"the OS keychain" if tier == "keychain" else "an owner-only encrypted file"
|
|
85
|
+
)
|
|
86
|
+
return "\n".join(
|
|
87
|
+
[
|
|
88
|
+
style.green("Agent mode active."),
|
|
89
|
+
f"{style.dim('default user')} {payload['default_user_id'] or '-'}",
|
|
90
|
+
f"{style.dim('plan ')} {payload['plan'] or '-'}",
|
|
91
|
+
f"{style.dim('expires ')} {payload['expires_at'] or '-'}",
|
|
92
|
+
f"{style.dim('key stored ')} in {where}",
|
|
93
|
+
"",
|
|
94
|
+
style.dim('Try: memorysync add "I prefer TypeScript"'),
|
|
95
|
+
"",
|
|
96
|
+
style.yellow(
|
|
97
|
+
"Nobody owns this account yet. To keep it and everything in it:"
|
|
98
|
+
),
|
|
99
|
+
f" {payload['claim_command']}",
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return {"data": payload, "text": render}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _init_by_claiming(ctx: dict, profile_name: str) -> dict:
|
|
107
|
+
"""``init --email`` — claim the evaluation account behind the stored key.
|
|
108
|
+
|
|
109
|
+
Two steps in one command, chosen by whether ``--code`` is present, because
|
|
110
|
+
that is how a person experiences it: run it, read the email, run it again with
|
|
111
|
+
the code.
|
|
112
|
+
"""
|
|
113
|
+
flags, settings = ctx["flags"], ctx["settings"]
|
|
114
|
+
email = str(flags.get("email") or "").strip()
|
|
115
|
+
|
|
116
|
+
if flags.get("code"):
|
|
117
|
+
# Completing needs no credential, so this works on a machine that never
|
|
118
|
+
# held the key.
|
|
119
|
+
client = ApiClient(
|
|
120
|
+
base_url=settings["base_url"], api_key=None, timeout=settings["timeout"]
|
|
121
|
+
)
|
|
122
|
+
body = {"email": email, "code": str(flags["code"]).strip()}
|
|
123
|
+
if flags.get("password"):
|
|
124
|
+
body["password"] = str(flags["password"])
|
|
125
|
+
claimed = client.complete_evaluation_claim(body) or {}
|
|
126
|
+
|
|
127
|
+
# Recorded locally so the per-command reminder stops. The key keeps its
|
|
128
|
+
# ``ms_eval_`` prefix after claiming, so the prefix alone cannot tell us.
|
|
129
|
+
data = config_module.load()
|
|
130
|
+
entry = data.setdefault("profiles", {}).setdefault(profile_name, {})
|
|
131
|
+
entry["claimed"] = True
|
|
132
|
+
config_module.save(data)
|
|
133
|
+
|
|
134
|
+
payload = {**claimed, "profile": profile_name, "mode": "claim-complete"}
|
|
135
|
+
|
|
136
|
+
def render_complete() -> str:
|
|
137
|
+
return "\n".join(
|
|
138
|
+
[
|
|
139
|
+
style.green("Account claimed."),
|
|
140
|
+
f"{style.dim('email ')} {claimed.get('email') or email}",
|
|
141
|
+
f"{style.dim('plan ')} {claimed.get('plan') or '-'}",
|
|
142
|
+
"",
|
|
143
|
+
style.dim(
|
|
144
|
+
"Your existing key keeps working and no longer expires. "
|
|
145
|
+
"Everything stored during the evaluation is still there."
|
|
146
|
+
),
|
|
147
|
+
]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return {"data": payload, "text": render_complete}
|
|
151
|
+
|
|
152
|
+
api_key = credentials.read_key(profile_name) or os.environ.get(
|
|
153
|
+
"MEMORYSYNC_API_KEY"
|
|
154
|
+
)
|
|
155
|
+
if not api_key:
|
|
156
|
+
raise usage_error(
|
|
157
|
+
"No stored key, so there is no evaluation account to claim.",
|
|
158
|
+
'Run "memorysync init --agent" first, or pass --code to finish a claim '
|
|
159
|
+
"you already started.",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
client = ApiClient(
|
|
163
|
+
base_url=settings["base_url"], api_key=api_key, timeout=settings["timeout"]
|
|
164
|
+
)
|
|
165
|
+
started = client.start_evaluation_claim({"email": email}) or {}
|
|
166
|
+
payload = {**started, "profile": profile_name, "mode": "claim-start"}
|
|
167
|
+
|
|
168
|
+
def render_start() -> str:
|
|
169
|
+
target = started.get("email") or email
|
|
170
|
+
return "\n".join(
|
|
171
|
+
[
|
|
172
|
+
style.green("Claim code sent."),
|
|
173
|
+
f"{style.dim('email ')} {target}",
|
|
174
|
+
f"{style.dim('expires')} {started.get('expires_in_minutes') or '-'} minutes",
|
|
175
|
+
"",
|
|
176
|
+
style.dim("Check your email, then run:"),
|
|
177
|
+
f" memorysync init --email {target} --code <CODE>",
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return {"data": payload, "text": render_start}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def identify(ctx: dict) -> dict:
|
|
185
|
+
"""``identify <name>`` — replace the profile's default end-user id.
|
|
186
|
+
|
|
187
|
+
Purely local. Renaming the default cannot rename memories already stored under
|
|
188
|
+
the old id, and pretending otherwise would be worse than saying so: the output
|
|
189
|
+
names the previous id so it can still be read back with ``--user``.
|
|
190
|
+
"""
|
|
191
|
+
positionals = ctx.get("positionals") or []
|
|
192
|
+
name = (positionals[0] if positionals else "").strip()
|
|
193
|
+
if not name:
|
|
194
|
+
raise usage_error("No name given.", "Try: memorysync identify alice")
|
|
195
|
+
|
|
196
|
+
profile_name = ctx["settings"]["profile_name"]
|
|
197
|
+
data = config_module.load()
|
|
198
|
+
entry = data.setdefault("profiles", {}).setdefault(profile_name, {})
|
|
199
|
+
previous = entry.get("user")
|
|
200
|
+
entry["user"] = name
|
|
201
|
+
config_module.save(data)
|
|
202
|
+
|
|
203
|
+
payload = {
|
|
204
|
+
"profile": profile_name,
|
|
205
|
+
"end_user": name,
|
|
206
|
+
"previous_end_user": previous,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
def render() -> str:
|
|
210
|
+
lines = [style.green(f"Default end user is now {name}.")]
|
|
211
|
+
if previous and previous != name:
|
|
212
|
+
lines.append(
|
|
213
|
+
style.dim(
|
|
214
|
+
f'Memories stored as "{previous}" keep that id - '
|
|
215
|
+
f"read them with --user {previous}."
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
lines.append(style.dim("Nothing was stored under a different id."))
|
|
220
|
+
return "\n".join(lines)
|
|
221
|
+
|
|
222
|
+
return {"data": payload, "text": render}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def init(ctx: dict) -> dict:
|
|
226
|
+
flags, settings = ctx["flags"], ctx["settings"]
|
|
227
|
+
profile_name = settings["profile_name"]
|
|
228
|
+
|
|
229
|
+
# Claiming acts on the account behind an existing key rather than creating a
|
|
230
|
+
# profile, so it runs before the overwrite guard.
|
|
231
|
+
if flags.get("email"):
|
|
232
|
+
if flags.get("agent"):
|
|
233
|
+
raise usage_error(
|
|
234
|
+
"--agent mints a new account and --email claims the current one.",
|
|
235
|
+
'Run "memorysync init --agent" first, then '
|
|
236
|
+
'"memorysync init --email <address>".',
|
|
237
|
+
)
|
|
238
|
+
return _init_by_claiming(ctx, profile_name)
|
|
239
|
+
|
|
240
|
+
if flags.get("agent") and flags.get("api_key"):
|
|
241
|
+
raise usage_error(
|
|
242
|
+
"--agent mints a key, so there is nothing to pass with --api-key.",
|
|
243
|
+
"Drop one of the two.",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
existing = config_module.profile(profile_name)
|
|
247
|
+
if existing and not flags.get("force"):
|
|
248
|
+
stored = credentials.read_key(profile_name)
|
|
249
|
+
if stored:
|
|
250
|
+
raise usage_error(
|
|
251
|
+
f'Profile "{profile_name}" already has a key.',
|
|
252
|
+
"Pass --force to replace it, or --profile <name> to add another.",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if flags.get("agent"):
|
|
256
|
+
return _init_as_agent(ctx, profile_name)
|
|
257
|
+
|
|
258
|
+
api_key = flags.get("api_key") or os.environ.get("MEMORYSYNC_API_KEY")
|
|
259
|
+
if not api_key:
|
|
260
|
+
if not sys.stdin.isatty():
|
|
261
|
+
raise usage_error(
|
|
262
|
+
"No API key given and no terminal to prompt from.",
|
|
263
|
+
"Pass --api-key, or set MEMORYSYNC_API_KEY.",
|
|
264
|
+
)
|
|
265
|
+
# getpass, so the key is not echoed into the terminal or the scrollback.
|
|
266
|
+
api_key = getpass.getpass("MemorySync API key: ").strip()
|
|
267
|
+
|
|
268
|
+
if not api_key:
|
|
269
|
+
raise usage_error("No API key given.")
|
|
270
|
+
|
|
271
|
+
# Verified before storing. A key that cannot read its own plan is not usable,
|
|
272
|
+
# and finding that out now is far cheaper than on the next command.
|
|
273
|
+
probe = ApiClient(
|
|
274
|
+
base_url=settings["base_url"],
|
|
275
|
+
api_key=api_key,
|
|
276
|
+
timeout=settings["timeout"],
|
|
277
|
+
)
|
|
278
|
+
try:
|
|
279
|
+
plan = probe.current_plan() or {}
|
|
280
|
+
except Exception as error: # noqa: BLE001 - re-raised as an auth failure below
|
|
281
|
+
raise auth_error(
|
|
282
|
+
f"That key was refused: {error}",
|
|
283
|
+
"Check it in the dashboard, or pass --base-url if you are not on production.",
|
|
284
|
+
) from None
|
|
285
|
+
|
|
286
|
+
tier = credentials.write_key(api_key, profile_name)
|
|
287
|
+
|
|
288
|
+
data = config_module.load()
|
|
289
|
+
entry = data.setdefault("profiles", {}).setdefault(profile_name, {})
|
|
290
|
+
if flags.get("user"):
|
|
291
|
+
entry["user"] = flags["user"]
|
|
292
|
+
if flags.get("project"):
|
|
293
|
+
entry["project"] = flags["project"]
|
|
294
|
+
if settings["base_url"] != config_module.DEFAULT_BASE_URL:
|
|
295
|
+
entry["base_url"] = settings["base_url"]
|
|
296
|
+
path = config_module.save(data)
|
|
297
|
+
|
|
298
|
+
payload = {
|
|
299
|
+
"profile": profile_name,
|
|
300
|
+
"stored_in": tier,
|
|
301
|
+
"config": str(path),
|
|
302
|
+
"user": entry.get("user"),
|
|
303
|
+
"project": entry.get("project"),
|
|
304
|
+
"plan": plan.get("plan") or plan.get("name"),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def render() -> str:
|
|
308
|
+
where = (
|
|
309
|
+
"the OS keychain"
|
|
310
|
+
if tier == "keychain"
|
|
311
|
+
else "an owner-only encrypted file"
|
|
312
|
+
)
|
|
313
|
+
lines = [
|
|
314
|
+
f"{style.green('Ready.')} Profile {style.bold(profile_name)} is set up.",
|
|
315
|
+
f"{style.dim('key ')} stored in {where}",
|
|
316
|
+
f"{style.dim('config ')} {path}",
|
|
317
|
+
]
|
|
318
|
+
if entry.get("user"):
|
|
319
|
+
lines.append(f"{style.dim('user ')} {entry['user']}")
|
|
320
|
+
else:
|
|
321
|
+
lines.append(
|
|
322
|
+
style.dim("no default user; pass --user on each call or re-run with --user")
|
|
323
|
+
)
|
|
324
|
+
if payload["plan"]:
|
|
325
|
+
lines.append(f"{style.dim('plan ')} {payload['plan']}")
|
|
326
|
+
return "\n".join(lines)
|
|
327
|
+
|
|
328
|
+
return {"data": payload, "text": render}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
"""Profiles and settings, resolved in one documented order.
|
|
2
2
|
|
|
3
3
|
Precedence, highest first: an explicit flag, then the environment, then the config
|
|
4
|
-
file, then a built-in default. That order is what people expect and
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
file, then a built-in default. That order is what people expect and it is what the
|
|
5
|
+
Node CLI does.
|
|
6
|
+
|
|
7
|
+
Environment variable names are the Node CLI's, which are also the ones the
|
|
8
|
+
documentation lists. This module previously read ``MEMORYSYNC_USER`` and
|
|
9
|
+
``MEMORYSYNC_PROJECT`` while Node read ``MEMORYSYNC_USER_ID`` and
|
|
10
|
+
``MEMORYSYNC_PROJECT_ID``, and its own docstring claimed the two agreed. They did
|
|
11
|
+
not: every documented example that exported a default user worked under one CLI
|
|
12
|
+
and was silently ignored by the other, which then failed with "this command needs
|
|
13
|
+
an end user" and no indication why. The shorter names are still accepted as
|
|
14
|
+
aliases, because they were published and someone may have used them.
|
|
7
15
|
|
|
8
16
|
The config file never holds a credential. It holds a profile name, a default end
|
|
9
17
|
user, a project and a base URL - the things that are useful to read, commit to a
|
|
@@ -23,23 +31,23 @@ DEFAULT_TIMEOUT_MS = 60000
|
|
|
23
31
|
|
|
24
32
|
|
|
25
33
|
def config_dir() -> Path:
|
|
26
|
-
"""Where settings live.
|
|
34
|
+
"""Where settings live: ``~/.memorysync``, the same place the Node CLI uses.
|
|
27
35
|
|
|
28
36
|
``MEMORYSYNC_CONFIG_DIR`` is honoured first so tests and CI can point at a
|
|
29
37
|
scratch directory instead of a developer's real profile.
|
|
38
|
+
|
|
39
|
+
This used to follow per-platform conventions - ``%APPDATA%\\memorysync`` on
|
|
40
|
+
Windows, ``$XDG_CONFIG_HOME/memorysync`` elsewhere - which is defensible on its
|
|
41
|
+
own but meant the two CLIs kept separate profile files. Running ``init`` under
|
|
42
|
+
one and a command under the other produced "No API key found" from a machine
|
|
43
|
+
that plainly had one, and the documentation's ``~/.memorysync`` was simply
|
|
44
|
+
wrong for this CLI. One location, shared, so a profile created by either is
|
|
45
|
+
seen by both.
|
|
30
46
|
"""
|
|
31
47
|
override = os.environ.get("MEMORYSYNC_CONFIG_DIR")
|
|
32
48
|
if override:
|
|
33
49
|
return Path(override)
|
|
34
|
-
|
|
35
|
-
if os.name == "nt":
|
|
36
|
-
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
37
|
-
return Path(base) / "memorysync"
|
|
38
|
-
|
|
39
|
-
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
40
|
-
if xdg:
|
|
41
|
-
return Path(xdg) / "memorysync"
|
|
42
|
-
return Path.home() / ".config" / "memorysync"
|
|
50
|
+
return Path.home() / ".memorysync"
|
|
43
51
|
|
|
44
52
|
|
|
45
53
|
def config_path() -> Path:
|
|
@@ -102,13 +110,20 @@ def resolve(flags: dict[str, Any]) -> dict[str, Any]:
|
|
|
102
110
|
)
|
|
103
111
|
stored = profile(profile_name)
|
|
104
112
|
|
|
105
|
-
def pick(
|
|
113
|
+
def pick(
|
|
114
|
+
flag_key: str,
|
|
115
|
+
env_keys: str | tuple[str, ...],
|
|
116
|
+
stored_key: str,
|
|
117
|
+
fallback: Any = None,
|
|
118
|
+
) -> Any:
|
|
106
119
|
value = flags.get(flag_key)
|
|
107
120
|
if value not in (None, False):
|
|
108
121
|
return value
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
names = (env_keys,) if isinstance(env_keys, str) else env_keys
|
|
123
|
+
for name in names:
|
|
124
|
+
env_value = os.environ.get(name)
|
|
125
|
+
if env_value:
|
|
126
|
+
return env_value
|
|
112
127
|
if stored.get(stored_key) is not None:
|
|
113
128
|
return stored[stored_key]
|
|
114
129
|
return fallback
|
|
@@ -124,9 +139,27 @@ def resolve(flags: dict[str, Any]) -> dict[str, Any]:
|
|
|
124
139
|
"base_url": str(
|
|
125
140
|
pick("base_url", "MEMORYSYNC_BASE_URL", "base_url", DEFAULT_BASE_URL)
|
|
126
141
|
).rstrip("/"),
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
# ``_ID`` first: that is the documented name and what the Node CLI reads.
|
|
143
|
+
# The shorter form stays as an alias rather than being dropped, because it
|
|
144
|
+
# shipped and silently ignoring it would be a second surprise.
|
|
145
|
+
"user": pick("user", ("MEMORYSYNC_USER_ID", "MEMORYSYNC_USER"), "user"),
|
|
146
|
+
"project": pick(
|
|
147
|
+
"project", ("MEMORYSYNC_PROJECT_ID", "MEMORYSYNC_PROJECT"), "project"
|
|
148
|
+
),
|
|
149
|
+
# Read here as well as in ``main`` so a format stored on the profile is
|
|
150
|
+
# honoured. Node resolves it from the profile too; omitting it meant
|
|
151
|
+
# `-o table` saved into a profile worked under one CLI only.
|
|
152
|
+
"output": pick("output", "MEMORYSYNC_OUTPUT", "output"),
|
|
129
153
|
"timeout": timeout,
|
|
154
|
+
# Whether the evaluation account behind this profile has been claimed.
|
|
155
|
+
# Recorded locally by ``init --email ... --code ...`` because a claimed
|
|
156
|
+
# key keeps its ``ms_eval_`` prefix, so the prefix alone cannot tell us
|
|
157
|
+
# and the per-command reminder would never stop.
|
|
158
|
+
#
|
|
159
|
+
# Deliberately read straight off the profile rather than through ``pick``:
|
|
160
|
+
# it is a fact about the account, not a preference, so a flag or an
|
|
161
|
+
# environment variable must not be able to silence the reminder.
|
|
162
|
+
"claimed": stored.get("claimed") is True,
|
|
130
163
|
}
|
|
131
164
|
|
|
132
165
|
|
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
"""Where the API key lives.
|
|
2
2
|
|
|
3
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
|
|
5
|
-
|
|
4
|
+
file readable only by the owner. Same order as the Node CLI and reached through the
|
|
5
|
+
same OS tools.
|
|
6
|
+
|
|
7
|
+
How much is shared with the Node CLI, precisely, because the previous version of
|
|
8
|
+
this docstring claimed "a key stored by one CLI is found by the other" and that
|
|
9
|
+
was not true on any platform:
|
|
10
|
+
|
|
11
|
+
* ``MEMORYSYNC_API_KEY`` shared. Same variable, wins everywhere.
|
|
12
|
+
* keychain (macOS/Linux) shared. Both use service ``memorysync-cli`` with the
|
|
13
|
+
profile name as the account. The account strings used
|
|
14
|
+
to differ, so neither found the other's entry.
|
|
15
|
+
* file (Windows) NOT shared, and cannot be. Node encrypts with
|
|
16
|
+
AES-256-GCM from ``node:crypto``; Python's standard
|
|
17
|
+
library has no AES and this package takes no
|
|
18
|
+
dependencies, so the two cannot read each other's
|
|
19
|
+
blob. Running ``init`` under each is required.
|
|
20
|
+
|
|
21
|
+
The file also moved from ``<config>/credentials`` to
|
|
22
|
+
``<config>/credentials/<profile>.pyenc``. Now that both CLIs share
|
|
23
|
+
``~/.memorysync``, a single file at that path would collide with the directory
|
|
24
|
+
Node creates there, and writing to it would fail with "is a directory".
|
|
6
25
|
|
|
7
26
|
Stated plainly, because it affects what a customer should expect:
|
|
8
27
|
|
|
@@ -38,11 +57,24 @@ ENV_VAR = "MEMORYSYNC_API_KEY"
|
|
|
38
57
|
|
|
39
58
|
|
|
40
59
|
def _account(profile: str) -> str:
|
|
41
|
-
|
|
60
|
+
"""The keychain account, matching the Node CLI exactly.
|
|
61
|
+
|
|
62
|
+
Was ``f"{SERVICE}:{profile}"`` while Node used the bare profile name, under the
|
|
63
|
+
same service. Two entries, neither CLI finding the other's, on the two
|
|
64
|
+
platforms where a real keychain exists and sharing was actually possible.
|
|
65
|
+
"""
|
|
66
|
+
return profile
|
|
42
67
|
|
|
43
68
|
|
|
44
|
-
def _keyfile() -> Path:
|
|
45
|
-
|
|
69
|
+
def _keyfile(profile: str = "default") -> Path:
|
|
70
|
+
"""One file per profile, inside the directory the Node CLI already uses.
|
|
71
|
+
|
|
72
|
+
A distinct extension rather than a distinct directory: the blobs are not
|
|
73
|
+
interchangeable (different ciphers) but they are the same kind of thing, and
|
|
74
|
+
keeping them together means ``credentials/`` is still the one place to look,
|
|
75
|
+
and to delete.
|
|
76
|
+
"""
|
|
77
|
+
return config_dir() / "credentials" / f"{profile}.pyenc"
|
|
46
78
|
|
|
47
79
|
|
|
48
80
|
def _obfuscation_key() -> bytes:
|
|
@@ -170,47 +202,37 @@ def _keychain_delete(profile: str) -> bool:
|
|
|
170
202
|
|
|
171
203
|
|
|
172
204
|
def _file_read(profile: str) -> str | None:
|
|
173
|
-
path = _keyfile()
|
|
205
|
+
path = _keyfile(profile)
|
|
174
206
|
try:
|
|
175
|
-
|
|
176
|
-
except (FileNotFoundError, OSError):
|
|
207
|
+
blob = path.read_text(encoding="utf-8").strip()
|
|
208
|
+
except (FileNotFoundError, OSError, IsADirectoryError):
|
|
209
|
+
return None
|
|
210
|
+
if not blob:
|
|
211
|
+
return None
|
|
212
|
+
try:
|
|
213
|
+
return _xor(base64.b64decode(blob), _obfuscation_key()).decode("utf-8")
|
|
214
|
+
except Exception: # noqa: BLE001 - a corrupt file is a miss, not a crash
|
|
177
215
|
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
216
|
|
|
189
217
|
|
|
190
218
|
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
|
|
219
|
+
path = _keyfile(profile)
|
|
202
220
|
|
|
203
221
|
if key is None:
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
222
|
+
try:
|
|
223
|
+
path.unlink()
|
|
224
|
+
except (FileNotFoundError, OSError):
|
|
225
|
+
pass
|
|
226
|
+
return
|
|
207
227
|
|
|
208
|
-
|
|
228
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
blob = base64.b64encode(_xor(key.encode("utf-8"), _obfuscation_key())).decode("ascii")
|
|
230
|
+
|
|
231
|
+
# Opened 0600 before any content lands, so there is no window in which the file
|
|
209
232
|
# exists with default permissions.
|
|
210
233
|
descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
211
234
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
212
|
-
|
|
213
|
-
handle.write(f"{name}={blob}\n")
|
|
235
|
+
handle.write(blob + "\n")
|
|
214
236
|
|
|
215
237
|
|
|
216
238
|
# ---------------------------------------------------------------------------
|
|
@@ -247,13 +269,16 @@ def delete_key(profile: str = "default") -> None:
|
|
|
247
269
|
_file_write(profile, None)
|
|
248
270
|
|
|
249
271
|
|
|
250
|
-
def describe_storage() -> str:
|
|
251
|
-
"""Human description of where a key would be stored, for `doctor`.
|
|
272
|
+
def describe_storage(profile: str = "default") -> str:
|
|
273
|
+
"""Human description of where a key would be stored, for `doctor`.
|
|
274
|
+
|
|
275
|
+
Says "this CLI only" on the file tier so a user who stored a key with the Node
|
|
276
|
+
CLI is told why it is not visible here, rather than being left to conclude the
|
|
277
|
+
key was never saved.
|
|
278
|
+
"""
|
|
252
279
|
tool = _keychain_tool()
|
|
253
280
|
if tool == "security":
|
|
254
|
-
return "macOS keychain (security)"
|
|
281
|
+
return "macOS keychain (security), shared with the Node CLI"
|
|
255
282
|
if tool == "secret-tool":
|
|
256
|
-
return "Linux keyring (secret-tool)"
|
|
257
|
-
|
|
258
|
-
return f"encrypted file, owner-only ({_keyfile()})"
|
|
259
|
-
return f"encrypted file, owner-only ({_keyfile()}) - no keychain tool found"
|
|
283
|
+
return "Linux keyring (secret-tool), shared with the Node CLI"
|
|
284
|
+
return f"encrypted file, owner-only, this CLI only ({_keyfile(profile)})"
|
|
@@ -20,6 +20,8 @@ an empty database.
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
+
import platform
|
|
24
|
+
|
|
23
25
|
from .registry import exit_codes
|
|
24
26
|
|
|
25
27
|
_CODES = exit_codes()
|
|
@@ -79,12 +81,32 @@ def usage_error(message: str, hint: str | None = None) -> CliError:
|
|
|
79
81
|
return CliError(message, exit_code=Exit.USAGE, code="usage_error", hint=hint)
|
|
80
82
|
|
|
81
83
|
|
|
84
|
+
def _default_auth_hint() -> str:
|
|
85
|
+
"""Why "no key" can be wrong-looking rather than wrong.
|
|
86
|
+
|
|
87
|
+
On Windows the two CLIs cannot share a stored key: Node encrypts with
|
|
88
|
+
AES-256-GCM and this package has no AES available without taking a dependency.
|
|
89
|
+
Someone who ran `memorysync init` seconds ago -- possibly reaching the *other*
|
|
90
|
+
CLI, since both install a binary of that name -- is otherwise told "No API key
|
|
91
|
+
found" by a machine that visibly has one, with nothing to act on.
|
|
92
|
+
"""
|
|
93
|
+
base = "Run `memorysync init` to store a key, or set MEMORYSYNC_API_KEY."
|
|
94
|
+
if platform.system() == "Windows":
|
|
95
|
+
return (
|
|
96
|
+
base
|
|
97
|
+
+ " On Windows the Node and Python CLIs keep separate key files, so a"
|
|
98
|
+
+ " key stored by one is not visible to the other; run init here too,"
|
|
99
|
+
+ " or set MEMORYSYNC_API_KEY, which both read."
|
|
100
|
+
)
|
|
101
|
+
return base
|
|
102
|
+
|
|
103
|
+
|
|
82
104
|
def auth_error(message: str, hint: str | None = None) -> CliError:
|
|
83
105
|
return CliError(
|
|
84
106
|
message,
|
|
85
107
|
exit_code=Exit.AUTH,
|
|
86
108
|
code="auth_error",
|
|
87
|
-
hint=hint or
|
|
109
|
+
hint=hint or _default_auth_hint(),
|
|
88
110
|
)
|
|
89
111
|
|
|
90
112
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Evaluation-key awareness shared by every command.
|
|
2
|
+
|
|
3
|
+
Mirrors ``sdk/cli/src/evaluation.mjs`` exactly. The two CLIs are documented as
|
|
4
|
+
interchangeable, so a reminder that appears in one and not the other is a parity
|
|
5
|
+
bug, not a cosmetic difference.
|
|
6
|
+
|
|
7
|
+
An evaluation key is minted by an agent and expires in seven days unless a human
|
|
8
|
+
claims it. The most expensive outcome is somebody building on one, never noticing
|
|
9
|
+
it is temporary, and losing the account on day eight — so every command running
|
|
10
|
+
on an unclaimed key ends with one line saying so, on stderr.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Mapping, Optional
|
|
16
|
+
|
|
17
|
+
#: Prefix the server puts on anonymously minted keys.
|
|
18
|
+
EVALUATION_KEY_PREFIX = "ms_eval_"
|
|
19
|
+
|
|
20
|
+
#: The command a human runs to keep the account.
|
|
21
|
+
CLAIM_HINT = "memorysync init --email you@example.com"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_evaluation_key(key: Optional[str]) -> bool:
|
|
25
|
+
"""True when *key* was minted anonymously."""
|
|
26
|
+
return isinstance(key, str) and key.startswith(EVALUATION_KEY_PREFIX)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def claim_reminder(
|
|
30
|
+
api_key: Optional[str] = None,
|
|
31
|
+
profile: Optional[Mapping[str, Any]] = None,
|
|
32
|
+
) -> Optional[str]:
|
|
33
|
+
"""The reminder line for this invocation, or ``None``.
|
|
34
|
+
|
|
35
|
+
Reads ``claimed`` from the local profile rather than asking the API. Checking
|
|
36
|
+
on every command would add a round-trip to ``memorysync --help``, and the
|
|
37
|
+
claim path already knows the answer the moment it succeeds, so it records it.
|
|
38
|
+
"""
|
|
39
|
+
if not is_evaluation_key(api_key):
|
|
40
|
+
return None
|
|
41
|
+
if profile and profile.get("claimed") is True:
|
|
42
|
+
return None
|
|
43
|
+
return (
|
|
44
|
+
"This is an unclaimed evaluation key and expires. "
|
|
45
|
+
f"Keep it: {CLAIM_HINT}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"EVALUATION_KEY_PREFIX",
|
|
51
|
+
"CLAIM_HINT",
|
|
52
|
+
"is_evaluation_key",
|
|
53
|
+
"claim_reminder",
|
|
54
|
+
]
|
|
@@ -194,6 +194,37 @@ class ApiClient:
|
|
|
194
194
|
# Named endpoints, so commands never carry raw paths
|
|
195
195
|
# -----------------------------------------------------------------------
|
|
196
196
|
|
|
197
|
+
def mint_evaluation_key(self, body: dict) -> Any:
|
|
198
|
+
"""Mint an evaluation key. The only endpoint here needing no credential.
|
|
199
|
+
|
|
200
|
+
Rate limited server-side per IP and per /24, failing closed, so a 429 is a
|
|
201
|
+
real answer rather than a transient error — it carries
|
|
202
|
+
``retry_after_seconds`` pointing at the next UTC day.
|
|
203
|
+
"""
|
|
204
|
+
return self.request("POST", "/evaluation/keys", body=body)
|
|
205
|
+
|
|
206
|
+
def start_evaluation_claim(self, body: dict) -> Any:
|
|
207
|
+
"""Ask for a claim code by email. Needs the evaluation key."""
|
|
208
|
+
return self.request("POST", "/evaluation/claim/start", body=body)
|
|
209
|
+
|
|
210
|
+
def complete_evaluation_claim(self, body: dict) -> Any:
|
|
211
|
+
"""Redeem a claim code.
|
|
212
|
+
|
|
213
|
+
Deliberately does not require the key: the code is the proof, so a claim
|
|
214
|
+
started on one machine can be finished on another — which is what happens
|
|
215
|
+
whenever somebody reads the code off their phone.
|
|
216
|
+
"""
|
|
217
|
+
return self.request("POST", "/evaluation/claim/complete", body=body)
|
|
218
|
+
|
|
219
|
+
def evaluation_usage(self) -> Any:
|
|
220
|
+
"""Remaining allowance for an evaluation key.
|
|
221
|
+
|
|
222
|
+
Evaluation keys are not granted ``billing:read``, so
|
|
223
|
+
``/org/billing/usage`` is closed to them. This reports the same counters
|
|
224
|
+
without the pricing fields.
|
|
225
|
+
"""
|
|
226
|
+
return self.request("GET", "/evaluation/usage")
|
|
227
|
+
|
|
197
228
|
def add_memory(self, body: dict) -> Any:
|
|
198
229
|
return self.request("POST", "/memory/add", body=body)
|
|
199
230
|
|
|
@@ -21,6 +21,7 @@ from .args import parse_args, suggest
|
|
|
21
21
|
from .commands import admin, init as init_command, memory, source as source_command, tooling
|
|
22
22
|
from .credentials import read_key
|
|
23
23
|
from .errors import CliError, Exit, auth_error, usage_error
|
|
24
|
+
from .evaluation import claim_reminder
|
|
24
25
|
from .http import ApiClient
|
|
25
26
|
from .output import (
|
|
26
27
|
emit,
|
|
@@ -37,8 +38,23 @@ Handler = Callable[[dict], dict]
|
|
|
37
38
|
# which is the same reason the Node CLI keeps its HANDLERS map next to its own. A
|
|
38
39
|
# name in the registry with no entry here fails as "not available" rather than
|
|
39
40
|
# "unknown command", so `help --json` stays honest about what exists.
|
|
41
|
+
def _note_unclaimed_key(api_key: str | None, settings: dict) -> None:
|
|
42
|
+
"""Tell the user their evaluation key is temporary, once per command.
|
|
43
|
+
|
|
44
|
+
On stderr so stdout stays machine-parseable — an agent piping ``--json``
|
|
45
|
+
somewhere sees nothing extra, and a person sees the line. Silent for a claimed
|
|
46
|
+
key or a normal one. Matches the Node CLI line for line.
|
|
47
|
+
"""
|
|
48
|
+
reminder = claim_reminder(
|
|
49
|
+
api_key, {"claimed": bool((settings or {}).get("claimed"))}
|
|
50
|
+
)
|
|
51
|
+
if reminder:
|
|
52
|
+
print(reminder, file=sys.stderr)
|
|
53
|
+
|
|
54
|
+
|
|
40
55
|
_HANDLERS: dict[str, Handler] = {
|
|
41
56
|
"init": init_command.init,
|
|
57
|
+
"identify": init_command.identify,
|
|
42
58
|
"add": memory.add,
|
|
43
59
|
"search": memory.search,
|
|
44
60
|
"list": memory.list_memories,
|
|
@@ -260,6 +276,7 @@ def dispatch(argv: list[str]) -> int:
|
|
|
260
276
|
# command list.
|
|
261
277
|
if command_name == "help" and (agent_mode or fmt == "json"):
|
|
262
278
|
write(json.dumps(result["data"], indent=2))
|
|
279
|
+
_note_unclaimed_key(api_key, settings)
|
|
263
280
|
return Exit.OK
|
|
264
281
|
|
|
265
282
|
if agent_mode:
|
|
@@ -274,6 +291,7 @@ def dispatch(argv: list[str]) -> int:
|
|
|
274
291
|
indent=2,
|
|
275
292
|
)
|
|
276
293
|
)
|
|
294
|
+
_note_unclaimed_key(api_key, settings)
|
|
277
295
|
return result.get("exit_code", Exit.OK)
|
|
278
296
|
|
|
279
297
|
# `completion` and `mcp` emit text that must not be decorated or reshaped.
|
|
@@ -281,9 +299,13 @@ def dispatch(argv: list[str]) -> int:
|
|
|
281
299
|
text = result["text"]()
|
|
282
300
|
if text:
|
|
283
301
|
write(text)
|
|
302
|
+
# No reminder here: `completion` and `mcp` emit text that gets pasted
|
|
303
|
+
# into a shell profile or a config file, and a stray line would be
|
|
304
|
+
# pasted too.
|
|
284
305
|
return result.get("exit_code", Exit.OK)
|
|
285
306
|
|
|
286
307
|
emit(fmt=fmt, data=result["data"], text=result["text"])
|
|
308
|
+
_note_unclaimed_key(api_key, settings)
|
|
287
309
|
return result.get("exit_code", Exit.OK)
|
|
288
310
|
|
|
289
311
|
except CliError as error:
|
|
@@ -33,10 +33,18 @@ _SEXAGESIMAL = re.compile(r"^\d+(:\d+)+$")
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def _colour_default() -> bool:
|
|
36
|
+
"""Same decision the Node CLI makes, including ``TERM=dumb``.
|
|
37
|
+
|
|
38
|
+
``TERM=dumb`` was honoured by Node and ignored here, so a terminal that
|
|
39
|
+
announces it cannot render ANSI got escape codes from one CLI and clean text
|
|
40
|
+
from the other.
|
|
41
|
+
"""
|
|
36
42
|
if os.environ.get("NO_COLOR"):
|
|
37
43
|
return False
|
|
38
44
|
if os.environ.get(_FORCE_COLOR):
|
|
39
45
|
return True
|
|
46
|
+
if os.environ.get("TERM") == "dumb":
|
|
47
|
+
return False
|
|
40
48
|
return bool(getattr(sys.stdout, "isatty", lambda: False)())
|
|
41
49
|
|
|
42
50
|
|
|
@@ -92,14 +92,38 @@
|
|
|
92
92
|
{
|
|
93
93
|
"name": "init",
|
|
94
94
|
"summary": "Store a credential and pick a default user and project.",
|
|
95
|
-
"description": "Prompts for an API key, verifies it against the API, then writes a profile. The key is never written to the config file in plain text: it goes to the OS keychain where one is reachable, and otherwise to an encrypted file readable only by you.",
|
|
96
|
-
"usage": "memorysync init [--api-key <key>] [--user <id>] [--project <id>] [--force]",
|
|
95
|
+
"description": "Prompts for an API key, verifies it against the API, then writes a profile. The key is never written to the config file in plain text: it goes to the OS keychain where one is reachable, and otherwise to an encrypted file readable only by you.\n\nWith --agent it mints an evaluation key instead of asking for one, so a coding agent can get working memory with no email, no verification code and no human. That key expires after seven days and nobody owns it until somebody claims it with --email.",
|
|
96
|
+
"usage": "memorysync init [--api-key <key> | --agent | --email <address>] [--user <id>] [--project <id>] [--force]",
|
|
97
97
|
"flags": [
|
|
98
98
|
{
|
|
99
99
|
"name": "--api-key",
|
|
100
100
|
"value": "key",
|
|
101
101
|
"description": "Skip the prompt and use this key."
|
|
102
102
|
},
|
|
103
|
+
{
|
|
104
|
+
"name": "--agent",
|
|
105
|
+
"description": "Mint an evaluation key with no signup. Expires in 7 days unless claimed."
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "--agent-caller",
|
|
109
|
+
"value": "name",
|
|
110
|
+
"description": "Which tool is minting the key, e.g. claude-code. Attribution only."
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"name": "--email",
|
|
114
|
+
"value": "address",
|
|
115
|
+
"description": "Claim the current evaluation account. Sends a code, then pass it with --code."
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "--code",
|
|
119
|
+
"value": "code",
|
|
120
|
+
"description": "The claim code from the email."
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"name": "--password",
|
|
124
|
+
"value": "password",
|
|
125
|
+
"description": "Set a dashboard password while claiming. Optional."
|
|
126
|
+
},
|
|
103
127
|
{
|
|
104
128
|
"name": "--user",
|
|
105
129
|
"short": "-u",
|
|
@@ -124,7 +148,29 @@
|
|
|
124
148
|
],
|
|
125
149
|
"examples": [
|
|
126
150
|
"memorysync init",
|
|
127
|
-
"memorysync init --api-key ms_live_xxx --user alice --force"
|
|
151
|
+
"memorysync init --api-key ms_live_xxx --user alice --force",
|
|
152
|
+
"memorysync init --agent --agent-caller claude-code",
|
|
153
|
+
"memorysync init --email you@example.com",
|
|
154
|
+
"memorysync init --email you@example.com --code K7MP-3XQR"
|
|
155
|
+
],
|
|
156
|
+
"requires_auth": false
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"name": "identify",
|
|
160
|
+
"summary": "Name the end user that later commands default to.",
|
|
161
|
+
"description": "An evaluation key starts with a generated end-user id like swift-otter-4821, because a key minted by an agent has nobody to name it after. This replaces it with something meaningful.\n\nOnly changes the local default: memories already stored under the old id keep that id, so pass --user explicitly to read them back.",
|
|
162
|
+
"usage": "memorysync identify <name> [--profile <name>]",
|
|
163
|
+
"flags": [
|
|
164
|
+
{
|
|
165
|
+
"name": "--profile",
|
|
166
|
+
"short": "-p",
|
|
167
|
+
"value": "name",
|
|
168
|
+
"description": "Profile to change. Default \"default\"."
|
|
169
|
+
}
|
|
170
|
+
],
|
|
171
|
+
"examples": [
|
|
172
|
+
"memorysync identify alice",
|
|
173
|
+
"memorysync identify alice@example.com"
|
|
128
174
|
],
|
|
129
175
|
"requires_auth": false
|
|
130
176
|
},
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.0.2"
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
"""init: store a credential and pick defaults.
|
|
2
|
-
|
|
3
|
-
The key is verified against the API before it is written. Storing an unverified key
|
|
4
|
-
means the next command fails with something that looks like a network problem, and
|
|
5
|
-
the person has no reason to suspect the thing they just typed.
|
|
6
|
-
|
|
7
|
-
It is never written to the config file in plain text. It goes to the OS keychain
|
|
8
|
-
where one is reachable, and otherwise to an owner-only encrypted file, and `init`
|
|
9
|
-
says which of the two happened rather than implying a keychain that was never
|
|
10
|
-
there.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import getpass
|
|
16
|
-
import os
|
|
17
|
-
import sys
|
|
18
|
-
|
|
19
|
-
from .. import config as config_module
|
|
20
|
-
from .. import credentials
|
|
21
|
-
from ..errors import auth_error, usage_error
|
|
22
|
-
from ..http import ApiClient
|
|
23
|
-
from ..output import style
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def init(ctx: dict) -> dict:
|
|
27
|
-
flags, settings = ctx["flags"], ctx["settings"]
|
|
28
|
-
profile_name = settings["profile_name"]
|
|
29
|
-
|
|
30
|
-
existing = config_module.profile(profile_name)
|
|
31
|
-
if existing and not flags.get("force"):
|
|
32
|
-
stored = credentials.read_key(profile_name)
|
|
33
|
-
if stored:
|
|
34
|
-
raise usage_error(
|
|
35
|
-
f'Profile "{profile_name}" already has a key.',
|
|
36
|
-
"Pass --force to replace it, or --profile <name> to add another.",
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
api_key = flags.get("api_key") or os.environ.get("MEMORYSYNC_API_KEY")
|
|
40
|
-
if not api_key:
|
|
41
|
-
if not sys.stdin.isatty():
|
|
42
|
-
raise usage_error(
|
|
43
|
-
"No API key given and no terminal to prompt from.",
|
|
44
|
-
"Pass --api-key, or set MEMORYSYNC_API_KEY.",
|
|
45
|
-
)
|
|
46
|
-
# getpass, so the key is not echoed into the terminal or the scrollback.
|
|
47
|
-
api_key = getpass.getpass("MemorySync API key: ").strip()
|
|
48
|
-
|
|
49
|
-
if not api_key:
|
|
50
|
-
raise usage_error("No API key given.")
|
|
51
|
-
|
|
52
|
-
# Verified before storing. A key that cannot read its own plan is not usable,
|
|
53
|
-
# and finding that out now is far cheaper than on the next command.
|
|
54
|
-
probe = ApiClient(
|
|
55
|
-
base_url=settings["base_url"],
|
|
56
|
-
api_key=api_key,
|
|
57
|
-
timeout=settings["timeout"],
|
|
58
|
-
)
|
|
59
|
-
try:
|
|
60
|
-
plan = probe.current_plan() or {}
|
|
61
|
-
except Exception as error: # noqa: BLE001 - re-raised as an auth failure below
|
|
62
|
-
raise auth_error(
|
|
63
|
-
f"That key was refused: {error}",
|
|
64
|
-
"Check it in the dashboard, or pass --base-url if you are not on production.",
|
|
65
|
-
) from None
|
|
66
|
-
|
|
67
|
-
tier = credentials.write_key(api_key, profile_name)
|
|
68
|
-
|
|
69
|
-
data = config_module.load()
|
|
70
|
-
entry = data.setdefault("profiles", {}).setdefault(profile_name, {})
|
|
71
|
-
if flags.get("user"):
|
|
72
|
-
entry["user"] = flags["user"]
|
|
73
|
-
if flags.get("project"):
|
|
74
|
-
entry["project"] = flags["project"]
|
|
75
|
-
if settings["base_url"] != config_module.DEFAULT_BASE_URL:
|
|
76
|
-
entry["base_url"] = settings["base_url"]
|
|
77
|
-
path = config_module.save(data)
|
|
78
|
-
|
|
79
|
-
payload = {
|
|
80
|
-
"profile": profile_name,
|
|
81
|
-
"stored_in": tier,
|
|
82
|
-
"config": str(path),
|
|
83
|
-
"user": entry.get("user"),
|
|
84
|
-
"project": entry.get("project"),
|
|
85
|
-
"plan": plan.get("plan") or plan.get("name"),
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
def render() -> str:
|
|
89
|
-
where = (
|
|
90
|
-
"the OS keychain"
|
|
91
|
-
if tier == "keychain"
|
|
92
|
-
else "an owner-only encrypted file"
|
|
93
|
-
)
|
|
94
|
-
lines = [
|
|
95
|
-
f"{style.green('Ready.')} Profile {style.bold(profile_name)} is set up.",
|
|
96
|
-
f"{style.dim('key ')} stored in {where}",
|
|
97
|
-
f"{style.dim('config ')} {path}",
|
|
98
|
-
]
|
|
99
|
-
if entry.get("user"):
|
|
100
|
-
lines.append(f"{style.dim('user ')} {entry['user']}")
|
|
101
|
-
else:
|
|
102
|
-
lines.append(
|
|
103
|
-
style.dim("no default user; pass --user on each call or re-run with --user")
|
|
104
|
-
)
|
|
105
|
-
if payload["plan"]:
|
|
106
|
-
lines.append(f"{style.dim('plan ')} {payload['plan']}")
|
|
107
|
-
return "\n".join(lines)
|
|
108
|
-
|
|
109
|
-
return {"data": payload, "text": render}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|