memorysync-cli 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ """source: inspect and control connectors.
2
+
3
+ No competitor's CLI touches connectors at all, which is the reason this exists: a
4
+ connector that silently stopped syncing is invisible until someone notices missing
5
+ memory, and the dashboard is a poor place to check it from a deployment script.
6
+
7
+ Only sync, pause and resume. There is no disconnect: removing a connector purges
8
+ everything derived from it, which is not a thing to make one keystroke away in a
9
+ tool that also runs in CI. That stays in the dashboard.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import urllib.parse
15
+ from typing import Any
16
+
17
+ from ..errors import not_found_error, usage_error
18
+ from ..output import render_table, style
19
+
20
+ _ACTIONS = {"sync", "pause", "resume"}
21
+
22
+
23
+ def _fetch(api: Any) -> list[dict]:
24
+ raw = api.request("GET", "/api/v1/integrations")
25
+ entries = raw if isinstance(raw, list) else (raw or {}).get("integrations") or []
26
+ return [entry for entry in entries if isinstance(entry, dict)]
27
+
28
+
29
+ def _colour_status(value: Any) -> str:
30
+ text = str(value or "unknown")
31
+ if text in {"error", "failed", "disconnected"}:
32
+ return style.red(text)
33
+ if text in {"syncing", "pending"}:
34
+ return style.yellow(text)
35
+ if text in {"connected", "active", "ok"}:
36
+ return style.green(text)
37
+ return text
38
+
39
+
40
+ def _resolve(api: Any, needle: str) -> dict:
41
+ """Find a connector by id or by provider name.
42
+
43
+ Accepting the provider name matters for usability: nobody remembers
44
+ `int_9f3a2c`, everybody remembers `github`.
45
+ """
46
+ entries = _fetch(api)
47
+ lowered = needle.lower()
48
+ for entry in entries:
49
+ if str(entry.get("id", "")).lower() == lowered:
50
+ return entry
51
+ matches = [e for e in entries if str(e.get("provider", "")).lower() == lowered]
52
+ if len(matches) == 1:
53
+ return matches[0]
54
+ if len(matches) > 1:
55
+ raise usage_error(
56
+ f'Several connectors match "{needle}".',
57
+ "Use the id instead: " + ", ".join(str(m.get("id")) for m in matches),
58
+ )
59
+ raise not_found_error(
60
+ f'No connector matching "{needle}".',
61
+ "Run `memorysync source list` to see what is connected.",
62
+ )
63
+
64
+
65
+ def source(ctx: dict) -> dict:
66
+ positionals, flags, api = ctx["positionals"], ctx["flags"], ctx["api"]
67
+ sub = positionals[0] if positionals else "list"
68
+ argument = positionals[1] if len(positionals) > 1 else None
69
+
70
+ if sub == "list":
71
+ entries = _fetch(api)
72
+ rows = [
73
+ {
74
+ "id": entry.get("id"),
75
+ "provider": entry.get("provider"),
76
+ "status": entry.get("status"),
77
+ "last_sync": entry.get("last_sync_at") or entry.get("last_synced_at"),
78
+ }
79
+ for entry in entries
80
+ ]
81
+
82
+ def render() -> str:
83
+ if not rows:
84
+ return style.dim("No connectors are configured.")
85
+ return render_table(
86
+ ["id", "provider", "status", "last sync"],
87
+ [
88
+ [r["id"], r["provider"], _colour_status(r["status"]), (r["last_sync"] or "-")[:19]]
89
+ for r in rows
90
+ ],
91
+ )
92
+
93
+ return {"data": rows, "text": render}
94
+
95
+ if sub == "status":
96
+ if not argument:
97
+ raise usage_error("Which source?", "memorysync source status github")
98
+ match = _resolve(api, argument)
99
+ return {
100
+ "data": match,
101
+ "text": lambda: "\n".join(
102
+ f"{style.dim(str(key).ljust(22))} "
103
+ f"{_colour_status(value) if key == 'status' else ('-' if value is None else value)}"
104
+ for key, value in match.items()
105
+ ),
106
+ }
107
+
108
+ if sub in _ACTIONS:
109
+ if not argument:
110
+ raise usage_error("Which source?", f"memorysync source {sub} github")
111
+ match = _resolve(api, argument)
112
+
113
+ if flags.get("dry_run"):
114
+ return {
115
+ "data": {"action": sub, "source": match.get("id"), "applied": False},
116
+ "text": lambda: style.yellow(
117
+ f"Dry run. Would {sub} {match.get('provider') or match.get('id')}."
118
+ ),
119
+ }
120
+
121
+ identifier = urllib.parse.quote(str(match.get("id")), safe="")
122
+ result = api.request("POST", f"/api/v1/integrations/{identifier}/{sub}", body={})
123
+ label = "Sync triggered for" if sub == "sync" else f"{sub.capitalize()}d"
124
+ return {
125
+ "data": {"action": sub, "source": match.get("id"), "result": result},
126
+ "text": lambda: f"{style.green(label)} {match.get('provider') or match.get('id')}",
127
+ }
128
+
129
+ raise usage_error(
130
+ f'Unknown subcommand "{sub}".',
131
+ "Try list, status, sync, pause or resume.",
132
+ )
@@ -0,0 +1,238 @@
1
+ """help, version, completion.
2
+
3
+ ``help --json`` is the agent-discovery contract, and for a closed-source tool it
4
+ is the documentation of record: nobody can read our source to find out what a
5
+ command really takes, so a stale answer here is worse than none.
6
+
7
+ It is emitted straight from the shared registry, so it cannot describe a flag the
8
+ parser rejects, and it is byte-identical to the Node CLI's because both read the
9
+ same generated tree.
10
+
11
+ Worth stating plainly why that matters. Mem0 is the only competitor shipping two
12
+ CLIs and documents them as identical. Their Python CLI answers ``help --json``
13
+ with twelve commands and full options; their Node CLI answers with a name, a
14
+ version and a description, so an agent asking the Node one what it can do learns
15
+ nothing. Sharing the declaration is what stops that happening here.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import Any
22
+
23
+ from .. import registry
24
+ from .._version import __version__
25
+ from ..errors import CliError, Exit, usage_error
26
+ from ..output import style
27
+
28
+ VERSION = __version__
29
+
30
+
31
+ def version(ctx: dict) -> dict:
32
+ # An object, not a list. The envelope wraps non-lists into `data: [...]`
33
+ # itself, so returning a list here would produce `[[{...}]]` in agent mode.
34
+ return {"data": {"version": VERSION}, "text": lambda: VERSION}
35
+
36
+
37
+ def _flag_line(flag: dict) -> str:
38
+ """One flag row, padded exactly as the Node CLI pads it.
39
+
40
+ Four leading spaces when there is no short form, so the long names line up
41
+ under each other rather than under the short ones. The 28-column label field is
42
+ copied deliberately: `--help` output is compared between the two CLIs
43
+ byte-for-byte, so the padding is part of the contract.
44
+ """
45
+ label = f"{flag['short']}, {flag['name']}" if flag.get("short") else f" {flag['name']}"
46
+ value = f" <{flag['value']}>" if flag.get("value") else ""
47
+ return f" {(label + value).ljust(28)}{flag['description']}"
48
+
49
+
50
+ def _wrap(text: str, width: int) -> str:
51
+ """Greedy wrap, matching the Node implementation's boundary behaviour.
52
+
53
+ Note the condition tests the candidate *including* the space before deciding,
54
+ which is what makes the two produce identical break points.
55
+ """
56
+ words = text.split()
57
+ lines: list[str] = []
58
+ current = ""
59
+ for word in words:
60
+ if len(f"{current} {word}".strip()) > width:
61
+ lines.append(current.strip())
62
+ current = word
63
+ else:
64
+ current += f" {word}"
65
+ if current.strip():
66
+ lines.append(current.strip())
67
+ return "\n".join(lines)
68
+
69
+
70
+ def _command_help(name: str) -> str:
71
+ spec = registry.commands().get(name)
72
+ if spec is None:
73
+ raise usage_error(f'Unknown command "{name}".', "Run `memorysync help` for the list.")
74
+
75
+ lines = [style.bold(f"memorysync {spec['name']}") + style.dim(f" - {spec['summary']}"), ""]
76
+
77
+ if spec.get("description"):
78
+ lines += [_wrap(spec["description"], 78), ""]
79
+ if spec.get("usage"):
80
+ lines += [style.bold("Usage"), f" {spec['usage']}", ""]
81
+
82
+ subcommands = spec.get("subcommands") or []
83
+ if subcommands:
84
+ lines.append(style.bold("Subcommands"))
85
+ width = max(len(s["name"]) for s in subcommands)
86
+ lines += [f" {s['name'].ljust(width + 2)}{s['summary']}" for s in subcommands]
87
+ lines.append("")
88
+
89
+ if spec.get("flags"):
90
+ lines.append(style.bold("Flags"))
91
+ lines += [_flag_line(flag) for flag in spec["flags"]]
92
+ lines.append("")
93
+
94
+ if spec.get("examples"):
95
+ lines.append(style.bold("Examples"))
96
+ lines += [f" {style.dim(example)}" for example in spec["examples"]]
97
+ lines.append("")
98
+
99
+ if spec.get("consumes_quota"):
100
+ lines.append(
101
+ style.dim(f"Consumes plan quota: {spec['consumes_quota']}. See `memorysync quota`.")
102
+ )
103
+
104
+ return "\n".join(lines).rstrip()
105
+
106
+
107
+ def _overview() -> str:
108
+ tree = registry.command_tree()
109
+ entries = tree["commands"]
110
+ width = max(len(entry["name"]) for entry in entries)
111
+
112
+ lines = [
113
+ style.bold("memorysync") + style.dim(" - agent memory from your terminal"),
114
+ "",
115
+ style.bold("Usage"),
116
+ " memorysync [global flags] <command> [args] [flags]",
117
+ "",
118
+ style.bold("Commands"),
119
+ ]
120
+ lines += [f" {entry['name'].ljust(width + 2)}{entry['summary']}" for entry in entries]
121
+ lines += ["", style.bold("Global flags")]
122
+ lines += [_flag_line(flag) for flag in tree["global_flags"]]
123
+ lines += [
124
+ "",
125
+ style.dim(f"Output formats: {', '.join(tree['output_formats'])}"),
126
+ style.dim("Agent mode: memorysync --json <command> (one JSON envelope, no colour)"),
127
+ style.dim("Full tree as JSON: memorysync help --json"),
128
+ style.dim("Docs: https://docs.memorysync.io/cli"),
129
+ ]
130
+ return "\n".join(lines)
131
+
132
+
133
+ def help_command(ctx: dict) -> dict:
134
+ """`help`, `help <command>`, and `help --json`.
135
+
136
+ The JSON form ignores a command argument and always returns the whole tree: an
137
+ agent asking for machine-readable help wants the full surface, and a partial
138
+ tree would be indistinguishable from a CLI that only has one command.
139
+ """
140
+ positionals = ctx.get("positionals") or []
141
+
142
+ if positionals:
143
+ target = positionals[0]
144
+ if target not in registry.commands():
145
+ raise usage_error(f'Unknown command "{target}".')
146
+ # The command's own entry from the tree, so `help add --json` returns the
147
+ # same object that appears inside `help --json`.
148
+ detail = registry.commands()[target]
149
+ return {"data": detail, "text": lambda: _command_help(target)}
150
+
151
+ # The tree itself, not wrapped. The dispatcher prints this raw for `--json`,
152
+ # so an agent bootstrapping from it reads `.commands` rather than `.data[0]`.
153
+ return {"data": registry.command_tree(), "text": _overview}
154
+
155
+
156
+
157
+ def completion(ctx: dict) -> dict:
158
+ """Emit a completion script for one shell.
159
+
160
+ Generated from the registry for the same reason as the help text: a static
161
+ script would quietly start offering commands that no longer exist. zepctl gets
162
+ completions free from Cobra; ours are built here, from the declaration the
163
+ dispatcher reads.
164
+ """
165
+ from ..completions import SCRIPTS
166
+
167
+ positionals = ctx.get("positionals") or []
168
+ shell = positionals[0] if positionals else None
169
+
170
+ if not shell or shell not in SCRIPTS:
171
+ raise usage_error(
172
+ f'No completion script for "{shell}".' if shell else "Which shell?",
173
+ "Supported: bash, zsh, fish, powershell.",
174
+ )
175
+
176
+ script = SCRIPTS[shell]()
177
+ # `raw` keeps the dispatcher from decorating a shell script that is about to be
178
+ # redirected into a completion directory.
179
+ return {"data": {"shell": shell, "script": script}, "raw": True, "text": lambda: script}
180
+
181
+
182
+ def mcp(ctx: dict) -> dict:
183
+ """Configure MCP clients, by delegating to the installer.
184
+
185
+ Deliberately a delegation rather than a reimplementation. The MCP config
186
+ formats differ per client and change as clients ship new versions; keeping that
187
+ knowledge in one published package means the two CLIs cannot disagree about
188
+ where Claude Desktop's config lives, and a fix reaches both without either
189
+ being republished.
190
+
191
+ ``npx`` is required here, which is the one place this CLI is not pure Python.
192
+ Said plainly in the error rather than failing obscurely, because a Python user
193
+ has no reason to expect a Node tool.
194
+ """
195
+ import shutil
196
+ import subprocess
197
+
198
+ positionals = ctx.get("positionals") or []
199
+ sub = positionals[0] if positionals else "install"
200
+
201
+ extra = {"install": [], "list": ["--list"], "remove": ["--remove"]}
202
+ if sub not in extra:
203
+ raise usage_error(f'Unknown subcommand "{sub}".', "Try install, list or remove.")
204
+
205
+ if shutil.which("npx") is None:
206
+ raise CliError(
207
+ "`memorysync mcp` needs npx, which is part of Node.js.",
208
+ exit_code=Exit.USAGE,
209
+ code="npx_missing",
210
+ hint=(
211
+ "Install Node.js 18 or newer, or configure your MCP client by hand: "
212
+ "https://docs.memorysync.io/mcp/install"
213
+ ),
214
+ )
215
+
216
+ args = ["npx", "-y", "memorysync-mcp-install", *extra[sub]]
217
+ if ctx.get("flags", {}).get("dry_run") and sub == "install":
218
+ args.append("--dry-run")
219
+
220
+ capture = ctx.get("format") == "json" or ctx.get("agent_mode")
221
+ completed = subprocess.run(
222
+ args,
223
+ capture_output=bool(capture),
224
+ text=True,
225
+ )
226
+
227
+ payload = {
228
+ "subcommand": sub,
229
+ "exit_code": completed.returncode,
230
+ **({"output": (completed.stdout or "").strip()} if capture else {}),
231
+ }
232
+
233
+ return {
234
+ "data": payload,
235
+ "raw": True,
236
+ "text": lambda: (completed.stdout or "").strip() if capture else "",
237
+ "exit_code": completed.returncode,
238
+ }
@@ -0,0 +1,150 @@
1
+ """Shell completion scripts, generated from the shared registry.
2
+
3
+ Ported from ``sdk/cli/src/commands/tooling.mjs`` and asserted byte-for-byte against
4
+ it. A shell function that differed between the two CLIs would mean tab completion
5
+ behaves differently depending on which one a developer happened to install, which
6
+ is precisely the surprise this whole arrangement exists to prevent.
7
+
8
+ Kept in its own module because the templates are long enough to bury the rest of
9
+ the tooling commands, and because the parity test points straight here when a
10
+ template drifts.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from . import registry
16
+
17
+
18
+ def _commands() -> dict[str, dict]:
19
+ return registry.commands()
20
+
21
+
22
+ def _with_subcommands() -> list[tuple[str, list[str]]]:
23
+ out = []
24
+ for name, spec in _commands().items():
25
+ subs = [s["name"] for s in spec.get("subcommands", [])]
26
+ if subs:
27
+ out.append((name, subs))
28
+ return out
29
+
30
+
31
+ def _bash_subcommand_case() -> str:
32
+ entries = _with_subcommands()
33
+ if not entries:
34
+ return ""
35
+ cases = "\n".join(
36
+ f' {name}) COMPREPLY=( $(compgen -W "{" ".join(subs)}" -- "$cur") ) ;;'
37
+ for name, subs in entries
38
+ )
39
+ return ' case "${COMP_WORDS[1]}" in\n' + cases + "\n esac"
40
+
41
+
42
+ def bash() -> str:
43
+ names = " ".join(_commands())
44
+ globals_ = " ".join(flag["name"] for flag in registry.global_flags())
45
+ formats = " ".join(registry.formats())
46
+ return f"""# memorysync bash completion
47
+ # Install: memorysync completion bash > /etc/bash_completion.d/memorysync
48
+ _memorysync_complete() {{
49
+ local cur prev
50
+ cur="${{COMP_WORDS[COMP_CWORD]}}"
51
+ prev="${{COMP_WORDS[COMP_CWORD-1]}}"
52
+ local commands="{names}"
53
+ local globals="{globals_}"
54
+
55
+ if [[ "$prev" == "-o" || "$prev" == "--output" ]]; then
56
+ COMPREPLY=( $(compgen -W "{formats}" -- "$cur") )
57
+ return
58
+ fi
59
+ if [[ "$cur" == -* ]]; then
60
+ COMPREPLY=( $(compgen -W "$globals" -- "$cur") )
61
+ return
62
+ fi
63
+ if [[ $COMP_CWORD -eq 1 ]]; then
64
+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
65
+ return
66
+ fi
67
+ {_bash_subcommand_case()}
68
+ }}
69
+ complete -F _memorysync_complete memorysync msync
70
+ """
71
+
72
+
73
+ def zsh() -> str:
74
+ described = "\n".join(
75
+ f" '{spec['name']}:{spec['summary'].replace(chr(39), chr(39) + chr(92) + chr(39) + chr(39))}'"
76
+ for spec in _commands().values()
77
+ )
78
+ cases = "\n".join(
79
+ f" {name}) _values 'subcommand' " + " ".join(f"'{s}'" for s in subs) + " ;;"
80
+ for name, subs in _with_subcommands()
81
+ )
82
+ formats = " ".join(registry.formats())
83
+ return f"""#compdef memorysync msync
84
+ # Install: memorysync completion zsh > "${{fpath[1]}}/_memorysync"
85
+ _memorysync() {{
86
+ local -a commands
87
+ commands=(
88
+ {described}
89
+ )
90
+ if (( CURRENT == 2 )); then
91
+ _describe 'command' commands
92
+ return
93
+ fi
94
+ case "${{words[2]}}" in
95
+ {cases}
96
+ esac
97
+ _arguments '-o[output format]:format:({formats})' '--json[agent mode]' '--user[end user]:id:'
98
+ }}
99
+ _memorysync "$@"
100
+ """
101
+
102
+
103
+ def fish() -> str:
104
+ def escape(text: str) -> str:
105
+ return text.replace("'", "\\'")
106
+
107
+ top = "\n".join(
108
+ f"complete -c memorysync -n __fish_use_subcommand -a {spec['name']} "
109
+ f"-d '{escape(spec['summary'])}'"
110
+ for spec in _commands().values()
111
+ )
112
+ nested = "\n".join(
113
+ f"complete -c memorysync -n '__fish_seen_subcommand_from {name}' -a {sub['name']} "
114
+ f"-d '{escape(sub['summary'])}'"
115
+ for name, spec in _commands().items()
116
+ for sub in spec.get("subcommands", [])
117
+ )
118
+ formats = " ".join(registry.formats())
119
+ return f"""# memorysync fish completion
120
+ # Install: memorysync completion fish > ~/.config/fish/completions/memorysync.fish
121
+ {top}
122
+ {nested}
123
+ complete -c memorysync -s o -l output -x -a '{formats}' -d 'Output format'
124
+ complete -c memorysync -l json -d 'Agent mode'
125
+ complete -c memorysync -s u -l user -x -d 'End user id'
126
+ """
127
+
128
+
129
+ def powershell() -> str:
130
+ names = ", ".join(f"'{name}'" for name in _commands())
131
+ formats = ", ".join(f"'{fmt}'" for fmt in registry.formats())
132
+ return f"""# memorysync PowerShell completion
133
+ # Install: add the following to your profile, or dot-source a file containing it.
134
+ Register-ArgumentCompleter -Native -CommandName memorysync, msync -ScriptBlock {{
135
+ param($wordToComplete, $commandAst, $cursorPosition)
136
+ $commands = @({names})
137
+ $formats = @({formats})
138
+ $tokens = $commandAst.CommandElements | ForEach-Object {{ $_.ToString() }}
139
+
140
+ if ($tokens.Count -ge 2 -and ($tokens[-1] -eq '-o' -or $tokens[-1] -eq '--output')) {{
141
+ return $formats | Where-Object {{ $_ -like "$wordToComplete*" }} |
142
+ ForEach-Object {{ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }}
143
+ }}
144
+ $commands | Where-Object {{ $_ -like "$wordToComplete*" }} |
145
+ ForEach-Object {{ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }}
146
+ }}
147
+ """
148
+
149
+
150
+ SCRIPTS = {"bash": bash, "zsh": zsh, "fish": fish, "powershell": powershell}
@@ -0,0 +1,147 @@
1
+ """Profiles and settings, resolved in one documented order.
2
+
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.
7
+
8
+ The config file never holds a credential. It holds a profile name, a default end
9
+ user, a project and a base URL - the things that are useful to read, commit to a
10
+ dotfiles repo, or paste into a bug report. Keys live in ``credentials.py``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ CONFIG_VERSION = 1
21
+ DEFAULT_BASE_URL = "https://api.memorysync.io"
22
+ DEFAULT_TIMEOUT_MS = 60000
23
+
24
+
25
+ def config_dir() -> Path:
26
+ """Where settings live.
27
+
28
+ ``MEMORYSYNC_CONFIG_DIR`` is honoured first so tests and CI can point at a
29
+ scratch directory instead of a developer's real profile.
30
+ """
31
+ override = os.environ.get("MEMORYSYNC_CONFIG_DIR")
32
+ if override:
33
+ 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"
43
+
44
+
45
+ def config_path() -> Path:
46
+ return config_dir() / "config.json"
47
+
48
+
49
+ def _empty() -> dict[str, Any]:
50
+ return {"version": CONFIG_VERSION, "profiles": {}}
51
+
52
+
53
+ def load() -> dict[str, Any]:
54
+ """Read the config file, tolerating absence and corruption.
55
+
56
+ A damaged file returns defaults rather than raising. Refusing to run because a
57
+ JSON file has a stray comma would block every command, including the ``config``
58
+ and ``init`` commands that could repair it.
59
+ """
60
+ path = config_path()
61
+ try:
62
+ with path.open(encoding="utf-8") as handle:
63
+ parsed = json.load(handle)
64
+ except (FileNotFoundError, ValueError, OSError):
65
+ return _empty()
66
+
67
+ if not isinstance(parsed, dict):
68
+ return _empty()
69
+
70
+ return {
71
+ "version": parsed.get("version", CONFIG_VERSION),
72
+ "profiles": parsed.get("profiles") if isinstance(parsed.get("profiles"), dict) else {},
73
+ }
74
+
75
+
76
+ def save(config: dict[str, Any]) -> Path:
77
+ path = config_path()
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ with path.open("w", encoding="utf-8") as handle:
80
+ json.dump(config, handle, indent=2)
81
+ handle.write("\n")
82
+ try:
83
+ # Not a secret, but not world-readable either: it names end users.
84
+ path.chmod(0o600)
85
+ except OSError:
86
+ pass
87
+ return path
88
+
89
+
90
+ def profile(name: str = "default") -> dict[str, Any]:
91
+ return load()["profiles"].get(name, {})
92
+
93
+
94
+ def resolve(flags: dict[str, Any]) -> dict[str, Any]:
95
+ """Settings for this invocation.
96
+
97
+ Values are read once here so every command sees the same view, rather than
98
+ each one reaching for the environment at a different moment.
99
+ """
100
+ profile_name = (
101
+ flags.get("profile") or os.environ.get("MEMORYSYNC_PROFILE") or "default"
102
+ )
103
+ stored = profile(profile_name)
104
+
105
+ def pick(flag_key: str, env_key: str, stored_key: str, fallback: Any = None) -> Any:
106
+ value = flags.get(flag_key)
107
+ if value not in (None, False):
108
+ return value
109
+ env_value = os.environ.get(env_key)
110
+ if env_value:
111
+ return env_value
112
+ if stored.get(stored_key) is not None:
113
+ return stored[stored_key]
114
+ return fallback
115
+
116
+ timeout = pick("timeout", "MEMORYSYNC_TIMEOUT", "timeout", DEFAULT_TIMEOUT_MS)
117
+ try:
118
+ timeout = int(timeout)
119
+ except (TypeError, ValueError):
120
+ timeout = DEFAULT_TIMEOUT_MS
121
+
122
+ return {
123
+ "profile_name": profile_name,
124
+ "base_url": str(
125
+ pick("base_url", "MEMORYSYNC_BASE_URL", "base_url", DEFAULT_BASE_URL)
126
+ ).rstrip("/"),
127
+ "user": pick("user", "MEMORYSYNC_USER", "user"),
128
+ "project": pick("project", "MEMORYSYNC_PROJECT", "project"),
129
+ "timeout": timeout,
130
+ }
131
+
132
+
133
+ def redacted(config: dict[str, Any] | None = None) -> dict[str, Any]:
134
+ """The config as it is safe to print.
135
+
136
+ Nothing secret is stored here, but this goes through one function anyway so a
137
+ future field cannot be added and printed by accident.
138
+ """
139
+ data = config or load()
140
+ return {
141
+ "version": data.get("version", CONFIG_VERSION),
142
+ "path": str(config_path()),
143
+ "profiles": {
144
+ name: {key: value for key, value in entry.items() if key != "api_key"}
145
+ for name, entry in data.get("profiles", {}).items()
146
+ },
147
+ }