aether-cache 0.1.1__tar.gz → 0.1.3__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.
- {aether_cache-0.1.1 → aether_cache-0.1.3}/.gitignore +1 -0
- aether_cache-0.1.1/README.md → aether_cache-0.1.3/PKG-INFO +24 -1
- aether_cache-0.1.1/PKG-INFO → aether_cache-0.1.3/README.md +11 -13
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/__init__.py +1 -1
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/cli.py +169 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/connect.py +87 -8
- aether_cache-0.1.3/aether_cache/portability.py +354 -0
- aether_cache-0.1.3/aether_cache/repos.py +134 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/server.py +13 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/index.html +447 -13
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui.py +109 -1
- aether_cache-0.1.3/docs/README.md +41 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/pyproject.toml +3 -2
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/__main__.py +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/config.py +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/engine/aether-engine.jar +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/engine.py +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/OFL.txt +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
|
@@ -1,9 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aether-cache
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Shared memory for AI coding tools that stays true to your code.
|
|
5
|
+
Project-URL: Homepage, https://anishchugh05.github.io/Aether-Cache-Redis-Compatible-Vector-Store/
|
|
6
|
+
Project-URL: Repository, https://github.com/anishchugh05/Aether-Cache-Redis-Compatible-Vector-Store
|
|
7
|
+
Author: Anish Chugh
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: fastembed>=0.3
|
|
10
|
+
Requires-Dist: mcp>=1.2
|
|
11
|
+
Requires-Dist: redis>=5.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
1
14
|
[](https://app.codecrafters.io/users/anishchugh05?r=2qF)
|
|
2
15
|
|
|
3
16
|
# Aether Cache
|
|
4
17
|
|
|
5
18
|
**Shared memory for AI coding tools that stays true to your code.**
|
|
6
19
|
|
|
20
|
+
**[Website](https://anishchugh05.github.io/Aether-Cache-Redis-Compatible-Vector-Store/)** · **[PyPI](https://pypi.org/project/aether-cache/)**
|
|
21
|
+
|
|
7
22
|
Claude Code, Cursor, and other AI tools each forget your project between sessions, and when they do remember things,
|
|
8
23
|
nothing checks whether those memories are still correct. Aether Cache gives every tool one shared, local memory, and keeps
|
|
9
24
|
it honest:
|
|
@@ -106,22 +121,29 @@ repo and try:
|
|
|
106
121
|
|
|
107
122
|
Then start a new session and ask how to run the tests.
|
|
108
123
|
|
|
124
|
+
Already keep notes for your AI in `CLAUDE.md`, `AGENTS.md`, or Cursor rules? Run `aether-cache import` in the repo
|
|
125
|
+
(or use **Import notes** in the dashboard) to turn them into memories, linked to the files they mention.
|
|
126
|
+
|
|
109
127
|
Memories are kept per project (the git repo's folder name), so projects never mix. Everything stays on your machine:
|
|
110
128
|
the engine only accepts connections from it, and your memories live in `~/.aether-cache`.
|
|
111
129
|
|
|
112
130
|
| Command | What it does |
|
|
113
131
|
|---|---|
|
|
114
132
|
| `aether-cache setup` | One-time setup: engine, model, and AI tools |
|
|
133
|
+
| `aether-cache add` | Adds the repo you're in (or a path) so the dashboard can check its files; the dashboard can also find and add repos for you |
|
|
115
134
|
| `aether-cache ui` | Opens the dashboard: see what your AI remembers, and confirm or retire memories whose files changed |
|
|
135
|
+
| `aether-cache import` | Turns the notes in `CLAUDE.md`, `AGENTS.md`, and Cursor rules into memories; shows them first and asks |
|
|
136
|
+
| `aether-cache export` | Saves memories, with their linked files and history, to a JSON file (`-p` for one project) |
|
|
116
137
|
| `aether-cache status` | Is it running, and what's in this project's memory |
|
|
117
138
|
| `aether-cache doctor` | Checks everything and tells you how to fix what's wrong |
|
|
118
139
|
| `aether-cache list` / `recall "query"` / `explain m1` | Look at memories from the terminal |
|
|
119
140
|
| `aether-cache sync` | Writes current memories into `AGENTS.md` / `CLAUDE.md` |
|
|
120
141
|
| `aether-cache connect cursor` | Connects one tool (`--print` shows config for any other MCP tool) |
|
|
121
142
|
| `aether-cache start` / `stop` | Runs or stops the engine (it also starts on its own when a tool needs it) |
|
|
143
|
+
| `aether-cache uninstall` | Disconnects your AI tools, stops the engine, and asks before deleting memories |
|
|
122
144
|
|
|
123
145
|
If `aether-cache` isn't found after installing, run `uv tool update-shell` and open a new terminal. Port 7380 taken?
|
|
124
|
-
Set `AETHER_PORT` and run `setup` again. To uninstall: `aether-cache
|
|
146
|
+
Set `AETHER_PORT` and run `setup` again. To uninstall: `aether-cache uninstall`, then `uv tool uninstall aether-cache`.
|
|
125
147
|
|
|
126
148
|
## Tools the AI can call
|
|
127
149
|
|
|
@@ -335,6 +357,7 @@ aether_cache/ the Python package behind the `aether-cache` comman
|
|
|
335
357
|
connect.py registers Aether Cache with Claude Code, Cursor, and Codex
|
|
336
358
|
cli.py the `aether-cache` command
|
|
337
359
|
ui.py, ui/index.html the dashboard (`aether-cache ui`): a local server, its API, and the page
|
|
360
|
+
portability.py import from notes files, export to JSON
|
|
338
361
|
engine/aether-engine.jar the prebuilt engine (rebuild with `python scripts/build_engine.py`)
|
|
339
362
|
memory_mcp/calibrate.py similarity calibration for duplicate/contradiction thresholds
|
|
340
363
|
vector_bench.py HNSW benchmark (writes results.json)
|
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: aether-cache
|
|
3
|
-
Version: 0.1.1
|
|
4
|
-
Summary: Shared memory for AI coding tools that stays true to your code.
|
|
5
|
-
Project-URL: Homepage, https://github.com/anishchugh05/Aether-Cache-Redis-Compatible-Vector-Store
|
|
6
|
-
Author: Anish Chugh
|
|
7
|
-
Requires-Python: >=3.10
|
|
8
|
-
Requires-Dist: fastembed>=0.3
|
|
9
|
-
Requires-Dist: mcp>=1.2
|
|
10
|
-
Requires-Dist: redis>=5.0
|
|
11
|
-
Description-Content-Type: text/markdown
|
|
12
|
-
|
|
13
1
|
[](https://app.codecrafters.io/users/anishchugh05?r=2qF)
|
|
14
2
|
|
|
15
3
|
# Aether Cache
|
|
16
4
|
|
|
17
5
|
**Shared memory for AI coding tools that stays true to your code.**
|
|
18
6
|
|
|
7
|
+
**[Website](https://anishchugh05.github.io/Aether-Cache-Redis-Compatible-Vector-Store/)** · **[PyPI](https://pypi.org/project/aether-cache/)**
|
|
8
|
+
|
|
19
9
|
Claude Code, Cursor, and other AI tools each forget your project between sessions, and when they do remember things,
|
|
20
10
|
nothing checks whether those memories are still correct. Aether Cache gives every tool one shared, local memory, and keeps
|
|
21
11
|
it honest:
|
|
@@ -118,22 +108,29 @@ repo and try:
|
|
|
118
108
|
|
|
119
109
|
Then start a new session and ask how to run the tests.
|
|
120
110
|
|
|
111
|
+
Already keep notes for your AI in `CLAUDE.md`, `AGENTS.md`, or Cursor rules? Run `aether-cache import` in the repo
|
|
112
|
+
(or use **Import notes** in the dashboard) to turn them into memories, linked to the files they mention.
|
|
113
|
+
|
|
121
114
|
Memories are kept per project (the git repo's folder name), so projects never mix. Everything stays on your machine:
|
|
122
115
|
the engine only accepts connections from it, and your memories live in `~/.aether-cache`.
|
|
123
116
|
|
|
124
117
|
| Command | What it does |
|
|
125
118
|
|---|---|
|
|
126
119
|
| `aether-cache setup` | One-time setup: engine, model, and AI tools |
|
|
120
|
+
| `aether-cache add` | Adds the repo you're in (or a path) so the dashboard can check its files; the dashboard can also find and add repos for you |
|
|
127
121
|
| `aether-cache ui` | Opens the dashboard: see what your AI remembers, and confirm or retire memories whose files changed |
|
|
122
|
+
| `aether-cache import` | Turns the notes in `CLAUDE.md`, `AGENTS.md`, and Cursor rules into memories; shows them first and asks |
|
|
123
|
+
| `aether-cache export` | Saves memories, with their linked files and history, to a JSON file (`-p` for one project) |
|
|
128
124
|
| `aether-cache status` | Is it running, and what's in this project's memory |
|
|
129
125
|
| `aether-cache doctor` | Checks everything and tells you how to fix what's wrong |
|
|
130
126
|
| `aether-cache list` / `recall "query"` / `explain m1` | Look at memories from the terminal |
|
|
131
127
|
| `aether-cache sync` | Writes current memories into `AGENTS.md` / `CLAUDE.md` |
|
|
132
128
|
| `aether-cache connect cursor` | Connects one tool (`--print` shows config for any other MCP tool) |
|
|
133
129
|
| `aether-cache start` / `stop` | Runs or stops the engine (it also starts on its own when a tool needs it) |
|
|
130
|
+
| `aether-cache uninstall` | Disconnects your AI tools, stops the engine, and asks before deleting memories |
|
|
134
131
|
|
|
135
132
|
If `aether-cache` isn't found after installing, run `uv tool update-shell` and open a new terminal. Port 7380 taken?
|
|
136
|
-
Set `AETHER_PORT` and run `setup` again. To uninstall: `aether-cache
|
|
133
|
+
Set `AETHER_PORT` and run `setup` again. To uninstall: `aether-cache uninstall`, then `uv tool uninstall aether-cache`.
|
|
137
134
|
|
|
138
135
|
## Tools the AI can call
|
|
139
136
|
|
|
@@ -347,6 +344,7 @@ aether_cache/ the Python package behind the `aether-cache` comman
|
|
|
347
344
|
connect.py registers Aether Cache with Claude Code, Cursor, and Codex
|
|
348
345
|
cli.py the `aether-cache` command
|
|
349
346
|
ui.py, ui/index.html the dashboard (`aether-cache ui`): a local server, its API, and the page
|
|
347
|
+
portability.py import from notes files, export to JSON
|
|
350
348
|
engine/aether-engine.jar the prebuilt engine (rebuild with `python scripts/build_engine.py`)
|
|
351
349
|
memory_mcp/calibrate.py similarity calibration for duplicate/contradiction thresholds
|
|
352
350
|
vector_bench.py HNSW benchmark (writes results.json)
|
|
@@ -179,6 +179,161 @@ def _tool_output(name: str, *tool_args) -> int:
|
|
|
179
179
|
return 0
|
|
180
180
|
|
|
181
181
|
|
|
182
|
+
def cmd_add(args) -> int:
|
|
183
|
+
from . import repos
|
|
184
|
+
from .server import EngineDown, store_down
|
|
185
|
+
try:
|
|
186
|
+
engine.ensure_running()
|
|
187
|
+
added = repos.add(args.path)
|
|
188
|
+
except repos.RepoError as e:
|
|
189
|
+
print(e)
|
|
190
|
+
return 1
|
|
191
|
+
except (engine.EngineError, EngineDown) as e:
|
|
192
|
+
print(store_down(e))
|
|
193
|
+
return 1
|
|
194
|
+
print(f"Added {added['name']} ({added['path']})" + (f", github.com/{added['github']}" if added["github"] else "") + ".")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _ask(question: str) -> bool:
|
|
199
|
+
if not sys.stdin.isatty():
|
|
200
|
+
return False
|
|
201
|
+
try:
|
|
202
|
+
return input(f"{question} [y/N] ").strip().lower() in ("y", "yes")
|
|
203
|
+
except EOFError:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def cmd_import(args) -> int:
|
|
208
|
+
from pathlib import Path
|
|
209
|
+
from . import portability, repos, server
|
|
210
|
+
folder = Path(args.path).expanduser()
|
|
211
|
+
if not folder.is_dir():
|
|
212
|
+
print(f"{args.path} isn't a folder.")
|
|
213
|
+
return 1
|
|
214
|
+
root = repos.git_top(folder) or folder.resolve()
|
|
215
|
+
project = root.name
|
|
216
|
+
try:
|
|
217
|
+
engine.ensure_running()
|
|
218
|
+
found = portability.scan(project, root)
|
|
219
|
+
except (engine.EngineError, server.EngineDown) as e:
|
|
220
|
+
print(server.store_down(e))
|
|
221
|
+
return 1
|
|
222
|
+
if not found["files"]:
|
|
223
|
+
print(f"No notes files in {root}.\nAether Cache looks for: " + ", ".join(portability.NOTE_FILES)
|
|
224
|
+
+ ", and .cursor/rules/.")
|
|
225
|
+
return 0
|
|
226
|
+
notes = found["notes"]
|
|
227
|
+
new = [n for n in notes if n["selected"]]
|
|
228
|
+
left_out = [n for n in notes if not n["known"] and not n["selected"]]
|
|
229
|
+
known = [n for n in notes if n["known"]]
|
|
230
|
+
print(f"Found {len(notes)} notes in " + ", ".join(f["path"] for f in found["files"]) + f" ({project})\n")
|
|
231
|
+
for i, n in enumerate(new, 1):
|
|
232
|
+
print(f" {i:>3}. {n['text']}" + (f"\n linked to {', '.join(n['files'])}" if n["files"] else ""))
|
|
233
|
+
if left_out:
|
|
234
|
+
print(f"\nLeft out ({len(left_out)}):")
|
|
235
|
+
for n in left_out:
|
|
236
|
+
why = (f"mentions {', '.join(n['missing'])}, which doesn't exist (probably out of date)"
|
|
237
|
+
if n["missing"] else "doesn't look like a fact about the project")
|
|
238
|
+
print(f" - {n['text']}\n {why}")
|
|
239
|
+
if known:
|
|
240
|
+
print(f"\nAlready remembered: {len(known)}")
|
|
241
|
+
if not new:
|
|
242
|
+
print("\nNothing new to import.")
|
|
243
|
+
return 0
|
|
244
|
+
if left_out:
|
|
245
|
+
print("\nTo pick notes one by one, including the left-out ones, use Import notes in `aether-cache ui`.")
|
|
246
|
+
if not args.yes and not _ask(f"\nImport {len(new)} {'memory' if len(new) == 1 else 'memories'} into {project}?"):
|
|
247
|
+
print("Nothing imported." + ("" if sys.stdin.isatty() else " Pass --yes to import without asking."))
|
|
248
|
+
return 0
|
|
249
|
+
try:
|
|
250
|
+
result = portability.apply(project, root)
|
|
251
|
+
repos.add(str(root)) # so the dashboard can check the imported memories' files
|
|
252
|
+
except repos.RepoError:
|
|
253
|
+
pass
|
|
254
|
+
except server.EngineDown as e:
|
|
255
|
+
print(server.store_down(e))
|
|
256
|
+
return 1
|
|
257
|
+
saved = len(result["saved"])
|
|
258
|
+
print(f"Imported {saved} {'memory' if saved == 1 else 'memories'} into {project}."
|
|
259
|
+
+ (f" {result['duplicates']} matched memories you already had." if result["duplicates"] else "")
|
|
260
|
+
+ (f"\n{result['conflicts']} may contradict an existing memory: decide in `aether-cache ui`."
|
|
261
|
+
if result["conflicts"] else ""))
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def cmd_export(args) -> int:
|
|
266
|
+
import json
|
|
267
|
+
from pathlib import Path
|
|
268
|
+
from . import portability, server
|
|
269
|
+
try:
|
|
270
|
+
engine.ensure_running()
|
|
271
|
+
data = portability.export(args.project or None)
|
|
272
|
+
except (engine.EngineError, server.EngineDown) as e:
|
|
273
|
+
print(server.store_down(e))
|
|
274
|
+
return 1
|
|
275
|
+
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
276
|
+
if args.output == "-":
|
|
277
|
+
sys.stdout.write(text)
|
|
278
|
+
return 0
|
|
279
|
+
out = Path(args.output or portability.export_filename(args.project[0] if len(args.project or []) == 1 else None))
|
|
280
|
+
out = out.expanduser()
|
|
281
|
+
out.write_text(text, encoding="utf-8")
|
|
282
|
+
n = sum(len(p["memories"]) for p in data["projects"])
|
|
283
|
+
print(f"Exported {n} {'memory' if n == 1 else 'memories'} from {len(data['projects'])} "
|
|
284
|
+
f"{'project' if len(data['projects']) == 1 else 'projects'} to {out.resolve()}")
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _uninstall_command() -> str:
|
|
289
|
+
exe = sys.executable.replace("\\", "/")
|
|
290
|
+
if "/uv/tools/" in exe or "/uv/tool/" in exe:
|
|
291
|
+
return "uv tool uninstall aether-cache"
|
|
292
|
+
if "/pipx/" in exe:
|
|
293
|
+
return "pipx uninstall aether-cache"
|
|
294
|
+
return f"{sys.executable} -m pip uninstall aether-cache"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_uninstall(args) -> int:
|
|
298
|
+
import shutil
|
|
299
|
+
print("Uninstalling Aether Cache\n")
|
|
300
|
+
failed = False
|
|
301
|
+
for tool in connect.TOOLS:
|
|
302
|
+
done, message = connect.DISCONNECTORS[tool]()
|
|
303
|
+
_say(OK if done else FAIL, message)
|
|
304
|
+
failed |= not done
|
|
305
|
+
print(" Restart any AI tools that are open, so they stop using Aether Cache.")
|
|
306
|
+
count = None
|
|
307
|
+
if engine.probe() == "aether":
|
|
308
|
+
try:
|
|
309
|
+
from .ui import all_memories
|
|
310
|
+
count = len(all_memories())
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
stopped = engine.stop()
|
|
314
|
+
still_running = engine.probe() == "aether"
|
|
315
|
+
_say(FAIL if still_running else OK, stopped)
|
|
316
|
+
failed |= still_running
|
|
317
|
+
|
|
318
|
+
has_data = config.HOME.exists()
|
|
319
|
+
if still_running and has_data: # it would rewrite them anyway
|
|
320
|
+
print(f"\nYour memories are kept in {config.HOME}: stop the engine first to delete them.")
|
|
321
|
+
print(f"\nLast step, remove the program itself:\n {_uninstall_command()}")
|
|
322
|
+
return 1
|
|
323
|
+
delete = args.delete_data
|
|
324
|
+
if has_data and not delete:
|
|
325
|
+
what = f"Your {count} {'memory is' if count == 1 else 'memories are'}" if count else "Your memories are"
|
|
326
|
+
print(f"\n{what} in {config.HOME}.")
|
|
327
|
+
delete = _ask("Delete them too? This can't be undone (keep a copy first with `aether-cache export`).")
|
|
328
|
+
if has_data and delete:
|
|
329
|
+
shutil.rmtree(config.HOME, ignore_errors=True)
|
|
330
|
+
_say(OK, f"Deleted {config.HOME}")
|
|
331
|
+
elif has_data:
|
|
332
|
+
_say(OK, f"Kept your memories in {config.HOME}. Reinstall any time and they're back.")
|
|
333
|
+
print(f"\nLast step, remove the program itself:\n {_uninstall_command()}")
|
|
334
|
+
return 1 if failed else 0
|
|
335
|
+
|
|
336
|
+
|
|
182
337
|
def cmd_serve(args) -> int:
|
|
183
338
|
from .server import serve
|
|
184
339
|
serve()
|
|
@@ -220,6 +375,20 @@ def main(argv=None) -> None:
|
|
|
220
375
|
func=lambda a: _tool_output("sync_to_files"))
|
|
221
376
|
sub.add_parser("check", help="retire memories whose linked files changed").set_defaults(
|
|
222
377
|
func=lambda a: _tool_output("check_stale"))
|
|
378
|
+
p = sub.add_parser("add", help="add a repo so the dashboard can check its files (default: this folder)")
|
|
379
|
+
p.add_argument("path", nargs="?", default=".")
|
|
380
|
+
p.set_defaults(func=cmd_add)
|
|
381
|
+
p = sub.add_parser("import", help="turn notes in CLAUDE.md, AGENTS.md, and Cursor rules into memories")
|
|
382
|
+
p.add_argument("path", nargs="?", default=".", help="the repo to import from (default: this folder)")
|
|
383
|
+
p.add_argument("--yes", "-y", action="store_true", help="import without asking")
|
|
384
|
+
p.set_defaults(func=cmd_import)
|
|
385
|
+
p = sub.add_parser("export", help="save memories, with their history, to a JSON file")
|
|
386
|
+
p.add_argument("--project", "-p", action="append", metavar="NAME", help="only this project (repeatable; default: all)")
|
|
387
|
+
p.add_argument("--output", "-o", metavar="FILE", help="where to write it (default: aether-cache-<date>.json here; - for stdout)")
|
|
388
|
+
p.set_defaults(func=cmd_export)
|
|
389
|
+
p = sub.add_parser("uninstall", help="disconnect your AI tools, stop the engine, and optionally delete memories")
|
|
390
|
+
p.add_argument("--delete-data", action="store_true", help="also delete all memories, without asking")
|
|
391
|
+
p.set_defaults(func=cmd_uninstall)
|
|
223
392
|
p = sub.add_parser("ui", help="open the dashboard: see and fix what your AI tools remember")
|
|
224
393
|
p.add_argument("--port", type=int, help="port for the dashboard (default 7381)")
|
|
225
394
|
p.add_argument("--no-open", action="store_true", help="don't open a browser, just print the link")
|
|
@@ -66,6 +66,28 @@ def add_instructions(path: Path) -> str:
|
|
|
66
66
|
return f"told it to use Aether Cache for project memory in {path}"
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def remove_instructions(path: Path) -> bool:
|
|
70
|
+
"""Takes our section back out of an instruction file, leaving the rest exactly as it was.
|
|
71
|
+
Deletes the file only if our section was all that was in it. Returns whether anything changed."""
|
|
72
|
+
try:
|
|
73
|
+
old = path.read_text(encoding="utf-8")
|
|
74
|
+
except OSError:
|
|
75
|
+
return False
|
|
76
|
+
if INSTR_START not in old or INSTR_END not in old:
|
|
77
|
+
return False
|
|
78
|
+
before, after = old[:old.index(INSTR_START)], old[old.index(INSTR_END) + len(INSTR_END):]
|
|
79
|
+
new = before.rstrip() + ("\n\n" + after.lstrip("\n") if before.strip() and after.strip() else after.lstrip("\n"))
|
|
80
|
+
if not new.strip():
|
|
81
|
+
path.unlink()
|
|
82
|
+
return True
|
|
83
|
+
if not new.endswith("\n"):
|
|
84
|
+
new += "\n"
|
|
85
|
+
tmp = path.with_name(path.name + ".aether-tmp")
|
|
86
|
+
tmp.write_text(new, encoding="utf-8")
|
|
87
|
+
os.replace(tmp, path)
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
69
91
|
def claude_instructions_file() -> Path:
|
|
70
92
|
return Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")) / "CLAUDE.md"
|
|
71
93
|
|
|
@@ -95,6 +117,20 @@ def connect_claude_code() -> Tuple[bool, str]:
|
|
|
95
117
|
return True, "Claude Code: connected for all projects (" + "; ".join(notes) + ")."
|
|
96
118
|
|
|
97
119
|
|
|
120
|
+
def disconnect_claude_code() -> Tuple[bool, str]:
|
|
121
|
+
notes = []
|
|
122
|
+
if shutil.which("claude"):
|
|
123
|
+
removed = [scope for scope in ("user", "local")
|
|
124
|
+
if _claude("mcp", "remove", NAME, "--scope", scope).returncode == 0]
|
|
125
|
+
if removed:
|
|
126
|
+
notes.append("removed the MCP server")
|
|
127
|
+
if _claude("mcp", "get", NAME).returncode == 0:
|
|
128
|
+
return False, "Claude Code: couldn't remove it. Run `claude mcp remove aether-cache` yourself."
|
|
129
|
+
if remove_instructions(claude_instructions_file()):
|
|
130
|
+
notes.append(f"removed our section from {claude_instructions_file()}")
|
|
131
|
+
return True, "Claude Code: disconnected" + (f" ({'; '.join(notes)})." if notes else " (it wasn't connected).")
|
|
132
|
+
|
|
133
|
+
|
|
98
134
|
def claude_code_connected() -> bool:
|
|
99
135
|
return bool(shutil.which("claude")) and _claude("mcp", "get", NAME).returncode == 0
|
|
100
136
|
|
|
@@ -128,6 +164,25 @@ def connect_cursor() -> Tuple[bool, str]:
|
|
|
128
164
|
return True, f"Cursor: connected for all projects ({path}). Restart Cursor to pick it up."
|
|
129
165
|
|
|
130
166
|
|
|
167
|
+
def disconnect_cursor() -> Tuple[bool, str]:
|
|
168
|
+
path = cursor_config()
|
|
169
|
+
try:
|
|
170
|
+
data = json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
171
|
+
except OSError:
|
|
172
|
+
return True, "Cursor: disconnected (it wasn't connected)."
|
|
173
|
+
except json.JSONDecodeError as e:
|
|
174
|
+
return False, f"Cursor: couldn't read {path} ({e}). Remove the \"{NAME}\" entry yourself."
|
|
175
|
+
servers = data.get("mcpServers") if isinstance(data, dict) else None
|
|
176
|
+
if not isinstance(servers, dict) or NAME not in servers:
|
|
177
|
+
return True, "Cursor: disconnected (it wasn't connected)."
|
|
178
|
+
shutil.copyfile(path, path.with_name("mcp.json.bak"))
|
|
179
|
+
del servers[NAME]
|
|
180
|
+
tmp = path.with_name("mcp.json.aether-tmp")
|
|
181
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
182
|
+
os.replace(tmp, path)
|
|
183
|
+
return True, f"Cursor: disconnected ({path}). Restart Cursor to pick it up."
|
|
184
|
+
|
|
185
|
+
|
|
131
186
|
def cursor_connected() -> bool:
|
|
132
187
|
try:
|
|
133
188
|
return NAME in json.loads(cursor_config().read_text(encoding="utf-8")).get("mcpServers", {})
|
|
@@ -155,14 +210,7 @@ def _is_our_header(line: str) -> bool:
|
|
|
155
210
|
def connect_codex() -> Tuple[bool, str]:
|
|
156
211
|
path = codex_config()
|
|
157
212
|
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()
|
|
213
|
+
body = _without_our_section(text) # drop our old section (if any): from its header to the next table header
|
|
166
214
|
new = (body + "\n\n" if body else "") + _codex_block()
|
|
167
215
|
if path.exists():
|
|
168
216
|
shutil.copyfile(path, path.with_name("config.toml.bak"))
|
|
@@ -174,6 +222,35 @@ def connect_codex() -> Tuple[bool, str]:
|
|
|
174
222
|
return True, f"Codex: connected ({path}; {note})."
|
|
175
223
|
|
|
176
224
|
|
|
225
|
+
def _without_our_section(text: str) -> str:
|
|
226
|
+
kept, skipping = [], False
|
|
227
|
+
for line in text.splitlines():
|
|
228
|
+
if line.strip().startswith("["):
|
|
229
|
+
skipping = _is_our_header(line)
|
|
230
|
+
if not skipping:
|
|
231
|
+
kept.append(line)
|
|
232
|
+
return "\n".join(kept).rstrip()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def disconnect_codex() -> Tuple[bool, str]:
|
|
236
|
+
path = codex_config()
|
|
237
|
+
notes = []
|
|
238
|
+
try:
|
|
239
|
+
text = path.read_text(encoding="utf-8")
|
|
240
|
+
except OSError:
|
|
241
|
+
text = ""
|
|
242
|
+
if any(_is_our_header(l) for l in text.splitlines()):
|
|
243
|
+
shutil.copyfile(path, path.with_name("config.toml.bak"))
|
|
244
|
+
body = _without_our_section(text)
|
|
245
|
+
tmp = path.with_name("config.toml.aether-tmp")
|
|
246
|
+
tmp.write_text(body + "\n" if body else "", encoding="utf-8")
|
|
247
|
+
os.replace(tmp, path)
|
|
248
|
+
notes.append(f"removed the MCP server from {path}")
|
|
249
|
+
if remove_instructions(path.with_name("AGENTS.md")):
|
|
250
|
+
notes.append(f"removed our section from {path.with_name('AGENTS.md')}")
|
|
251
|
+
return True, "Codex: disconnected" + (f" ({'; '.join(notes)})." if notes else " (it wasn't connected).")
|
|
252
|
+
|
|
253
|
+
|
|
177
254
|
def codex_connected() -> bool:
|
|
178
255
|
try:
|
|
179
256
|
return any(_is_our_header(l) for l in codex_config().read_text(encoding="utf-8").splitlines())
|
|
@@ -192,4 +269,6 @@ def generic_instructions() -> str:
|
|
|
192
269
|
|
|
193
270
|
|
|
194
271
|
CONNECTORS = {"claude-code": connect_claude_code, "cursor": connect_cursor, "codex": connect_codex}
|
|
272
|
+
DISCONNECTORS = {"claude-code": disconnect_claude_code, "cursor": disconnect_cursor, "codex": disconnect_codex}
|
|
273
|
+
LABELS = {"claude-code": "Claude Code", "cursor": "Cursor", "codex": "Codex"}
|
|
195
274
|
CHECKS = {"claude-code": claude_code_connected, "cursor": cursor_connected, "codex": codex_connected}
|