adl-toolkit 0.0.0-stage → 0.1.1

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 (104) hide show
  1. package/INSTALL_TOOLKIT.md +163 -0
  2. package/README.md +269 -2
  3. package/adl_drift/__init__.py +6 -0
  4. package/adl_drift/cli/__init__.py +1 -0
  5. package/adl_drift/cli/commands.py +993 -0
  6. package/adl_drift/cli/main.py +744 -0
  7. package/adl_drift/cortex/__init__.py +1 -0
  8. package/adl_drift/cortex/embeddings.py +96 -0
  9. package/adl_drift/cortex/memory.py +1749 -0
  10. package/adl_drift/cortex/patterns.py +89 -0
  11. package/adl_drift/cortex/telemetry.py +192 -0
  12. package/adl_drift/detectors/__init__.py +1 -0
  13. package/adl_drift/detectors/async_patterns.py +115 -0
  14. package/adl_drift/detectors/base.py +121 -0
  15. package/adl_drift/detectors/config.py +122 -0
  16. package/adl_drift/detectors/data_access.py +128 -0
  17. package/adl_drift/detectors/decorators.py +122 -0
  18. package/adl_drift/detectors/dynamic_patterns.py +260 -0
  19. package/adl_drift/detectors/errors.py +134 -0
  20. package/adl_drift/detectors/imports.py +132 -0
  21. package/adl_drift/detectors/logging_patterns.py +108 -0
  22. package/adl_drift/detectors/routes.py +257 -0
  23. package/adl_drift/detectors/security.py +236 -0
  24. package/adl_drift/detectors/taint.py +406 -0
  25. package/adl_drift/detectors/tests.py +95 -0
  26. package/adl_drift/detectors/type_conventions.py +81 -0
  27. package/adl_drift/graph/__init__.py +1 -0
  28. package/adl_drift/graph/builder.py +2670 -0
  29. package/adl_drift/graph/cfg.py +461 -0
  30. package/adl_drift/graph/cluster.py +40 -0
  31. package/adl_drift/graph/edges.py +86 -0
  32. package/adl_drift/graph/node.py +90 -0
  33. package/adl_drift/harness/__init__.py +10 -0
  34. package/adl_drift/harness/setup.py +832 -0
  35. package/adl_drift/harness/skills/drift-antigravity.md +19 -0
  36. package/adl_drift/harness/skills/drift-generic.md +19 -0
  37. package/adl_drift/harness/skills/drift.md +25 -0
  38. package/adl_drift/harness/watch.py +84 -0
  39. package/adl_drift/lang/__init__.py +8 -0
  40. package/adl_drift/lang/base.py +464 -0
  41. package/adl_drift/lang/java/__init__.py +6 -0
  42. package/adl_drift/lang/java/backend.py +97 -0
  43. package/adl_drift/lang/java/cfg.py +1424 -0
  44. package/adl_drift/lang/java/config.py +707 -0
  45. package/adl_drift/lang/java/detectors/__init__.py +29 -0
  46. package/adl_drift/lang/java/detectors/additional.py +445 -0
  47. package/adl_drift/lang/java/detectors/base.py +154 -0
  48. package/adl_drift/lang/java/detectors/data_access.py +148 -0
  49. package/adl_drift/lang/java/detectors/imports.py +53 -0
  50. package/adl_drift/lang/java/detectors/routes.py +159 -0
  51. package/adl_drift/lang/java/detectors/security.py +386 -0
  52. package/adl_drift/lang/java/detectors/tests.py +74 -0
  53. package/adl_drift/lang/java/extractor.py +582 -0
  54. package/adl_drift/lang/java/ids.py +99 -0
  55. package/adl_drift/lang/java/parser.py +49 -0
  56. package/adl_drift/lang/java/resolution/__init__.py +1 -0
  57. package/adl_drift/lang/java/resolution/constants.py +1619 -0
  58. package/adl_drift/lang/java/resolution/constructors.py +278 -0
  59. package/adl_drift/lang/java/resolution/context.py +305 -0
  60. package/adl_drift/lang/java/resolution/dispatch.py +540 -0
  61. package/adl_drift/lang/java/resolution/functional.py +1318 -0
  62. package/adl_drift/lang/java/resolution/generated.py +95 -0
  63. package/adl_drift/lang/java/resolution/model.py +153 -0
  64. package/adl_drift/lang/java/resolution/orchestration.py +866 -0
  65. package/adl_drift/lang/java/resolution/overloads.py +1275 -0
  66. package/adl_drift/lang/java/resolution/scopes.py +1111 -0
  67. package/adl_drift/lang/java/resolution/types.py +2947 -0
  68. package/adl_drift/lang/java/resolver.py +1401 -0
  69. package/adl_drift/lang/java/scopes.py +69 -0
  70. package/adl_drift/lang/java/taint.py +500 -0
  71. package/adl_drift/lang/java/type_provenance.py +342 -0
  72. package/adl_drift/lang/python/__init__.py +8 -0
  73. package/adl_drift/lang/python/backend.py +94 -0
  74. package/adl_drift/lang/python/cfg.py +537 -0
  75. package/adl_drift/lang/python/extractor.py +454 -0
  76. package/adl_drift/lang/python/parser.py +41 -0
  77. package/adl_drift/lang/python/resolver.py +1789 -0
  78. package/adl_drift/lang/python/taint.py +365 -0
  79. package/adl_drift/lang/registry.py +117 -0
  80. package/adl_drift/parser/__init__.py +1 -0
  81. package/adl_drift/parser/cache.py +38 -0
  82. package/adl_drift/parser/core.py +33 -0
  83. package/adl_drift/parser/extractor.py +17 -0
  84. package/adl_drift/parser/literal_eval.py +326 -0
  85. package/adl_drift/scanner/__init__.py +1 -0
  86. package/adl_drift/scanner/discovery.py +338 -0
  87. package/adl_drift/scanner/incremental.py +261 -0
  88. package/adl_drift/scanner/parallel.py +224 -0
  89. package/adl_drift/server/__init__.py +1 -0
  90. package/adl_drift/server/handlers.py +2849 -0
  91. package/adl_drift/server/mcp_server.py +127 -0
  92. package/adl_drift/server/mcp_stdio.py +335 -0
  93. package/adl_drift/server/protocol.py +71 -0
  94. package/adl_drift/server/static/index.html +696 -0
  95. package/adl_drift/server/web.py +350 -0
  96. package/adl_drift/utils/__init__.py +1 -0
  97. package/adl_drift/utils/config.py +77 -0
  98. package/adl_drift/utils/fswalk.py +52 -0
  99. package/adl_drift/utils/types.py +345 -0
  100. package/bin/adl-drift.mjs +35 -0
  101. package/bin/adl-toolkit.mjs +488 -0
  102. package/package.json +29 -4
  103. package/pyproject.toml +49 -0
  104. package/scripts/install_toolkit.py +3584 -0
@@ -0,0 +1,832 @@
1
+ """Auto-configuration for MCP clients + the `doctor` diagnostic.
2
+
3
+ `adl-drift mcp setup` writes the client configs that point at the stdio
4
+ server and installs the skill file that teaches the agent when to use the
5
+ tools. `adl-drift mcp doctor` spawns the real server and runs the JSON-RPC
6
+ handshake so "it doesn't work" is diagnosable in one command.
7
+
8
+ Config writes merge — existing servers/keys in a config file are preserved;
9
+ the legacy `drift` entry is migrated to `adl-drift`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import queue
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import threading
21
+ from importlib import resources
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ SERVER_NAME = "adl-drift"
26
+ LEGACY_SERVER_NAME = "drift"
27
+ SKILL_PACKAGE = "adl_drift.harness"
28
+
29
+ # Where each client looks for things, relative to the project root.
30
+ CLAUDE_MCP_CONFIG = ".mcp.json"
31
+ CLAUDE_SKILL_PATH = ".claude/skills/drift/SKILL.md"
32
+ CURSOR_MCP_CONFIG = ".cursor/mcp.json"
33
+ CURSOR_RULE_PATH = ".cursor/rules/drift.mdc"
34
+ CODEX_MCP_CONFIG = ".codex/config.toml"
35
+
36
+ # Antigravity (Google's VS Code-derived IDE) keeps MCP config under
37
+ # ~/.gemini/, with a recent migration from a legacy path to a unified path.
38
+ # We detect the migration marker at install time and sweep both paths on
39
+ # uninstall for safety. Antigravity has no project-local config concept.
40
+ ANTIGRAVITY_UNIFIED_DIR = ".gemini/config"
41
+ ANTIGRAVITY_LEGACY_DIR = ".gemini/antigravity"
42
+ ANTIGRAVITY_MCP_CONFIG = "mcp_config.json"
43
+ ANTIGRAVITY_MIGRATED_MARKER = ".migrated"
44
+
45
+ _CURSOR_RULE_FRONTMATTER = (
46
+ "---\n"
47
+ "description: Call-graph and pattern index for this repo (ADL Drift MCP tools).\n"
48
+ "alwaysApply: false\n"
49
+ "---\n\n"
50
+ )
51
+
52
+
53
+ def server_entry() -> Dict[str, Any]:
54
+ """The mcpServers entry both clients share.
55
+
56
+ `sys.executable` is the venv Python running `adl-drift mcp setup` — the
57
+ one place the right interpreter is always known. `--project .` resolves
58
+ against the client's cwd, which for project-scoped configs is the
59
+ project root, so the file stays committable across machines only if the
60
+ venv path matches; `doctor` flags a dangling interpreter either way.
61
+ """
62
+ return {
63
+ "command": sys.executable,
64
+ "args": ["-m", "adl_drift.server.mcp_stdio", "--project", "."],
65
+ }
66
+
67
+
68
+ def antigravity_server_entry(project_path: Path) -> Dict[str, Any]:
69
+ """The MCP-server entry for Antigravity IDE.
70
+
71
+ Antigravity rejects the `type: "stdio"` field that other clients accept,
72
+ so this entry omits it. Because Antigravity config is global (not
73
+ project-scoped), we pin the absolute project path so the server always
74
+ indexes the project that was configured.
75
+ """
76
+ return {
77
+ "command": sys.executable,
78
+ "args": [
79
+ "-m", "adl_drift.server.mcp_stdio",
80
+ "--project", str(Path(project_path).resolve()),
81
+ ],
82
+ }
83
+
84
+
85
+ def codex_server_entry(project_path: Path) -> Dict[str, Any]:
86
+ """Project-scoped MCP entry for Codex CLI and its VS Code extension."""
87
+ return {
88
+ "command": sys.executable,
89
+ "args": [
90
+ "-m", "adl_drift.server.mcp_stdio",
91
+ "--project", str(Path(project_path).resolve()),
92
+ ],
93
+ }
94
+
95
+
96
+ def _codex_table(entry: Dict[str, Any]) -> str:
97
+ return (
98
+ "[mcp_servers.adl-drift]\n"
99
+ f"command = {json.dumps(entry['command'])}\n"
100
+ f"args = {json.dumps(entry['args'])}\n"
101
+ )
102
+
103
+
104
+ def _replace_codex_table(text: str, replacement: str = "") -> str:
105
+ """Replace ADL Drift and legacy TOML tables, preserving other settings."""
106
+ return re.sub(
107
+ r"(?ms)^\[mcp_servers\.(?:adl-drift|drift)(?:\.[^\]]+)?\]\n.*?"
108
+ r"(?=^\[(?!mcp_servers\.(?:adl-drift|drift)(?:\.|\]))|\Z)",
109
+ replacement,
110
+ text,
111
+ )
112
+
113
+
114
+ def _merge_codex_config(config_path: Path, project_path: Path) -> tuple[str, str]:
115
+ existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
116
+ updated = _replace_codex_table(existing).rstrip()
117
+ table = _codex_table(codex_server_entry(project_path))
118
+ updated = f"{updated}\n\n{table}" if updated else table
119
+ action = (
120
+ "created" if not config_path.exists()
121
+ else "unchanged" if existing == updated else "updated"
122
+ )
123
+ return updated, action
124
+
125
+
126
+ def _remove_codex_entry(config_path: Path) -> Optional[Dict[str, str]]:
127
+ if not config_path.exists():
128
+ return None
129
+ existing = config_path.read_text(encoding="utf-8")
130
+ updated = _replace_codex_table(existing).strip()
131
+ if updated == existing.strip():
132
+ return None
133
+ if updated:
134
+ config_path.write_text(updated + "\n", encoding="utf-8")
135
+ else:
136
+ config_path.unlink()
137
+ return {"path": CODEX_MCP_CONFIG, "action": "removed"}
138
+
139
+
140
+ def _codex_config_entry(config_path: Path) -> Optional[Dict[str, Any]]:
141
+ if not config_path.exists():
142
+ return None
143
+ text = config_path.read_text(encoding="utf-8")
144
+ match = None
145
+ for name in (SERVER_NAME, LEGACY_SERVER_NAME):
146
+ match = re.search(
147
+ rf"(?ms)^\[mcp_servers\.{re.escape(name)}\]\n(.*?)"
148
+ rf"(?=^\[(?!mcp_servers\.{re.escape(name)}(?:\.|\]))|\Z)",
149
+ text,
150
+ )
151
+ if match:
152
+ break
153
+ if match is None:
154
+ return None
155
+ values = dict(re.findall(r"(?m)^(command|args)\s*=\s*(.+)$", match.group(1)))
156
+ try:
157
+ return {"command": json.loads(values["command"]), "args": json.loads(values["args"])}
158
+ except (KeyError, json.JSONDecodeError):
159
+ return None
160
+
161
+
162
+ def _antigravity_paths(home: Optional[Path] = None) -> Dict[str, Path]:
163
+ home = home or Path.home()
164
+ unified_dir = home / ANTIGRAVITY_UNIFIED_DIR
165
+ legacy_dir = home / ANTIGRAVITY_LEGACY_DIR
166
+ return {
167
+ "unified_dir": unified_dir,
168
+ "unified_file": unified_dir / ANTIGRAVITY_MCP_CONFIG,
169
+ "legacy_dir": legacy_dir,
170
+ "legacy_file": legacy_dir / ANTIGRAVITY_MCP_CONFIG,
171
+ "migrated_marker": unified_dir / ANTIGRAVITY_MIGRATED_MARKER,
172
+ }
173
+
174
+
175
+ def _antigravity_preferred_config_path(home: Optional[Path] = None) -> Path:
176
+ """Return the Antigravity MCP config path to write to.
177
+
178
+ Prefer the unified path when Antigravity has signalled migration (marker
179
+ file present) or when the unified file already exists; otherwise fall
180
+ back to the legacy path.
181
+ """
182
+ paths = _antigravity_paths(home)
183
+ if paths["migrated_marker"].exists() or paths["unified_file"].exists():
184
+ return paths["unified_file"]
185
+ return paths["legacy_file"]
186
+
187
+
188
+ def read_skill(name: str = "drift.md") -> str:
189
+ return (resources.files(SKILL_PACKAGE) / "skills" / name).read_text(encoding="utf-8")
190
+
191
+
192
+ # AGENTS.md marker-fenced section installed for Codex and Antigravity. The
193
+ # fenced block lets setup merge updates without overwriting hand-written
194
+ # content, and lets uninstall remove only the workflow that ADL Drift owns.
195
+ AGENTS_MD_PATH = "AGENTS.md"
196
+ ANTIGRAVITY_RULE_PATH = ".agents/rules/drift.md"
197
+ AGENTS_MD_MARKER_START = "<!-- BEGIN DRIFT WORKFLOW -->"
198
+ AGENTS_MD_MARKER_END = "<!-- END DRIFT WORKFLOW -->"
199
+ _LEGACY_AGENTS_MD_MARKER_START = "<!-- BEGIN DRIFT ANTIGRAVITY WORKFLOW -->"
200
+ _LEGACY_AGENTS_MD_MARKER_END = "<!-- END DRIFT ANTIGRAVITY WORKFLOW -->"
201
+
202
+
203
+ def _agents_workflow_section() -> str:
204
+ """Return the marker-fenced ADL Drift workflow section for AGENTS.md."""
205
+ body = read_skill("drift-antigravity.md")
206
+ return (
207
+ f"{AGENTS_MD_MARKER_START}\n"
208
+ f"{body}\n"
209
+ f"{AGENTS_MD_MARKER_END}\n"
210
+ )
211
+
212
+
213
+ def _antigravity_configured_for_project(project_path: Path) -> bool:
214
+ """Return whether Antigravity has an ADL Drift entry for this project."""
215
+ expected_args = antigravity_server_entry(project_path)["args"]
216
+ paths = _antigravity_paths()
217
+ for config_path in (paths["unified_file"], paths["legacy_file"]):
218
+ if not config_path.exists():
219
+ continue
220
+ try:
221
+ config = json.loads(config_path.read_text(encoding="utf-8"))
222
+ except (json.JSONDecodeError, OSError):
223
+ continue
224
+ if not isinstance(config, dict):
225
+ continue
226
+ servers = config.get("mcpServers", {})
227
+ if isinstance(servers, dict) and any(
228
+ isinstance(servers.get(name), dict)
229
+ and servers[name].get("args") == expected_args
230
+ for name in (SERVER_NAME, LEGACY_SERVER_NAME)
231
+ ):
232
+ return True
233
+ return False
234
+
235
+
236
+ def _agents_md_block(existing: str) -> tuple[int, int, str] | None:
237
+ """Find the current or legacy ADL Drift workflow fence in ``existing``."""
238
+ for start, end in (
239
+ (AGENTS_MD_MARKER_START, AGENTS_MD_MARKER_END),
240
+ (_LEGACY_AGENTS_MD_MARKER_START, _LEGACY_AGENTS_MD_MARKER_END),
241
+ ):
242
+ start_idx = existing.find(start)
243
+ end_idx = existing.find(end)
244
+ if start_idx != -1 and end_idx > start_idx:
245
+ return start_idx, end_idx + len(end), start
246
+ return None
247
+
248
+
249
+ def _merge_agents_md(project_path: Path, section: str) -> tuple[str, str]:
250
+ """Return (merged content, action) for AGENTS.md without writing.
251
+
252
+ The section is treated as an opaque block bounded by the marker comments.
253
+ Re-running setup with the same section produces an unchanged action.
254
+ """
255
+ path = project_path / AGENTS_MD_PATH
256
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
257
+
258
+ block = _agents_md_block(existing)
259
+ if block:
260
+ start_idx, end_idx, _ = block
261
+ before = existing[:start_idx].rstrip("\n")
262
+ after = existing[end_idx:].lstrip("\n")
263
+ merged = ""
264
+ if before:
265
+ merged += before + "\n\n"
266
+ merged += section
267
+ if after:
268
+ merged += "\n" + after + "\n"
269
+ action = "updated" if merged != existing else "unchanged"
270
+ elif not existing:
271
+ merged = f"# Project Agents\n\n{section}"
272
+ action = "created"
273
+ else:
274
+ merged = existing.rstrip("\n") + "\n\n" + section
275
+ action = "updated"
276
+ return merged, action
277
+
278
+
279
+ def detect_clients(project_path: Path) -> List[str]:
280
+ """Config-dir probing: project dirs first, then home dirs."""
281
+ found = []
282
+ home = Path.home()
283
+ if (project_path / ".claude").is_dir() or (home / ".claude").is_dir():
284
+ found.append("claude")
285
+ if (project_path / ".cursor").is_dir() or (home / ".cursor").is_dir():
286
+ found.append("cursor")
287
+ if (project_path / CODEX_MCP_CONFIG).exists():
288
+ found.append("codex")
289
+ paths = _antigravity_paths(home)
290
+ if (
291
+ paths["unified_dir"].is_dir()
292
+ or paths["legacy_dir"].is_dir()
293
+ or paths["unified_file"].exists()
294
+ or paths["legacy_file"].exists()
295
+ ):
296
+ found.append("antigravity")
297
+ return found
298
+
299
+
300
+ def _load_config_json(config_path: Path) -> Dict[str, Any]:
301
+ """Load a JSON config file, treating an empty/whitespace file as `{}`."""
302
+ if not config_path.exists():
303
+ return {}
304
+ text = config_path.read_text(encoding="utf-8").strip()
305
+ if not text:
306
+ return {}
307
+ try:
308
+ config = json.loads(text)
309
+ except json.JSONDecodeError as e:
310
+ raise ValueError(f"{config_path} is not valid JSON ({e}); fix or remove it") from e
311
+ if not isinstance(config, dict):
312
+ raise ValueError(f"{config_path} top level must be a JSON object")
313
+ return config
314
+
315
+
316
+ def _merge_server_config(config_path: Path) -> tuple[Dict[str, Any], str]:
317
+ """Return (merged config, action) without writing."""
318
+ config = _load_config_json(config_path)
319
+
320
+ servers = config.setdefault("mcpServers", {})
321
+ entry = server_entry()
322
+ legacy_removed = servers.pop(LEGACY_SERVER_NAME, None) is not None
323
+ if servers.get(SERVER_NAME) == entry and not legacy_removed:
324
+ return config, "unchanged"
325
+ action = "updated" if config_path.exists() else "created"
326
+ servers[SERVER_NAME] = entry
327
+ return config, action
328
+
329
+
330
+ def _merge_antigravity_config(
331
+ config_path: Path, project_path: Path
332
+ ) -> tuple[Dict[str, Any], str]:
333
+ """Merge the ADL Drift entry into an Antigravity `mcp_config.json`.
334
+
335
+ Antigravity's top-level key is also `mcpServers`, but the entry omits
336
+ the `type` field used by Claude/Cursor. Existing keys other than the
337
+ ADL Drift entry are preserved. Empty/whitespace files are treated as `{}`.
338
+ """
339
+ config = _load_config_json(config_path)
340
+
341
+ servers = config.setdefault("mcpServers", {})
342
+ entry = antigravity_server_entry(project_path)
343
+ legacy_removed = servers.pop(LEGACY_SERVER_NAME, None) is not None
344
+ if servers.get(SERVER_NAME) == entry and not legacy_removed:
345
+ return config, "unchanged"
346
+ action = "updated" if config_path.exists() else "created"
347
+ servers[SERVER_NAME] = entry
348
+ return config, action
349
+
350
+
351
+ def _plan_file(path: Path, content: str) -> str:
352
+ if not path.exists():
353
+ return "created"
354
+ return "unchanged" if path.read_text(encoding="utf-8") == content else "updated"
355
+
356
+
357
+ def _remove_antigravity_entry(config_path: Path, unlink_empty: bool = False) -> Optional[Dict[str, str]]:
358
+ """Remove ADL Drift entries from an Antigravity config file.
359
+
360
+ Returns an action record if the file existed and contained a drift entry,
361
+ otherwise None. Empty `mcpServers` objects are pruned. By default the file
362
+ is left in place (Antigravity manages it); pass `unlink_empty=True` to
363
+ delete the file when it becomes empty.
364
+ """
365
+ if not config_path.exists():
366
+ return None
367
+ try:
368
+ config = json.loads(config_path.read_text(encoding="utf-8"))
369
+ except (json.JSONDecodeError, ValueError):
370
+ return None
371
+ if not isinstance(config, dict):
372
+ return None
373
+ servers = config.get("mcpServers", {})
374
+ names = {SERVER_NAME, LEGACY_SERVER_NAME} & set(servers) if isinstance(servers, dict) else set()
375
+ if not names:
376
+ return None
377
+ for name in names:
378
+ del servers[name]
379
+ if not servers:
380
+ config.pop("mcpServers", None)
381
+ if unlink_empty and not config:
382
+ config_path.unlink()
383
+ else:
384
+ config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
385
+ return {"path": str(config_path), "action": "removed"}
386
+
387
+
388
+ def setup(
389
+ project_path: Path,
390
+ claude: bool = False,
391
+ cursor: bool = False,
392
+ antigravity: bool = False,
393
+ codex: bool = False,
394
+ dry_run: bool = False,
395
+ ) -> List[Dict[str, str]]:
396
+ """Configure MCP clients. No flags = configure every detected client
397
+ (generic .mcp.json if none detected). Returns one action record per
398
+ file: {path, action: created|updated|unchanged|removed}."""
399
+ project_path = Path(project_path).resolve()
400
+ if not claude and not cursor and not antigravity and not codex:
401
+ detected = detect_clients(project_path)
402
+ claude = "claude" in detected
403
+ cursor = "cursor" in detected
404
+ antigravity = "antigravity" in detected
405
+ codex = True
406
+ if not detected:
407
+ claude = True # generic fallback: .mcp.json is the open format
408
+
409
+ actions: List[Dict[str, str]] = []
410
+
411
+ def _write_json(rel: str) -> None:
412
+ path = project_path / rel
413
+ config, action = _merge_server_config(path)
414
+ actions.append({"path": rel, "action": action})
415
+ if not dry_run and action != "unchanged":
416
+ path.parent.mkdir(parents=True, exist_ok=True)
417
+ path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
418
+
419
+ def _write_text(rel: str, content: str) -> None:
420
+ path = project_path / rel
421
+ action = _plan_file(path, content)
422
+ actions.append({"path": rel, "action": action})
423
+ if not dry_run and action != "unchanged":
424
+ path.parent.mkdir(parents=True, exist_ok=True)
425
+ path.write_text(content, encoding="utf-8")
426
+
427
+ if claude:
428
+ _write_json(CLAUDE_MCP_CONFIG)
429
+ _write_text(CLAUDE_SKILL_PATH, read_skill("drift.md"))
430
+ if cursor:
431
+ _write_json(CURSOR_MCP_CONFIG)
432
+ _write_text(CURSOR_RULE_PATH, _CURSOR_RULE_FRONTMATTER + read_skill("drift-generic.md"))
433
+ if codex:
434
+ config_path = project_path / CODEX_MCP_CONFIG
435
+ config, _ = _merge_codex_config(config_path, project_path)
436
+ _write_text(CODEX_MCP_CONFIG, config)
437
+ if codex or antigravity:
438
+ agents_content, agents_action = _merge_agents_md(
439
+ project_path, _agents_workflow_section()
440
+ )
441
+ actions.append({"path": AGENTS_MD_PATH, "action": agents_action})
442
+ if not dry_run and agents_action != "unchanged":
443
+ (project_path / AGENTS_MD_PATH).write_text(agents_content, encoding="utf-8")
444
+ if antigravity:
445
+ preferred = _antigravity_preferred_config_path()
446
+ if dry_run:
447
+ # Report what would happen without touching disk.
448
+ actions.append({"path": str(preferred), "action": "created"})
449
+ actions.append({"path": ANTIGRAVITY_RULE_PATH, "action": "created"})
450
+ else:
451
+ config, action = _merge_antigravity_config(preferred, project_path)
452
+ preferred.parent.mkdir(parents=True, exist_ok=True)
453
+ preferred.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
454
+ actions.append({"path": str(preferred), "action": action})
455
+
456
+ # If the user originally installed on the legacy path and Antigravity
457
+ # has since migrated, strip the stale legacy entry so they don't wind
458
+ # up with two competing ADL Drift configs.
459
+ paths = _antigravity_paths()
460
+ if preferred == paths["unified_file"] and paths["legacy_file"].exists():
461
+ removed = _remove_antigravity_entry(paths["legacy_file"])
462
+ if removed:
463
+ actions.append(removed)
464
+
465
+ # Native Antigravity workspace rules are evaluated for every
466
+ # prompt; AGENTS.md remains installed for compatibility.
467
+ _write_text(ANTIGRAVITY_RULE_PATH, read_skill("drift-antigravity.md"))
468
+
469
+ return actions
470
+
471
+
472
+ # ---------------------------------------------------------------- uninstall
473
+
474
+
475
+ def _remove_server_entry(config_path: Path) -> Optional[Dict[str, str]]:
476
+ """Remove ADL Drift entries from a Claude/Cursor style config file.
477
+
478
+ Returns an action record if the file existed and contained a drift entry,
479
+ otherwise None. Empty `mcpServers` objects are pruned; if the whole file
480
+ becomes empty it is removed.
481
+ """
482
+ if not config_path.exists():
483
+ return None
484
+ try:
485
+ config = json.loads(config_path.read_text(encoding="utf-8"))
486
+ except (json.JSONDecodeError, ValueError):
487
+ return None
488
+ if not isinstance(config, dict):
489
+ return None
490
+ servers = config.get("mcpServers", {})
491
+ names = {SERVER_NAME, LEGACY_SERVER_NAME} & set(servers) if isinstance(servers, dict) else set()
492
+ if not names:
493
+ return None
494
+ for name in names:
495
+ del servers[name]
496
+ if not servers:
497
+ config.pop("mcpServers", None)
498
+ if not config:
499
+ config_path.unlink()
500
+ else:
501
+ config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
502
+ return {"path": str(config_path), "action": "removed"}
503
+
504
+
505
+ def _remove_text_file_if_matches(path: Path, expected_content: str) -> Optional[Dict[str, str]]:
506
+ """Remove a skill/rule file if its current content exactly matches ours."""
507
+ if not path.exists():
508
+ return None
509
+ try:
510
+ current = path.read_text(encoding="utf-8")
511
+ except OSError:
512
+ return None
513
+ if current != expected_content:
514
+ return {"path": str(path), "action": "unchanged"}
515
+ path.unlink()
516
+ return {"path": str(path), "action": "removed"}
517
+
518
+
519
+ def _remove_agents_md_section(project_path: Path) -> Optional[Dict[str, str]]:
520
+ """Remove the marker-fenced ADL Drift section from AGENTS.md.
521
+
522
+ Returns an action record if AGENTS.md existed and contained the section.
523
+ If removing the section leaves only whitespace or the generic header we
524
+ created, the whole file is removed.
525
+ """
526
+ path = project_path / AGENTS_MD_PATH
527
+ if not path.exists():
528
+ return None
529
+ existing = path.read_text(encoding="utf-8")
530
+ block = _agents_md_block(existing)
531
+ if not block:
532
+ return {"path": AGENTS_MD_PATH, "action": "unchanged"}
533
+ start_idx, end_idx, _ = block
534
+ before = existing[:start_idx].rstrip("\n")
535
+ after = existing[end_idx:].lstrip("\n")
536
+ merged = ""
537
+ if before:
538
+ merged += before + "\n"
539
+ if after:
540
+ merged += after + "\n"
541
+ remaining = merged.strip()
542
+ # If the file only contains our generic header (or nothing), remove it.
543
+ if not remaining or remaining == "# Project Agents":
544
+ path.unlink()
545
+ else:
546
+ path.write_text(merged, encoding="utf-8")
547
+ return {"path": AGENTS_MD_PATH, "action": "removed"}
548
+
549
+
550
+ def uninstall(
551
+ project_path: Path,
552
+ claude: bool = False,
553
+ cursor: bool = False,
554
+ antigravity: bool = False,
555
+ codex: bool = False,
556
+ dry_run: bool = False,
557
+ ) -> List[Dict[str, str]]:
558
+ """Remove ADL Drift MCP client configuration. No flags = uninstall every
559
+ detected client. Returns one action record per touched file."""
560
+ project_path = Path(project_path).resolve()
561
+ if not claude and not cursor and not antigravity and not codex:
562
+ detected = detect_clients(project_path)
563
+ claude = "claude" in detected
564
+ cursor = "cursor" in detected
565
+ antigravity = "antigravity" in detected
566
+ codex = "codex" in detected
567
+ if not detected:
568
+ claude = True # generic fallback
569
+
570
+ actions: List[Dict[str, str]] = []
571
+ remove_agents_workflow = (codex or antigravity) and not (
572
+ (not codex and _codex_config_entry(project_path / CODEX_MCP_CONFIG))
573
+ or (not antigravity and _antigravity_configured_for_project(project_path))
574
+ )
575
+
576
+ if claude:
577
+ if dry_run:
578
+ actions.append({"path": CLAUDE_MCP_CONFIG, "action": "removed"})
579
+ actions.append({"path": CLAUDE_SKILL_PATH, "action": "removed"})
580
+ else:
581
+ removed = _remove_server_entry(project_path / CLAUDE_MCP_CONFIG)
582
+ if removed:
583
+ removed["path"] = CLAUDE_MCP_CONFIG
584
+ actions.append(removed)
585
+ removed = _remove_text_file_if_matches(
586
+ project_path / CLAUDE_SKILL_PATH, read_skill("drift.md")
587
+ )
588
+ if removed:
589
+ removed["path"] = CLAUDE_SKILL_PATH
590
+ actions.append(removed)
591
+
592
+ if cursor:
593
+ if dry_run:
594
+ actions.append({"path": CURSOR_MCP_CONFIG, "action": "removed"})
595
+ actions.append({"path": CURSOR_RULE_PATH, "action": "removed"})
596
+ else:
597
+ removed = _remove_server_entry(project_path / CURSOR_MCP_CONFIG)
598
+ if removed:
599
+ removed["path"] = CURSOR_MCP_CONFIG
600
+ actions.append(removed)
601
+ removed = _remove_text_file_if_matches(
602
+ project_path / CURSOR_RULE_PATH,
603
+ _CURSOR_RULE_FRONTMATTER + read_skill("drift-generic.md"),
604
+ )
605
+ if removed:
606
+ removed["path"] = CURSOR_RULE_PATH
607
+ actions.append(removed)
608
+
609
+ if codex:
610
+ if dry_run:
611
+ actions.append({"path": CODEX_MCP_CONFIG, "action": "removed"})
612
+ else:
613
+ removed = _remove_codex_entry(project_path / CODEX_MCP_CONFIG)
614
+ if removed:
615
+ actions.append(removed)
616
+
617
+ if remove_agents_workflow:
618
+ if dry_run:
619
+ actions.append({"path": AGENTS_MD_PATH, "action": "removed"})
620
+ else:
621
+ removed = _remove_agents_md_section(project_path)
622
+ if removed:
623
+ actions.append(removed)
624
+
625
+ if antigravity:
626
+ paths = _antigravity_paths()
627
+ preferred = _antigravity_preferred_config_path()
628
+ if dry_run:
629
+ actions.append({"path": str(preferred), "action": "removed"})
630
+ actions.append({"path": ANTIGRAVITY_RULE_PATH, "action": "removed"})
631
+ else:
632
+ for config_path in (paths["unified_file"], paths["legacy_file"]):
633
+ removed = _remove_antigravity_entry(config_path, unlink_empty=True)
634
+ if removed:
635
+ actions.append(removed)
636
+ removed = _remove_text_file_if_matches(
637
+ project_path / ANTIGRAVITY_RULE_PATH,
638
+ read_skill("drift-antigravity.md"),
639
+ )
640
+ if removed:
641
+ removed["path"] = ANTIGRAVITY_RULE_PATH
642
+ actions.append(removed)
643
+
644
+ return actions
645
+
646
+
647
+ # ---------------------------------------------------------------- doctor
648
+
649
+
650
+ def _configured_entry(project_path: Path) -> Optional[Dict[str, Any]]:
651
+ """The ADL Drift entry from a supported project or Antigravity config, if any."""
652
+ for rel in (CLAUDE_MCP_CONFIG, CURSOR_MCP_CONFIG):
653
+ path = project_path / rel
654
+ if not path.exists():
655
+ continue
656
+ try:
657
+ servers = json.loads(path.read_text(encoding="utf-8")).get("mcpServers", {})
658
+ except (json.JSONDecodeError, AttributeError):
659
+ continue
660
+ for name in (SERVER_NAME, LEGACY_SERVER_NAME):
661
+ if name in servers:
662
+ return servers[name]
663
+ codex_entry = _codex_config_entry(project_path / CODEX_MCP_CONFIG)
664
+ if codex_entry:
665
+ return codex_entry
666
+ # Antigravity config lives in the home dir, not the project.
667
+ antigravity_path = _antigravity_preferred_config_path()
668
+ if antigravity_path.exists():
669
+ try:
670
+ servers = json.loads(antigravity_path.read_text(encoding="utf-8")).get("mcpServers", {})
671
+ except (json.JSONDecodeError, AttributeError):
672
+ servers = {}
673
+ for name in (SERVER_NAME, LEGACY_SERVER_NAME):
674
+ if name in servers:
675
+ return servers[name]
676
+ return None
677
+
678
+
679
+ def _handshake(command: List[str], cwd: Path, timeout: float) -> Dict[str, Any]:
680
+ """Spawn the stdio server, run initialize + tools/list, return findings."""
681
+ # A file drains startup tracebacks without blocking the server's stderr pipe.
682
+ with tempfile.TemporaryFile() as stderr:
683
+ proc = subprocess.Popen(
684
+ command, cwd=cwd, text=True, encoding="utf-8", errors="replace",
685
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr,
686
+ )
687
+
688
+ def send(msg: Dict[str, Any]) -> None:
689
+ assert proc.stdin is not None
690
+ proc.stdin.write(json.dumps(msg) + "\n")
691
+ proc.stdin.flush()
692
+
693
+ def recv(want_id: int) -> Dict[str, Any]:
694
+ assert proc.stdout is not None
695
+ while True:
696
+ line = proc.stdout.readline()
697
+ if not line:
698
+ raise RuntimeError("server closed stdout before responding")
699
+ try:
700
+ msg = json.loads(line)
701
+ except json.JSONDecodeError:
702
+ raise RuntimeError(f"non-JSON on stdout (framing corruption): {line[:120]!r}")
703
+ if not isinstance(msg, dict):
704
+ raise TypeError("invalid JSON-RPC response: expected an object")
705
+ if msg.get("id") == want_id:
706
+ if "error" in msg:
707
+ raise RuntimeError(f"JSON-RPC request {want_id} failed: {msg['error']}")
708
+ if not isinstance(msg.get("result"), dict):
709
+ raise RuntimeError(f"JSON-RPC request {want_id} returned an invalid result")
710
+ return msg
711
+
712
+ responses: queue.Queue[tuple[dict[str, Any] | None, Exception | None]] = queue.Queue()
713
+
714
+ def exchange() -> None:
715
+ try:
716
+ send({
717
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
718
+ "params": {
719
+ "protocolVersion": "2024-11-05", "capabilities": {},
720
+ "clientInfo": {"name": "adl-drift-doctor", "version": "1.0"},
721
+ },
722
+ })
723
+ init = recv(1)["result"]
724
+ info = init.get("serverInfo")
725
+ if not isinstance(info, dict) or info.get("name") != SERVER_NAME:
726
+ raise RuntimeError("initialize did not identify the adl-drift server")
727
+ send({"jsonrpc": "2.0", "method": "notifications/initialized"})
728
+ send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
729
+ tools = recv(2)["result"].get("tools")
730
+ if (not isinstance(tools, list) or not tools
731
+ or any(not isinstance(t, dict) or not isinstance(t.get("name"), str) for t in tools)):
732
+ raise RuntimeError("tools/list returned no valid tools")
733
+ responses.put(({"server_info": info, "tools": [t["name"] for t in tools]}, None))
734
+ except (RuntimeError, OSError, ValueError, TypeError, KeyError) as error:
735
+ responses.put((None, error))
736
+
737
+ worker = threading.Thread(target=exchange, daemon=True)
738
+ try:
739
+ worker.start()
740
+ try:
741
+ result, error = responses.get(timeout=timeout)
742
+ except queue.Empty:
743
+ result, error = None, RuntimeError(f"MCP handshake timed out after {timeout:g}s")
744
+ if error is not None and proc.poll() is None:
745
+ proc.kill()
746
+ worker.join(timeout=1)
747
+ if proc.stdin is not None:
748
+ try:
749
+ proc.stdin.close()
750
+ except OSError:
751
+ pass
752
+ try:
753
+ proc.wait(timeout=1)
754
+ except subprocess.TimeoutExpired:
755
+ proc.kill()
756
+ proc.wait()
757
+ if error is not None:
758
+ stderr.seek(0, 2)
759
+ stderr.seek(max(0, stderr.tell() - 4096))
760
+ detail = stderr.read().decode("utf-8", errors="replace").strip()
761
+ raise RuntimeError(str(error) + (f"\nServer stderr:\n{detail}" if detail else "")) from error
762
+ assert result is not None
763
+ return result
764
+ finally:
765
+ if proc.poll() is None:
766
+ proc.kill()
767
+ proc.wait()
768
+ worker.join(timeout=1)
769
+ if proc.stdin is not None:
770
+ try:
771
+ proc.stdin.close()
772
+ except OSError:
773
+ pass
774
+ if proc.stdout is not None:
775
+ proc.stdout.close()
776
+
777
+
778
+ def doctor(project_path: Path, timeout: float = 30.0) -> Dict[str, Any]:
779
+ """Verify the wiring end-to-end. Returns {ok, checks: [...]}; `ok`
780
+ covers the required chain (interpreter → SDK → live handshake);
781
+ config/skill presence is reported but only required for client pickup."""
782
+ project_path = Path(project_path).resolve()
783
+ checks: List[Dict[str, Any]] = []
784
+
785
+ def check(name: str, ok: bool, detail: str, required: bool = True) -> None:
786
+ checks.append({"name": name, "ok": ok, "detail": detail, "required": required})
787
+
788
+ entry = _configured_entry(project_path)
789
+ if entry is None:
790
+ check("config", False,
791
+ "no adl-drift entry in .mcp.json, .cursor/mcp.json, .codex/config.toml, or Antigravity config — run `adl-drift mcp setup`",
792
+ required=False)
793
+ command = [sys.executable, "-m", "adl_drift.server.mcp_stdio", "--project", "."]
794
+ else:
795
+ check("config", True, f"adl-drift entry found (command: {entry.get('command')})",
796
+ required=False)
797
+ command = [entry["command"], *entry.get("args", [])]
798
+
799
+ interpreter = Path(command[0])
800
+ if interpreter.exists():
801
+ check("interpreter", True, str(interpreter))
802
+ else:
803
+ check("interpreter", False, f"{interpreter} does not exist — re-run `adl-drift mcp setup`")
804
+
805
+ probe = subprocess.run(
806
+ [command[0], "-c", "import mcp"], capture_output=True, text=True,
807
+ ) if interpreter.exists() else None
808
+ if probe is not None and probe.returncode == 0:
809
+ check("mcp-sdk", True, "`import mcp` ok in the configured interpreter")
810
+ else:
811
+ detail = probe.stderr.strip().splitlines()[-1] if probe and probe.stderr else "interpreter missing"
812
+ check("mcp-sdk", probe is not None and probe.returncode == 0, detail)
813
+
814
+ if all(c["ok"] for c in checks if c["required"]):
815
+ try:
816
+ result = _handshake(command, cwd=project_path, timeout=timeout)
817
+ names = result["tools"]
818
+ check("handshake", True,
819
+ f"{result['server_info'].get('name')} {result['server_info'].get('version')} "
820
+ f"— {len(names)} tools: {', '.join(names)}")
821
+ except (RuntimeError, OSError, KeyError, subprocess.SubprocessError) as e:
822
+ check("handshake", False, str(e))
823
+ else:
824
+ check("handshake", False, "skipped — fix the checks above first")
825
+
826
+ skill = project_path / CLAUDE_SKILL_PATH
827
+ check("skill", skill.exists(),
828
+ str(skill) if skill.exists()
829
+ else "no skill installed (fine for MCP-only mode; `mcp setup --claude` adds it)",
830
+ required=False)
831
+
832
+ return {"ok": all(c["ok"] for c in checks if c["required"]), "checks": checks}