memorysync-cli 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memorysync_cli/__init__.py +13 -0
- memorysync_cli/__main__.py +9 -0
- memorysync_cli/_version.py +1 -0
- memorysync_cli/args.py +220 -0
- memorysync_cli/commands/__init__.py +6 -0
- memorysync_cli/commands/admin.py +354 -0
- memorysync_cli/commands/init.py +109 -0
- memorysync_cli/commands/memory.py +629 -0
- memorysync_cli/commands/source.py +132 -0
- memorysync_cli/commands/tooling.py +238 -0
- memorysync_cli/completions.py +150 -0
- memorysync_cli/config.py +147 -0
- memorysync_cli/credentials.py +259 -0
- memorysync_cli/errors.py +110 -0
- memorysync_cli/http.py +257 -0
- memorysync_cli/main.py +325 -0
- memorysync_cli/output.py +311 -0
- memorysync_cli/registry.json +612 -0
- memorysync_cli/registry.py +101 -0
- memorysync_cli-1.0.2.dist-info/METADATA +158 -0
- memorysync_cli-1.0.2.dist-info/RECORD +24 -0
- memorysync_cli-1.0.2.dist-info/WHEEL +4 -0
- memorysync_cli-1.0.2.dist-info/entry_points.txt +3 -0
- memorysync_cli-1.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -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}
|
memorysync_cli/config.py
ADDED
|
@@ -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
|
+
}
|