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.
Files changed (27) hide show
  1. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/PKG-INFO +27 -1
  2. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/README.md +26 -0
  3. memorysync_cli-1.1.0/src/memorysync_cli/_version.py +1 -0
  4. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/commands/admin.py +12 -3
  5. memorysync_cli-1.1.0/src/memorysync_cli/commands/init.py +328 -0
  6. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/config.py +52 -19
  7. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/credentials.py +67 -42
  8. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/errors.py +23 -1
  9. memorysync_cli-1.1.0/src/memorysync_cli/evaluation.py +54 -0
  10. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/http.py +31 -0
  11. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/main.py +22 -0
  12. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/output.py +8 -0
  13. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/registry.json +49 -3
  14. memorysync_cli-1.0.2/src/memorysync_cli/_version.py +0 -1
  15. memorysync_cli-1.0.2/src/memorysync_cli/commands/init.py +0 -109
  16. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/.gitignore +0 -0
  17. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/LICENSE +0 -0
  18. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/pyproject.toml +0 -0
  19. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/__init__.py +0 -0
  20. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/__main__.py +0 -0
  21. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/args.py +0 -0
  22. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/commands/__init__.py +0 -0
  23. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/commands/memory.py +0 -0
  24. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/commands/source.py +0 -0
  25. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/commands/tooling.py +0 -0
  26. {memorysync_cli-1.0.2 → memorysync_cli-1.1.0}/src/memorysync_cli/completions.py +0 -0
  27. {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.2
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
- None if key else "Run `memorysync init`, or set MEMORYSYNC_API_KEY.",
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, more
5
- importantly, it is what the Node CLI does, so a script that sets
6
- ``MEMORYSYNC_USER`` behaves the same whichever CLI is installed.
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(flag_key: str, env_key: str, stored_key: str, fallback: Any = None) -> Any:
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
- env_value = os.environ.get(env_key)
110
- if env_value:
111
- return env_value
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
- "user": pick("user", "MEMORYSYNC_USER", "user"),
128
- "project": pick("project", "MEMORYSYNC_PROJECT", "project"),
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, and reached through
5
- the same OS tools, so a key stored by one CLI is found by the other.
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
- return f"{SERVICE}:{profile}"
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
- return config_dir() / "credentials"
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
- raw = path.read_text(encoding="utf-8")
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
- entries.pop(profile, None)
205
- else:
206
- entries[profile] = base64.b64encode(_xor(key.encode("utf-8"), _obfuscation_key())).decode("ascii")
222
+ try:
223
+ path.unlink()
224
+ except (FileNotFoundError, OSError):
225
+ pass
226
+ return
207
227
 
208
- # Written 0600 before any content lands, so there is no window where the file
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
- for name, blob in entries.items():
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
- if platform.system() == "Windows":
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 "Run `memorysync init` to store a key, or set MEMORYSYNC_API_KEY.",
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