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.
Files changed (23) hide show
  1. {aether_cache-0.1.1 → aether_cache-0.1.3}/.gitignore +1 -0
  2. aether_cache-0.1.1/README.md → aether_cache-0.1.3/PKG-INFO +24 -1
  3. aether_cache-0.1.1/PKG-INFO → aether_cache-0.1.3/README.md +11 -13
  4. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/__init__.py +1 -1
  5. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/cli.py +169 -0
  6. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/connect.py +87 -8
  7. aether_cache-0.1.3/aether_cache/portability.py +354 -0
  8. aether_cache-0.1.3/aether_cache/repos.py +134 -0
  9. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/server.py +13 -0
  10. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/index.html +447 -13
  11. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui.py +109 -1
  12. aether_cache-0.1.3/docs/README.md +41 -0
  13. {aether_cache-0.1.1 → aether_cache-0.1.3}/pyproject.toml +3 -2
  14. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/__main__.py +0 -0
  15. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/config.py +0 -0
  16. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/engine/aether-engine.jar +0 -0
  17. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/engine.py +0 -0
  18. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/OFL.txt +0 -0
  19. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  20. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  21. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  22. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  23. {aether_cache-0.1.1 → aether_cache-0.1.3}/aether_cache/ui/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
@@ -19,3 +19,4 @@ dist/
19
19
  .impeccable/
20
20
  PRODUCT.md
21
21
  DESIGN.md
22
+ .DS_Store
@@ -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
  [![progress-banner](https://backend.codecrafters.io/progress/redis/f43a7cd3-11e6-45dc-a8ba-9d6690c73ac0)](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 stop && uv tool 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
  [![progress-banner](https://backend.codecrafters.io/progress/redis/f43a7cd3-11e6-45dc-a8ba-9d6690c73ac0)](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 stop && uv tool 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)
@@ -1,3 +1,3 @@
1
1
  """Aether Cache: shared memory for AI coding tools that stays true to your code."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.1.3"
@@ -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}