aether-cache 0.1.0__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.
- aether_cache/__init__.py +3 -0
- aether_cache/__main__.py +5 -0
- aether_cache/cli.py +240 -0
- aether_cache/config.py +44 -0
- aether_cache/connect.py +195 -0
- aether_cache/engine/aether-engine.jar +0 -0
- aether_cache/engine.py +253 -0
- aether_cache/server.py +464 -0
- aether_cache-0.1.0.dist-info/METADATA +353 -0
- aether_cache-0.1.0.dist-info/RECORD +13 -0
- aether_cache-0.1.0.dist-info/WHEEL +5 -0
- aether_cache-0.1.0.dist-info/entry_points.txt +2 -0
- aether_cache-0.1.0.dist-info/top_level.txt +1 -0
aether_cache/__init__.py
ADDED
aether_cache/__main__.py
ADDED
aether_cache/cli.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""The `aether-cache` command."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
from collections import Counter
|
|
5
|
+
|
|
6
|
+
from . import __version__, config, connect, engine
|
|
7
|
+
|
|
8
|
+
OK, WARN, FAIL = " ok ", " !! ", " FAIL "
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _say(mark: str, text: str) -> None:
|
|
12
|
+
lines = text.splitlines() or [""]
|
|
13
|
+
print(f"[{mark}] {lines[0]}")
|
|
14
|
+
for line in lines[1:]:
|
|
15
|
+
print(f" {line}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------- commands ----------
|
|
19
|
+
|
|
20
|
+
def cmd_setup(args) -> int:
|
|
21
|
+
print(f"Setting up Aether Cache {__version__}\n")
|
|
22
|
+
try:
|
|
23
|
+
java, major = engine.find_java()
|
|
24
|
+
_say(OK, f"Java {major} ({java})")
|
|
25
|
+
_say(OK, engine.start())
|
|
26
|
+
except engine.EngineError as e:
|
|
27
|
+
_say(FAIL, str(e))
|
|
28
|
+
print("\nFix that, then run `aether-cache setup` again.")
|
|
29
|
+
return 1
|
|
30
|
+
|
|
31
|
+
if config.FAKE_EMBEDDER:
|
|
32
|
+
_say(OK, "Embedding model: test mode, nothing to download")
|
|
33
|
+
else:
|
|
34
|
+
print("[ .... ] Downloading the embedding model (about 90 MB, only the first time)...", flush=True)
|
|
35
|
+
try:
|
|
36
|
+
from .server import embed
|
|
37
|
+
embed("warm up")
|
|
38
|
+
_say(OK, f"Embedding model ready ({config.MODEL})")
|
|
39
|
+
except Exception as e:
|
|
40
|
+
_say(WARN, f"Couldn't download the embedding model yet: {e}\n"
|
|
41
|
+
f"It will be downloaded the first time your AI saves or searches a memory.")
|
|
42
|
+
|
|
43
|
+
tools = args.tools or connect.detected()
|
|
44
|
+
if not tools:
|
|
45
|
+
_say(WARN, "No supported AI coding tools found (Claude Code, Cursor, Codex).")
|
|
46
|
+
print("\n" + connect.generic_instructions())
|
|
47
|
+
failed = []
|
|
48
|
+
for tool in tools:
|
|
49
|
+
done, message = connect.CONNECTORS[tool]()
|
|
50
|
+
_say(OK if done else FAIL, message)
|
|
51
|
+
if not done:
|
|
52
|
+
failed.append(tool)
|
|
53
|
+
if failed:
|
|
54
|
+
print(f"\nAlmost done: fix the {', '.join(failed)} problem above, then run "
|
|
55
|
+
f"`aether-cache connect {' '.join(failed)}`.")
|
|
56
|
+
return 1
|
|
57
|
+
|
|
58
|
+
print("\nDone. Open your AI tool in any git repo and try:\n"
|
|
59
|
+
' "Remember that our tests need DOCKER_HOST set. It depends on docker-compose.yml."\n'
|
|
60
|
+
"Then, in a new session:\n"
|
|
61
|
+
' "How do I run the tests?"\n'
|
|
62
|
+
"\nCheck on things any time with `aether-cache status`.")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_start(args) -> int:
|
|
67
|
+
try:
|
|
68
|
+
print(engine.start())
|
|
69
|
+
return 0
|
|
70
|
+
except engine.EngineError as e:
|
|
71
|
+
print(e)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_stop(args) -> int:
|
|
76
|
+
print(engine.stop())
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_status(args) -> int:
|
|
81
|
+
state = engine.probe()
|
|
82
|
+
if state == "aether":
|
|
83
|
+
pid = engine.running_pid()
|
|
84
|
+
print(f"Engine: running on {config.HOST}:{config.PORT}" + (f" (pid {pid})" if pid else ""))
|
|
85
|
+
elif state == "other":
|
|
86
|
+
print(f"Engine: not running. {engine.port_taken_message()}")
|
|
87
|
+
return 1
|
|
88
|
+
else:
|
|
89
|
+
print("Engine: not running (it starts automatically when your AI tool needs it, or run `aether-cache start`)")
|
|
90
|
+
print(f"Data: {config.DATA_DIR}")
|
|
91
|
+
if state == "aether":
|
|
92
|
+
from .server import project_name, run
|
|
93
|
+
project = project_name()
|
|
94
|
+
counts = Counter(status for _, status, _, _ in run("MEM.LIST", project, "STATUS", "any"))
|
|
95
|
+
summary = ", ".join(f"{counts[s]} {s}" for s in ("active", "stale", "superseded") if counts[s])
|
|
96
|
+
print(f"This project ({project}): {summary or 'no memories yet'}")
|
|
97
|
+
connected = [t for t in connect.TOOLS if connect.CHECKS[t]()]
|
|
98
|
+
print("Connected tools: " + (", ".join(connected) if connected else "none (run `aether-cache connect`)"))
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_connect(args) -> int:
|
|
103
|
+
if args.print:
|
|
104
|
+
print(connect.generic_instructions())
|
|
105
|
+
return 0
|
|
106
|
+
tools = args.tools or connect.detected()
|
|
107
|
+
if not tools:
|
|
108
|
+
print("No supported AI coding tools found (Claude Code, Cursor, Codex).\n")
|
|
109
|
+
print(connect.generic_instructions())
|
|
110
|
+
return 1
|
|
111
|
+
failed = False
|
|
112
|
+
for tool in tools:
|
|
113
|
+
done, message = connect.CONNECTORS[tool]()
|
|
114
|
+
_say(OK if done else FAIL, message)
|
|
115
|
+
failed |= not done
|
|
116
|
+
return 1 if failed else 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_doctor(args) -> int:
|
|
120
|
+
problems = 0
|
|
121
|
+
_say(OK, f"Aether Cache {__version__}, Python {sys.version.split()[0]} ({sys.executable})")
|
|
122
|
+
try:
|
|
123
|
+
java, major = engine.find_java()
|
|
124
|
+
_say(OK, f"Java {major} ({java})")
|
|
125
|
+
except engine.EngineError as e:
|
|
126
|
+
_say(FAIL, str(e))
|
|
127
|
+
problems += 1
|
|
128
|
+
if engine.JAR.is_file():
|
|
129
|
+
_say(OK, f"Engine file present ({engine.JAR.name})")
|
|
130
|
+
else:
|
|
131
|
+
_say(FAIL, f"Engine file missing ({engine.JAR}). Reinstall Aether Cache.")
|
|
132
|
+
problems += 1
|
|
133
|
+
state = engine.probe()
|
|
134
|
+
if state == "aether":
|
|
135
|
+
_say(OK, f"Engine answering on {config.HOST}:{config.PORT}")
|
|
136
|
+
elif state == "other":
|
|
137
|
+
_say(FAIL, engine.port_taken_message())
|
|
138
|
+
problems += 1
|
|
139
|
+
else:
|
|
140
|
+
_say(WARN, f"Engine not running. It starts automatically when needed; `aether-cache start` starts it now.\n"
|
|
141
|
+
f"If it won't start, see {config.LOG_FILE}")
|
|
142
|
+
if config.FAKE_EMBEDDER:
|
|
143
|
+
_say(WARN, "Test embedder is on (AETHER_EMBEDDER=fake): search quality is poor. Unset it for real use.")
|
|
144
|
+
elif any(config.MODELS_DIR.glob("*")):
|
|
145
|
+
_say(OK, f"Embedding model downloaded ({config.MODELS_DIR})")
|
|
146
|
+
else:
|
|
147
|
+
_say(WARN, "Embedding model not downloaded yet; it downloads on first use (about 90 MB).")
|
|
148
|
+
for tool in connect.TOOLS:
|
|
149
|
+
if connect.CHECKS[tool]():
|
|
150
|
+
_say(OK, f"Connected to {tool}")
|
|
151
|
+
elif tool in connect.detected():
|
|
152
|
+
_say(WARN, f"{tool} is installed but not connected. Run `aether-cache connect {tool}`.")
|
|
153
|
+
from .server import git_toplevel, project_name
|
|
154
|
+
top = git_toplevel()
|
|
155
|
+
_say(OK if top else WARN, f"This folder's project: {project_name()}"
|
|
156
|
+
+ ("" if top else " (not a git repo, so memories can't be linked to file versions here)"))
|
|
157
|
+
print("\nEverything looks good." if problems == 0 else f"\n{problems} problem(s) to fix above.")
|
|
158
|
+
return 1 if problems else 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_list(args) -> int:
|
|
162
|
+
from .server import EngineDown, project_name, run, store_down
|
|
163
|
+
try:
|
|
164
|
+
rows = run("MEM.LIST", project_name(), "STATUS", args.status)
|
|
165
|
+
except EngineDown as e:
|
|
166
|
+
print(store_down(e))
|
|
167
|
+
return 1
|
|
168
|
+
if not rows:
|
|
169
|
+
print(f"No {'' if args.status == 'any' else args.status + ' '}memories in {project_name()} yet.")
|
|
170
|
+
for mid, status, files, text in rows:
|
|
171
|
+
linked = ", ".join(f.rsplit("@", 1)[0] for f in files.split(",") if f)
|
|
172
|
+
print(f"{mid:>5} {status:<10} {text}" + (f" [{linked}]" if linked else ""))
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _tool_output(name: str, *tool_args) -> int:
|
|
177
|
+
from . import server
|
|
178
|
+
print(getattr(server, name)(*tool_args))
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cmd_serve(args) -> int:
|
|
183
|
+
from .server import serve
|
|
184
|
+
serve()
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def main(argv=None) -> None:
|
|
189
|
+
try:
|
|
190
|
+
sys.stdout.reconfigure(errors="replace") # never crash printing on a limited console
|
|
191
|
+
except (AttributeError, ValueError):
|
|
192
|
+
pass
|
|
193
|
+
parser = argparse.ArgumentParser(
|
|
194
|
+
prog="aether-cache",
|
|
195
|
+
description="Shared memory for AI coding tools that stays true to your code.")
|
|
196
|
+
parser.add_argument("--version", action="version", version=f"aether-cache {__version__}")
|
|
197
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
198
|
+
|
|
199
|
+
p = sub.add_parser("setup", help="one-time setup: start the engine, download the model, connect your AI tools")
|
|
200
|
+
p.add_argument("tools", nargs="*", metavar="tool",
|
|
201
|
+
help="which tools to connect (default: every supported tool found)")
|
|
202
|
+
p.set_defaults(func=cmd_setup)
|
|
203
|
+
p = sub.add_parser("connect", help="connect AI tools: claude-code, cursor, codex")
|
|
204
|
+
p.add_argument("tools", nargs="*", metavar="tool",
|
|
205
|
+
help="which tools to connect (default: every supported tool found)")
|
|
206
|
+
p.add_argument("--print", action="store_true", help="print config for any other MCP tool instead")
|
|
207
|
+
p.set_defaults(func=cmd_connect)
|
|
208
|
+
sub.add_parser("start", help="start the engine in the background").set_defaults(func=cmd_start)
|
|
209
|
+
sub.add_parser("stop", help="stop the engine").set_defaults(func=cmd_stop)
|
|
210
|
+
sub.add_parser("status", help="is it running, and what's in this project's memory").set_defaults(func=cmd_status)
|
|
211
|
+
sub.add_parser("doctor", help="check everything and say how to fix problems").set_defaults(func=cmd_doctor)
|
|
212
|
+
p = sub.add_parser("list", help="list this project's memories")
|
|
213
|
+
p.add_argument("--status", default="active", choices=["active", "stale", "superseded", "any"])
|
|
214
|
+
p.set_defaults(func=cmd_list)
|
|
215
|
+
sub.add_parser("recall", help="search this project's memories").add_argument("query")
|
|
216
|
+
sub.choices["recall"].set_defaults(func=lambda a: _tool_output("recall", a.query))
|
|
217
|
+
sub.add_parser("explain", help="where a memory came from and its history").add_argument("id")
|
|
218
|
+
sub.choices["explain"].set_defaults(func=lambda a: _tool_output("explain_memory", a.id))
|
|
219
|
+
sub.add_parser("sync", help="write current memories into AGENTS.md / CLAUDE.md").set_defaults(
|
|
220
|
+
func=lambda a: _tool_output("sync_to_files"))
|
|
221
|
+
sub.add_parser("check", help="retire memories whose linked files changed").set_defaults(
|
|
222
|
+
func=lambda a: _tool_output("check_stale"))
|
|
223
|
+
sub.add_parser("serve", help="run the MCP server (your AI tool runs this for you)").set_defaults(func=cmd_serve)
|
|
224
|
+
|
|
225
|
+
args = parser.parse_args(argv)
|
|
226
|
+
unknown = [t for t in getattr(args, "tools", None) or [] if t not in connect.TOOLS]
|
|
227
|
+
if unknown:
|
|
228
|
+
parser.error(f"unknown tool {unknown[0]!r}; choose from {', '.join(connect.TOOLS)} "
|
|
229
|
+
f"(or `aether-cache connect --print` for any other MCP tool)")
|
|
230
|
+
if not getattr(args, "func", None):
|
|
231
|
+
parser.print_help()
|
|
232
|
+
print("\nNew here? Run `aether-cache setup`.")
|
|
233
|
+
sys.exit(0)
|
|
234
|
+
try:
|
|
235
|
+
sys.exit(args.func(args))
|
|
236
|
+
except KeyboardInterrupt:
|
|
237
|
+
sys.exit(130)
|
|
238
|
+
except BrokenPipeError: # output piped into something like `head` that stopped reading
|
|
239
|
+
sys.stderr.close()
|
|
240
|
+
sys.exit(0)
|
aether_cache/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Where Aether Cache keeps things, and how to reach the engine. All settings are optional."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _env(*names: str, default: str = "") -> str:
|
|
7
|
+
"""First set environment variable among names (newer AETHER_* names win over older MEMORY_* ones)."""
|
|
8
|
+
for name in names:
|
|
9
|
+
value = os.environ.get(name)
|
|
10
|
+
if value:
|
|
11
|
+
return value
|
|
12
|
+
return default
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Everything lives under one folder: the engine's data, its log, and the downloaded embedding model.
|
|
16
|
+
HOME = Path(_env("AETHER_HOME", default=str(Path.home() / ".aether-cache"))).expanduser()
|
|
17
|
+
DATA_DIR = HOME / "data"
|
|
18
|
+
LOG_FILE = HOME / "engine.log"
|
|
19
|
+
PID_FILE = HOME / "engine.pid"
|
|
20
|
+
MODELS_DIR = HOME / "models"
|
|
21
|
+
|
|
22
|
+
# The engine listens on this machine only. The port is deliberately not Redis's 6379, so Aether Cache
|
|
23
|
+
# never collides with a real Redis you may already run.
|
|
24
|
+
HOST = _env("AETHER_HOST", "MEMORY_HOST", default="127.0.0.1")
|
|
25
|
+
PORT = int(_env("AETHER_PORT", "MEMORY_PORT", default="7380"))
|
|
26
|
+
|
|
27
|
+
# Which AI tool this server is running for (recorded with every memory it saves).
|
|
28
|
+
SOURCE = _env("AETHER_SOURCE", "MEMORY_SOURCE", default="unknown")
|
|
29
|
+
MODEL = _env("AETHER_MODEL", "MEMORY_MODEL", default="sentence-transformers/all-MiniLM-L6-v2")
|
|
30
|
+
FAKE_EMBEDDER = _env("AETHER_EMBEDDER", "MEMORY_EMBEDDER") == "fake" # testing only, needs no download
|
|
31
|
+
# Existing memories at least this similar to a newly saved one are shown to the AI to judge.
|
|
32
|
+
# Low on purpose: in calibration, "we use Postgres" vs "we migrated to MySQL" scored only 0.30.
|
|
33
|
+
RELATED_SIMILARITY = float(_env("AETHER_RELATED_SIMILARITY", "MEMORY_RELATED_SIMILARITY", default="0.25"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def project_override() -> str:
|
|
37
|
+
return _env("AETHER_PROJECT", "MEMORY_PROJECT")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def repo_override() -> str:
|
|
41
|
+
"""The project folder, when the AI tool says where it is (Cursor passes ${workspaceFolder}).
|
|
42
|
+
Ignored if the tool didn't fill in the placeholder."""
|
|
43
|
+
value = _env("AETHER_REPO", "MEMORY_REPO")
|
|
44
|
+
return "" if "${" in value else value
|
aether_cache/connect.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Registering Aether Cache with AI coding tools, so they start its MCP server on their own."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Tuple
|
|
9
|
+
|
|
10
|
+
NAME = "aether-cache"
|
|
11
|
+
TOOLS = ["claude-code", "cursor", "codex"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def settings_env() -> dict:
|
|
15
|
+
"""Non-default settings from this shell (a custom port or data folder) that the tools must use too."""
|
|
16
|
+
return {k: os.environ[k] for k in ("AETHER_PORT", "AETHER_HOME") if os.environ.get(k)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def server_command() -> List[str]:
|
|
20
|
+
"""How a tool launches the server: this exact Python, so it works even when the tool's PATH
|
|
21
|
+
doesn't include the folder the `aether-cache` command was installed into."""
|
|
22
|
+
return [sys.executable, "-m", "aether_cache", "serve"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cursor_config() -> Path:
|
|
26
|
+
return Path.home() / ".cursor" / "mcp.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def codex_config() -> Path:
|
|
30
|
+
return Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")) / "config.toml"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def detected() -> List[str]:
|
|
34
|
+
"""The supported tools that look installed on this machine."""
|
|
35
|
+
found = []
|
|
36
|
+
if shutil.which("claude"):
|
|
37
|
+
found.append("claude-code")
|
|
38
|
+
if (Path.home() / ".cursor").is_dir():
|
|
39
|
+
found.append("cursor")
|
|
40
|
+
if shutil.which("codex") or codex_config().parent.is_dir():
|
|
41
|
+
found.append("codex")
|
|
42
|
+
return found
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------- instruction files ----------
|
|
46
|
+
|
|
47
|
+
INSTR_START = "<!-- aether-cache:instructions:start -->"
|
|
48
|
+
INSTR_END = "<!-- aether-cache:instructions:end -->"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def add_instructions(path: Path) -> str:
|
|
52
|
+
"""Adds (or refreshes) a short, clearly marked section telling the AI to use Aether Cache for project
|
|
53
|
+
memory. Without it, tools tend to reach for their built-in memory instead. Nothing else is touched."""
|
|
54
|
+
from .server import INSTRUCTIONS
|
|
55
|
+
block = f"{INSTR_START}\n## Project memory\n{INSTRUCTIONS}\n{INSTR_END}"
|
|
56
|
+
old = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
57
|
+
if INSTR_START in old and INSTR_END in old:
|
|
58
|
+
new = old[:old.index(INSTR_START)] + block + old[old.index(INSTR_END) + len(INSTR_END):]
|
|
59
|
+
else:
|
|
60
|
+
new = (old.rstrip() + "\n\n" if old.strip() else "") + block + "\n"
|
|
61
|
+
if new != old:
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
tmp = path.with_name(path.name + ".aether-tmp")
|
|
64
|
+
tmp.write_text(new, encoding="utf-8")
|
|
65
|
+
os.replace(tmp, path)
|
|
66
|
+
return f"told it to use Aether Cache for project memory in {path}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def claude_instructions_file() -> Path:
|
|
70
|
+
return Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")) / "CLAUDE.md"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------- Claude Code ----------
|
|
74
|
+
|
|
75
|
+
def _claude(*args: str) -> subprocess.CompletedProcess:
|
|
76
|
+
return subprocess.run([shutil.which("claude") or "claude", *args], capture_output=True, text=True, timeout=60)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def connect_claude_code() -> Tuple[bool, str]:
|
|
80
|
+
if not shutil.which("claude"):
|
|
81
|
+
return False, "Claude Code isn't installed (no `claude` command found)."
|
|
82
|
+
notes = []
|
|
83
|
+
# replace any earlier registration, including the one from when the project was called memstore
|
|
84
|
+
for old in (NAME, "memstore"):
|
|
85
|
+
if _claude("mcp", "get", old).returncode == 0:
|
|
86
|
+
for scope in ("user", "local"):
|
|
87
|
+
if _claude("mcp", "remove", old, "--scope", scope).returncode == 0 and old == "memstore":
|
|
88
|
+
notes.append("removed the old `memstore` entry")
|
|
89
|
+
cmd = server_command()
|
|
90
|
+
env_flags = [x for k, v in settings_env().items() for x in ("-e", f"{k}={v}")]
|
|
91
|
+
out = _claude("mcp", "add", NAME, "--scope", "user", "-e", "MEMORY_SOURCE=claude-code", *env_flags, "--", *cmd)
|
|
92
|
+
if out.returncode != 0:
|
|
93
|
+
return False, f"`claude mcp add` failed: {(out.stderr or out.stdout).strip()}"
|
|
94
|
+
notes.append(add_instructions(claude_instructions_file()))
|
|
95
|
+
return True, "Claude Code: connected for all projects (" + "; ".join(notes) + ")."
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def claude_code_connected() -> bool:
|
|
99
|
+
return bool(shutil.which("claude")) and _claude("mcp", "get", NAME).returncode == 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------- Cursor ----------
|
|
103
|
+
|
|
104
|
+
def _server_entry(source: str, workspace_var: str = "") -> dict:
|
|
105
|
+
cmd = server_command()
|
|
106
|
+
env = {"MEMORY_SOURCE": source, **settings_env()}
|
|
107
|
+
if workspace_var:
|
|
108
|
+
env["AETHER_REPO"] = workspace_var # tells the server which project folder is open
|
|
109
|
+
return {"command": cmd[0], "args": cmd[1:], "env": env}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def connect_cursor() -> Tuple[bool, str]:
|
|
113
|
+
path = cursor_config()
|
|
114
|
+
data = {}
|
|
115
|
+
if path.exists():
|
|
116
|
+
text = path.read_text(encoding="utf-8")
|
|
117
|
+
try:
|
|
118
|
+
data = json.loads(text) if text.strip() else {}
|
|
119
|
+
except json.JSONDecodeError as e:
|
|
120
|
+
return False, (f"Couldn't read {path} ({e}). Fix it, or add this under \"mcpServers\" yourself:\n"
|
|
121
|
+
+ json.dumps({NAME: _server_entry("cursor", "${workspaceFolder}")}, indent=2))
|
|
122
|
+
shutil.copyfile(path, path.with_name("mcp.json.bak"))
|
|
123
|
+
data.setdefault("mcpServers", {})[NAME] = _server_entry("cursor", "${workspaceFolder}")
|
|
124
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
tmp = path.with_name("mcp.json.aether-tmp")
|
|
126
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
127
|
+
os.replace(tmp, path)
|
|
128
|
+
return True, f"Cursor: connected for all projects ({path}). Restart Cursor to pick it up."
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cursor_connected() -> bool:
|
|
132
|
+
try:
|
|
133
|
+
return NAME in json.loads(cursor_config().read_text(encoding="utf-8")).get("mcpServers", {})
|
|
134
|
+
except (OSError, ValueError, AttributeError):
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------- Codex ----------
|
|
139
|
+
|
|
140
|
+
def _codex_block() -> str:
|
|
141
|
+
cmd = server_command()
|
|
142
|
+
q = json.dumps # TOML basic strings use the same escaping as JSON strings
|
|
143
|
+
return (f"[mcp_servers.{NAME}]\n"
|
|
144
|
+
f"command = {q(cmd[0])}\n"
|
|
145
|
+
f"args = [{', '.join(q(a) for a in cmd[1:])}]\n"
|
|
146
|
+
"env = { " + ", ".join(f"{k} = {q(v)}" for k, v in {"MEMORY_SOURCE": "codex", **settings_env()}.items())
|
|
147
|
+
+ " }\n")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_our_header(line: str) -> bool:
|
|
151
|
+
s = line.strip().replace('"', "").replace("'", "")
|
|
152
|
+
return s == f"[mcp_servers.{NAME}]" or s.startswith(f"[mcp_servers.{NAME}.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def connect_codex() -> Tuple[bool, str]:
|
|
156
|
+
path = codex_config()
|
|
157
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
158
|
+
# drop our old section (if any): from its header to the next table header
|
|
159
|
+
kept, skipping = [], False
|
|
160
|
+
for line in text.splitlines():
|
|
161
|
+
if line.strip().startswith("["):
|
|
162
|
+
skipping = _is_our_header(line)
|
|
163
|
+
if not skipping:
|
|
164
|
+
kept.append(line)
|
|
165
|
+
body = "\n".join(kept).rstrip()
|
|
166
|
+
new = (body + "\n\n" if body else "") + _codex_block()
|
|
167
|
+
if path.exists():
|
|
168
|
+
shutil.copyfile(path, path.with_name("config.toml.bak"))
|
|
169
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
tmp = path.with_name("config.toml.aether-tmp")
|
|
171
|
+
tmp.write_text(new, encoding="utf-8")
|
|
172
|
+
os.replace(tmp, path)
|
|
173
|
+
note = add_instructions(path.with_name("AGENTS.md")) # Codex's instructions for every project
|
|
174
|
+
return True, f"Codex: connected ({path}; {note})."
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def codex_connected() -> bool:
|
|
178
|
+
try:
|
|
179
|
+
return any(_is_our_header(l) for l in codex_config().read_text(encoding="utf-8").splitlines())
|
|
180
|
+
except OSError:
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------- any other MCP tool ----------
|
|
185
|
+
|
|
186
|
+
def generic_instructions() -> str:
|
|
187
|
+
entry = _server_entry("your-tool-name")
|
|
188
|
+
return ("For any other MCP-capable tool, add this server to its MCP config (most use this JSON shape):\n\n"
|
|
189
|
+
+ json.dumps({"mcpServers": {NAME: entry}}, indent=2)
|
|
190
|
+
+ "\n\nOr, as a single command: " + " ".join(f'"{c}"' if " " in c else c for c in server_command())
|
|
191
|
+
+ "\nRun the tool from your project folder, or set AETHER_REPO to the project's path.")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
CONNECTORS = {"claude-code": connect_claude_code, "cursor": connect_cursor, "codex": connect_codex}
|
|
195
|
+
CHECKS = {"claude-code": claude_code_connected, "cursor": cursor_connected, "codex": codex_connected}
|
|
Binary file
|