code-context-engine 0.4.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.
Files changed (63) hide show
  1. code_context_engine-0.4.0.dist-info/METADATA +389 -0
  2. code_context_engine-0.4.0.dist-info/RECORD +63 -0
  3. code_context_engine-0.4.0.dist-info/WHEEL +5 -0
  4. code_context_engine-0.4.0.dist-info/entry_points.txt +4 -0
  5. code_context_engine-0.4.0.dist-info/licenses/LICENSE +21 -0
  6. code_context_engine-0.4.0.dist-info/top_level.txt +1 -0
  7. context_engine/__init__.py +3 -0
  8. context_engine/cli.py +2848 -0
  9. context_engine/cli_style.py +66 -0
  10. context_engine/compression/__init__.py +0 -0
  11. context_engine/compression/compressor.py +144 -0
  12. context_engine/compression/ollama_client.py +33 -0
  13. context_engine/compression/output_rules.py +77 -0
  14. context_engine/compression/prompts.py +9 -0
  15. context_engine/compression/quality.py +37 -0
  16. context_engine/config.py +198 -0
  17. context_engine/dashboard/__init__.py +0 -0
  18. context_engine/dashboard/_page.py +1548 -0
  19. context_engine/dashboard/server.py +429 -0
  20. context_engine/editors.py +265 -0
  21. context_engine/event_bus.py +24 -0
  22. context_engine/indexer/__init__.py +0 -0
  23. context_engine/indexer/chunker.py +147 -0
  24. context_engine/indexer/embedder.py +154 -0
  25. context_engine/indexer/embedding_cache.py +168 -0
  26. context_engine/indexer/git_hooks.py +73 -0
  27. context_engine/indexer/git_indexer.py +136 -0
  28. context_engine/indexer/ignorefile.py +96 -0
  29. context_engine/indexer/manifest.py +78 -0
  30. context_engine/indexer/pipeline.py +624 -0
  31. context_engine/indexer/secrets.py +332 -0
  32. context_engine/indexer/watcher.py +109 -0
  33. context_engine/integration/__init__.py +0 -0
  34. context_engine/integration/bootstrap.py +76 -0
  35. context_engine/integration/git_context.py +132 -0
  36. context_engine/integration/mcp_server.py +1825 -0
  37. context_engine/integration/session_capture.py +306 -0
  38. context_engine/memory/__init__.py +6 -0
  39. context_engine/memory/compressor.py +344 -0
  40. context_engine/memory/db.py +922 -0
  41. context_engine/memory/extractive.py +106 -0
  42. context_engine/memory/grammar.py +419 -0
  43. context_engine/memory/hook_installer.py +258 -0
  44. context_engine/memory/hook_server.py +83 -0
  45. context_engine/memory/hooks.py +327 -0
  46. context_engine/memory/migrate.py +268 -0
  47. context_engine/models.py +96 -0
  48. context_engine/pricing.py +104 -0
  49. context_engine/project_commands.py +296 -0
  50. context_engine/retrieval/__init__.py +0 -0
  51. context_engine/retrieval/confidence.py +47 -0
  52. context_engine/retrieval/query_parser.py +105 -0
  53. context_engine/retrieval/retriever.py +199 -0
  54. context_engine/serve_http.py +208 -0
  55. context_engine/services.py +252 -0
  56. context_engine/storage/__init__.py +0 -0
  57. context_engine/storage/backend.py +39 -0
  58. context_engine/storage/fts_store.py +112 -0
  59. context_engine/storage/graph_store.py +219 -0
  60. context_engine/storage/local_backend.py +109 -0
  61. context_engine/storage/remote_backend.py +117 -0
  62. context_engine/storage/vector_store.py +357 -0
  63. context_engine/utils.py +72 -0
@@ -0,0 +1,429 @@
1
+ """FastAPI dashboard server for CCE index inspection."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from urllib.parse import quote
10
+
11
+ from typing import Literal
12
+
13
+ from fastapi import FastAPI, Request
14
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
15
+ from pydantic import BaseModel
16
+
17
+ from context_engine.config import Config
18
+ from context_engine.dashboard._page import PAGE_HTML
19
+ from context_engine.indexer.pipeline import PathOutsideProjectError, run_indexing
20
+ from context_engine.storage.local_backend import LocalBackend
21
+
22
+ # Mutating HTTP methods require a same-origin browser request OR a non-browser
23
+ # client (Sec-Fetch-Site absent). This blocks CSRF from a malicious local page
24
+ # without breaking the dashboard's own fetch() calls or curl/tests.
25
+ _MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
26
+ # Optional bearer token for mutating endpoints. When CCE_DASHBOARD_TOKEN is set
27
+ # in the environment, mutating requests must include `Authorization: Bearer
28
+ # <token>` (the dashboard JS picks the token up from a `?token=` URL param so
29
+ # users can paste a single URL into a browser). When the env var is unset the
30
+ # dashboard remains open like before — the CSRF check above is the only guard.
31
+ _DASHBOARD_TOKEN_ENV = "CCE_DASHBOARD_TOKEN"
32
+
33
+
34
+ class ReindexRequest(BaseModel):
35
+ full: bool = False
36
+
37
+
38
+ class CompressionRequest(BaseModel):
39
+ level: Literal["off", "lite", "standard", "max"]
40
+
41
+
42
+ def create_app(config: Config, project_dir: Path) -> FastAPI:
43
+ """Build and return the FastAPI application.
44
+
45
+ All route handlers close over `storage_base` and `project_dir` so the
46
+ app is self-contained and trivial to test with TestClient.
47
+ """
48
+ project_name = project_dir.name
49
+ storage_base = Path(config.storage_path) / project_name
50
+
51
+ app = FastAPI(title="CCE Dashboard", docs_url=None, redoc_url=None)
52
+
53
+ expected_token = (os.environ.get(_DASHBOARD_TOKEN_ENV) or "").strip() or None
54
+
55
+ @app.middleware("http")
56
+ async def csrf_and_auth(request: Request, call_next):
57
+ if request.method in _MUTATING_METHODS:
58
+ # CSRF: browser cross-origin requests are rejected. Non-browser
59
+ # clients (curl, tests) don't send Sec-Fetch-Site at all and are
60
+ # allowed to proceed to the auth check.
61
+ sfs = request.headers.get("sec-fetch-site")
62
+ if sfs is not None and sfs != "same-origin":
63
+ return JSONResponse(
64
+ {"error": "cross-origin requests not allowed"},
65
+ status_code=403,
66
+ )
67
+ # Auth: only enforced when CCE_DASHBOARD_TOKEN is set. Use
68
+ # constant-time comparison so a token-guessing attacker can't
69
+ # learn anything from response timing.
70
+ if expected_token is not None:
71
+ auth = request.headers.get("authorization", "")
72
+ presented = ""
73
+ if auth.startswith("Bearer "):
74
+ presented = auth[len("Bearer "):]
75
+ if not presented or not hmac.compare_digest(presented, expected_token):
76
+ return JSONResponse(
77
+ {"error": "invalid or missing bearer token"},
78
+ status_code=401,
79
+ )
80
+ return await call_next(request)
81
+
82
+ backend = LocalBackend(base_path=str(storage_base))
83
+
84
+ # ── helpers ────────────────────────────────────────────────────────────
85
+
86
+ def _read_json(path: Path) -> dict:
87
+ if path.exists():
88
+ try:
89
+ return json.loads(path.read_text())
90
+ except (json.JSONDecodeError, OSError):
91
+ pass
92
+ return {}
93
+
94
+ def _read_manifest() -> dict[str, str]:
95
+ """Return {file_path: content_hash} regardless of on-disk schema.
96
+
97
+ Manifest.save() writes {"__schema_version": 2, "files": {...},
98
+ "last_git_sha": ...}. Older installs may have left the flat
99
+ {file_path: hash} form. Both shapes collapse to the same dict here.
100
+ """
101
+ raw = _read_json(storage_base / "manifest.json")
102
+ if isinstance(raw.get("files"), dict):
103
+ return raw["files"]
104
+ # Legacy flat manifest, or empty / unreadable file.
105
+ return raw if raw and "__schema_version" not in raw else {}
106
+
107
+ def _read_stats() -> dict:
108
+ return _read_json(storage_base / "stats.json")
109
+
110
+ def _read_state() -> dict:
111
+ return _read_json(storage_base / "state.json")
112
+
113
+ def _read_sessions(limit: int = 20) -> list[dict]:
114
+ sessions_dir = storage_base / "sessions"
115
+ if not sessions_dir.exists():
116
+ return []
117
+ files = sorted(sessions_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
118
+ result = []
119
+ for f in files[:limit]:
120
+ try:
121
+ result.append(json.loads(f.read_text()))
122
+ except (json.JSONDecodeError, OSError):
123
+ pass
124
+ return result
125
+
126
+ # ── routes ─────────────────────────────────────────────────────────────
127
+
128
+ @app.get("/", response_class=HTMLResponse)
129
+ async def serve_page() -> str:
130
+ return PAGE_HTML
131
+
132
+ @app.get("/api/status")
133
+ async def get_status() -> dict:
134
+ stats = _read_stats()
135
+ manifest = _read_manifest()
136
+ state = _read_state()
137
+
138
+ try:
139
+ chunks = backend.count_chunks()
140
+ except Exception:
141
+ chunks = 0
142
+
143
+ full_file = stats.get("full_file_tokens", 0)
144
+ served = stats.get("served_tokens", 0)
145
+ baseline = full_file if full_file > 0 else stats.get("raw_tokens", 0)
146
+ saved_pct = max(0, int((1 - served / baseline) * 100)) if baseline > 0 else 0
147
+
148
+ output_level = state.get("output_level", config.output_compression)
149
+
150
+ return {
151
+ "project": project_name,
152
+ "initialized": bool(manifest),
153
+ "chunks": chunks,
154
+ "files": len(manifest),
155
+ "queries": stats.get("queries", 0),
156
+ "tokens_saved_pct": saved_pct,
157
+ "output_level": output_level,
158
+ }
159
+
160
+ @app.get("/api/files")
161
+ async def get_files() -> list:
162
+ manifest = _read_manifest()
163
+ if not manifest:
164
+ return []
165
+
166
+ chunk_counts = backend.file_chunk_counts()
167
+
168
+ result = []
169
+ for rel_path, stored_hash in sorted(manifest.items()):
170
+ abs_path = project_dir / rel_path
171
+ if not abs_path.exists():
172
+ status = "missing"
173
+ else:
174
+ try:
175
+ current = abs_path.read_text(encoding="utf-8", errors="strict")
176
+ current_hash = hashlib.sha256(current.encode("utf-8")).hexdigest()
177
+ status = "ok" if current_hash == stored_hash else "stale"
178
+ except (UnicodeDecodeError, OSError):
179
+ status = "ok" # binary file, trust the manifest
180
+ result.append({
181
+ "path": rel_path,
182
+ "chunks": chunk_counts.get(rel_path, 0),
183
+ "status": status,
184
+ })
185
+ return result
186
+
187
+ @app.get("/api/sessions")
188
+ async def get_sessions() -> list:
189
+ return _read_sessions()
190
+
191
+ # ── memory.db API (PR 5) ────────────────────────────────────────────────
192
+ # These endpoints expose the per-project memory.db introduced in PRs 1–4.
193
+ # All queries open a fresh connection (cheap on SQLite WAL) so the
194
+ # dashboard process never holds a long-lived handle that would block the
195
+ # MCP server's writes.
196
+
197
+ def _open_memory_conn():
198
+ from context_engine.memory import db as memory_db
199
+ path = memory_db.memory_db_path(storage_base)
200
+ if not path.exists():
201
+ return None
202
+ return memory_db.connect(path)
203
+
204
+ @app.get("/api/memory/sessions")
205
+ async def memory_sessions_list(limit: int = 50) -> list:
206
+ """Sessions list: most-recent first. Limited to `limit` rows.
207
+
208
+ Stored `rollup_summary` was passed through grammar.compress on write;
209
+ we expand here so the dashboard renders natural prose, not the
210
+ article-stripped storage form.
211
+ """
212
+ from context_engine.memory.grammar import expand as _grammar_expand
213
+ conn = _open_memory_conn()
214
+ if conn is None:
215
+ return []
216
+ try:
217
+ rows = list(conn.execute(
218
+ "SELECT id, project, started_at, ended_at, status, "
219
+ "prompt_count, rollup_summary FROM sessions "
220
+ "ORDER BY started_at_epoch DESC LIMIT ?",
221
+ (max(1, min(limit, 500)),),
222
+ ))
223
+ finally:
224
+ conn.close()
225
+ out = []
226
+ for r in rows:
227
+ d = dict(r)
228
+ if d.get("rollup_summary"):
229
+ d["rollup_summary"] = _grammar_expand(d["rollup_summary"])
230
+ out.append(d)
231
+ return out
232
+
233
+ @app.get("/api/memory/sessions/{session_id}/timeline")
234
+ async def memory_session_timeline(session_id: str) -> dict:
235
+ """A single session's turn summaries plus header metadata."""
236
+ from context_engine.memory.grammar import expand as _grammar_expand
237
+ conn = _open_memory_conn()
238
+ if conn is None:
239
+ return {"session": None, "turns": []}
240
+ try:
241
+ session_row = conn.execute(
242
+ "SELECT id, project, started_at, ended_at, status, "
243
+ "prompt_count, rollup_summary FROM sessions WHERE id = ?",
244
+ (session_id,),
245
+ ).fetchone()
246
+ if session_row is None:
247
+ return {"session": None, "turns": []}
248
+ turn_rows = list(conn.execute(
249
+ "SELECT prompt_number, summary, tier, created_at_epoch "
250
+ "FROM turn_summaries WHERE session_id = ? "
251
+ "ORDER BY prompt_number ASC",
252
+ (session_id,),
253
+ ))
254
+ finally:
255
+ conn.close()
256
+ session_d = dict(session_row)
257
+ if session_d.get("rollup_summary"):
258
+ session_d["rollup_summary"] = _grammar_expand(session_d["rollup_summary"])
259
+ turns = []
260
+ for r in turn_rows:
261
+ d = dict(r)
262
+ if d.get("summary"):
263
+ d["summary"] = _grammar_expand(d["summary"])
264
+ turns.append(d)
265
+ return {"session": session_d, "turns": turns}
266
+
267
+ @app.get("/api/memory/decisions")
268
+ async def memory_decisions_search(
269
+ q: str = "", source: str | None = None, limit: int = 50
270
+ ) -> list:
271
+ """FTS5 search over decisions, optionally filtered by source."""
272
+ conn = _open_memory_conn()
273
+ if conn is None:
274
+ return []
275
+ limit = max(1, min(limit, 500))
276
+ try:
277
+ params: list = []
278
+ sql = (
279
+ "SELECT d.id, d.session_id, d.decision, d.reason, d.source, "
280
+ "d.created_at FROM decisions d "
281
+ )
282
+ if q.strip():
283
+ sql += (
284
+ "JOIN decisions_fts ON decisions_fts.rowid = d.id "
285
+ "WHERE decisions_fts MATCH ? "
286
+ )
287
+ # Wrap in a phrase quote so FTS5's tokeniser treats user
288
+ # input as literal text — without this, characters like '-'
289
+ # parse as operators (`bge-small` becomes "bge AND NOT
290
+ # small") and queries silently miss real hits.
291
+ params.append('"' + q.strip().replace('"', '""') + '"')
292
+ else:
293
+ sql += "WHERE 1=1 "
294
+ if source in {"manual", "auto", "migrated"}:
295
+ sql += "AND d.source = ? "
296
+ params.append(source)
297
+ sql += "ORDER BY d.created_at_epoch DESC LIMIT ?"
298
+ params.append(limit)
299
+ try:
300
+ rows = list(conn.execute(sql, params))
301
+ except Exception:
302
+ # FTS5 syntax errors on weird queries — fall back to unranked.
303
+ rows = list(conn.execute(
304
+ "SELECT id, session_id, decision, reason, source, "
305
+ "created_at FROM decisions ORDER BY created_at_epoch "
306
+ "DESC LIMIT ?",
307
+ (limit,),
308
+ ))
309
+ finally:
310
+ conn.close()
311
+ # Stored decision/reason are compressed; expand for display.
312
+ from context_engine.memory.grammar import expand as _grammar_expand
313
+ out = []
314
+ for r in rows:
315
+ d = dict(r)
316
+ if d.get("decision"):
317
+ d["decision"] = _grammar_expand(d["decision"])
318
+ if d.get("reason"):
319
+ d["reason"] = _grammar_expand(d["reason"])
320
+ out.append(d)
321
+ return out
322
+
323
+ @app.get("/api/savings")
324
+ async def get_savings() -> dict:
325
+ stats = _read_stats()
326
+ full_file = stats.get("full_file_tokens", 0)
327
+ served = stats.get("served_tokens", 0)
328
+ raw = stats.get("raw_tokens", 0)
329
+ baseline = full_file if full_file > 0 else raw
330
+ saved = max(0, baseline - served)
331
+ pct = int(saved / baseline * 100) if baseline > 0 else 0
332
+ return {
333
+ "queries": stats.get("queries", 0),
334
+ "baseline_tokens": baseline,
335
+ "served_tokens": served,
336
+ "tokens_saved": saved,
337
+ "savings_pct": pct,
338
+ }
339
+
340
+ # ── action routes ──────────────────────────────────────────────────────
341
+
342
+ @app.post("/api/reindex")
343
+ async def reindex(req: ReindexRequest) -> dict:
344
+ result = await run_indexing(config, project_dir, full=req.full)
345
+ return {
346
+ "total_chunks": result.total_chunks,
347
+ "indexed_files": result.indexed_files,
348
+ "deleted_files": result.deleted_files,
349
+ "skipped_files": result.skipped_files,
350
+ "errors": result.errors,
351
+ }
352
+
353
+ @app.post("/api/reindex/{file_path:path}", response_model=None)
354
+ async def reindex_file(file_path: str) -> dict | JSONResponse:
355
+ try:
356
+ result = await run_indexing(config, project_dir, target_path=file_path)
357
+ except PathOutsideProjectError:
358
+ return JSONResponse({"error": "invalid file_path"}, status_code=400)
359
+ return {
360
+ "total_chunks": result.total_chunks,
361
+ "indexed_files": result.indexed_files,
362
+ "deleted_files": result.deleted_files,
363
+ "skipped_files": result.skipped_files,
364
+ "errors": result.errors,
365
+ }
366
+
367
+ @app.post("/api/clear")
368
+ async def clear_index() -> dict:
369
+ await backend.clear()
370
+ from context_engine.utils import atomic_write_text
371
+
372
+ atomic_write_text(
373
+ storage_base / "manifest.json",
374
+ json.dumps({"__schema_version": 2, "files": {}, "last_git_sha": None}),
375
+ )
376
+ atomic_write_text(
377
+ storage_base / "stats.json",
378
+ json.dumps({"queries": 0, "raw_tokens": 0, "served_tokens": 0, "full_file_tokens": 0}),
379
+ )
380
+ return {"ok": True}
381
+
382
+ @app.delete("/api/files/{file_path:path}", response_model=None)
383
+ async def delete_file(file_path: str) -> dict | JSONResponse:
384
+ # Reject absolute paths and traversal — the manifest stores project-relative
385
+ # paths, so anything else is either an attacker probe or a bug.
386
+ if file_path.startswith("/") or ".." in Path(file_path).parts:
387
+ return JSONResponse({"error": "invalid file_path"}, status_code=400)
388
+ files = _read_manifest()
389
+ if file_path not in files:
390
+ return JSONResponse({"error": "file not indexed"}, status_code=404)
391
+ await backend.delete_by_file(file_path)
392
+ files.pop(file_path, None)
393
+ # Preserve schema fields (last_git_sha) when rewriting.
394
+ raw = _read_json(storage_base / "manifest.json")
395
+ if isinstance(raw.get("files"), dict):
396
+ raw["files"] = files
397
+ payload = raw
398
+ else:
399
+ payload = {"__schema_version": 2, "files": files, "last_git_sha": None}
400
+ from context_engine.utils import atomic_write_text
401
+
402
+ atomic_write_text(storage_base / "manifest.json", json.dumps(payload))
403
+ return {"ok": True, "deleted": file_path}
404
+
405
+ @app.post("/api/compression")
406
+ async def set_compression(req: CompressionRequest) -> dict:
407
+ state = _read_state()
408
+ state["output_level"] = req.level
409
+ (storage_base / "state.json").write_text(json.dumps(state))
410
+ return {"level": req.level}
411
+
412
+ @app.get("/api/export")
413
+ async def export_data():
414
+ payload = {
415
+ "project": project_name,
416
+ "stats": _read_stats(),
417
+ "manifest": _read_manifest(),
418
+ "sessions": _read_sessions(),
419
+ }
420
+ safe_name = quote(project_name, safe="")
421
+ return Response(
422
+ content=json.dumps(payload, indent=2),
423
+ media_type="application/json",
424
+ headers={
425
+ "Content-Disposition": f"attachment; filename={safe_name}-cce-export.json"
426
+ },
427
+ )
428
+
429
+ return app
@@ -0,0 +1,265 @@
1
+ """Multi-editor MCP configuration.
2
+
3
+ Detects installed editors and writes MCP server config in each editor's
4
+ format. Supports Claude Code, VS Code/Copilot, Cursor, Gemini CLI, and
5
+ OpenAI Codex CLI.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from context_engine.utils import atomic_write_text, resolve_cce_binary
13
+
14
+
15
+ # ── Editor definitions ────────────────────────────────────────────────
16
+ # format: "json" (default) or "toml" for Codex
17
+
18
+ EDITORS: dict[str, dict] = {
19
+ "claude": {
20
+ "name": "Claude Code",
21
+ "config_path": ".mcp.json",
22
+ "servers_key": "mcpServers",
23
+ "format": "json",
24
+ "detect": [".mcp.json"],
25
+ },
26
+ "vscode": {
27
+ "name": "VS Code / Copilot",
28
+ "config_path": ".vscode/mcp.json",
29
+ "servers_key": "servers",
30
+ "format": "json",
31
+ "detect": [".vscode"],
32
+ },
33
+ "cursor": {
34
+ "name": "Cursor",
35
+ "config_path": ".cursor/mcp.json",
36
+ "servers_key": "mcpServers",
37
+ "format": "json",
38
+ "detect": [".cursor", ".cursorrules"],
39
+ },
40
+ "gemini": {
41
+ "name": "Gemini CLI",
42
+ "config_path": ".gemini/settings.json",
43
+ "servers_key": "mcpServers",
44
+ "format": "json",
45
+ "detect": [".gemini", "GEMINI.md"],
46
+ },
47
+ "codex": {
48
+ "name": "OpenAI Codex",
49
+ "config_path": ".codex/config.toml",
50
+ "format": "toml",
51
+ "detect": [".codex"],
52
+ },
53
+ }
54
+
55
+ # ── Instruction file definitions ──────────────────────────────────────
56
+
57
+ # Editor-agnostic CCE instructions (no "Claude Code" references)
58
+ _CCE_INSTRUCTIONS = """\
59
+ ## Context Engine (CCE)
60
+
61
+ This project uses Code Context Engine for intelligent code retrieval and
62
+ cross-session memory.
63
+
64
+ ### Searching the codebase
65
+
66
+ **Use `context_search` instead of reading files directly** when exploring
67
+ the codebase, answering questions about code, or understanding how things
68
+ work. `context_search` returns the most relevant code chunks with
69
+ confidence scores instead of whole files.
70
+
71
+ When to use `context_search`:
72
+ - Answering questions about the codebase ("how does X work?", "where is Y?")
73
+ - Exploring structure or architecture
74
+ - Finding related code, functions, or patterns
75
+
76
+ Other tools:
77
+ - `expand_chunk` for full source of a compressed result
78
+ - `related_context` for what calls/imports a function
79
+ - `session_recall` to recall past decisions
80
+
81
+ ### Cross-session memory
82
+
83
+ Call `session_recall("topic phrase")` before answering non-trivial questions.
84
+ Call `record_decision(decision="...", reason="...")` after making choices.
85
+ Call `record_code_area(file_path="...", description="...")` after meaningful work.
86
+ """
87
+
88
+ INSTRUCTION_FILES: dict[str, dict] = {
89
+ "cursorrules": {
90
+ "name": ".cursorrules",
91
+ "path": ".cursorrules",
92
+ "detect": [".cursor", ".cursorrules"],
93
+ },
94
+ "gemini": {
95
+ "name": "GEMINI.md",
96
+ "path": "GEMINI.md",
97
+ "detect": [".gemini", "GEMINI.md"],
98
+ },
99
+ }
100
+
101
+
102
+ # ── Public API ────────────────────────────────────────────────────────
103
+
104
+ def detect_editors(project_dir: Path) -> list[str]:
105
+ """Return list of editor keys detected in the project directory."""
106
+ found = []
107
+ for key, editor in EDITORS.items():
108
+ for marker in editor["detect"]:
109
+ if (project_dir / marker).exists():
110
+ found.append(key)
111
+ break
112
+ return found
113
+
114
+
115
+ def _codex_toml_block(command: str, project_dir: str) -> str:
116
+ """Generate the TOML block for Codex CLI's config.toml."""
117
+ args_toml = ", ".join(f'"{a}"' for a in ["serve", "--project-dir", project_dir])
118
+ return f'[mcp_servers.context-engine]\ncommand = "{command}"\nargs = [{args_toml}]\n'
119
+
120
+
121
+ def configure_mcp(project_dir: Path, editor_key: str) -> bool:
122
+ """Write MCP config for a specific editor. Returns True if changed."""
123
+ editor = EDITORS[editor_key]
124
+ config_path = project_dir / editor["config_path"]
125
+ command = resolve_cce_binary()
126
+
127
+ config_path.parent.mkdir(parents=True, exist_ok=True)
128
+
129
+ if editor.get("format") == "toml":
130
+ return _configure_toml(config_path, command, str(project_dir))
131
+
132
+ servers_key = editor["servers_key"]
133
+ entry = {"command": command, "args": ["serve", "--project-dir", str(project_dir)]}
134
+
135
+ if config_path.exists():
136
+ try:
137
+ data = json.loads(config_path.read_text())
138
+ except (json.JSONDecodeError, OSError):
139
+ data = {}
140
+ else:
141
+ data = {}
142
+
143
+ servers = data.setdefault(servers_key, {})
144
+ if "context-engine" in servers:
145
+ existing = servers["context-engine"]
146
+ if existing.get("command") == command and existing.get("args") == entry["args"]:
147
+ return False
148
+ servers["context-engine"] = entry
149
+ atomic_write_text(config_path, json.dumps(data, indent=2) + "\n")
150
+ return True
151
+
152
+ servers["context-engine"] = entry
153
+ atomic_write_text(config_path, json.dumps(data, indent=2) + "\n")
154
+ return True
155
+
156
+
157
+ def _configure_toml(config_path: Path, command: str, project_dir: str) -> bool:
158
+ """Add CCE to a TOML config file (Codex). Returns True if changed."""
159
+ block = _codex_toml_block(command, project_dir)
160
+ marker = "[mcp_servers.context-engine]"
161
+
162
+ if config_path.exists():
163
+ content = config_path.read_text()
164
+ if marker in content:
165
+ return False # already configured
166
+ config_path.write_text(content.rstrip() + "\n\n" + block)
167
+ else:
168
+ config_path.write_text(block)
169
+ return True
170
+
171
+
172
+ def remove_mcp(project_dir: Path, editor_key: str) -> str | None:
173
+ """Remove CCE from an editor's MCP config. Returns status message or None."""
174
+ editor = EDITORS[editor_key]
175
+ config_path = project_dir / editor["config_path"]
176
+
177
+ if not config_path.exists():
178
+ return None
179
+
180
+ if editor.get("format") == "toml":
181
+ return _remove_toml(config_path, editor["config_path"])
182
+
183
+ servers_key = editor["servers_key"]
184
+ try:
185
+ data = json.loads(config_path.read_text())
186
+ servers = data.get(servers_key, {})
187
+ if "context-engine" not in servers:
188
+ return None
189
+ del servers["context-engine"]
190
+ if servers:
191
+ config_path.write_text(json.dumps(data, indent=2) + "\n")
192
+ return f"Removed context-engine from {editor['config_path']}"
193
+ else:
194
+ config_path.unlink()
195
+ return f"Removed {editor['config_path']}"
196
+ except (json.JSONDecodeError, OSError):
197
+ return None
198
+
199
+
200
+ def _remove_toml(config_path: Path, display_path: str) -> str | None:
201
+ """Remove CCE block from a TOML config file."""
202
+ import re
203
+ content = config_path.read_text()
204
+ marker = "[mcp_servers.context-engine]"
205
+ if marker not in content:
206
+ return None
207
+
208
+ # Remove the [mcp_servers.context-engine] block (until next section header or EOF)
209
+ pattern = r"\[mcp_servers\.context-engine\].*?(?=\n\[|$)"
210
+ new_content = re.sub(pattern, "", content, flags=re.DOTALL).strip()
211
+ if new_content:
212
+ config_path.write_text(new_content + "\n")
213
+ return f"Removed context-engine from {display_path}"
214
+ else:
215
+ config_path.unlink()
216
+ return f"Removed {display_path}"
217
+
218
+
219
+ def write_instruction_file(project_dir: Path, file_key: str) -> bool:
220
+ """Write CCE instructions to an editor's instruction file. Returns True if written."""
221
+ info = INSTRUCTION_FILES[file_key]
222
+ path = project_dir / info["path"]
223
+ marker = "## Context Engine (CCE)"
224
+
225
+ if path.exists():
226
+ content = path.read_text()
227
+ if marker in content:
228
+ return False # already has CCE block
229
+ # Append
230
+ path.write_text(content.rstrip() + "\n\n" + _CCE_INSTRUCTIONS)
231
+ else:
232
+ path.write_text(_CCE_INSTRUCTIONS)
233
+ return True
234
+
235
+
236
+ def remove_instruction_file(project_dir: Path, file_key: str) -> str | None:
237
+ """Remove CCE block from an editor's instruction file. Returns status or None."""
238
+ info = INSTRUCTION_FILES[file_key]
239
+ path = project_dir / info["path"]
240
+ marker = "## Context Engine (CCE)"
241
+
242
+ if not path.exists():
243
+ return None
244
+
245
+ content = path.read_text()
246
+ if marker not in content:
247
+ return None
248
+
249
+ # Remove the CCE block
250
+ start = content.index(marker)
251
+ # Find the next ## heading or end of file
252
+ rest = content[start + len(marker):]
253
+ next_heading = rest.find("\n## ")
254
+ if next_heading >= 0:
255
+ end = start + len(marker) + next_heading
256
+ else:
257
+ end = len(content)
258
+
259
+ new_content = (content[:start] + content[end:]).strip()
260
+ if new_content:
261
+ path.write_text(new_content + "\n")
262
+ return f"Removed CCE block from {info['name']}"
263
+ else:
264
+ path.unlink()
265
+ return f"Removed {info['name']}"