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,3584 @@
1
+ #!/usr/bin/env python3
2
+ """Install one graph tool and optional agent skills into a project."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import base64
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import queue
12
+ import re
13
+ import shlex
14
+ import shutil
15
+ import signal
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ import tempfile
20
+ import time
21
+ from urllib.request import Request, urlopen
22
+ from collections import deque
23
+ from dataclasses import dataclass, replace
24
+ from http.client import HTTPException
25
+ from pathlib import Path, PureWindowsPath
26
+ from typing import Callable, Sequence
27
+
28
+
29
+ REPO_ROOT = Path(__file__).resolve().parent.parent
30
+ CODEGRAPH_PACKAGE = "@colbymchenry/codegraph@1.6.1"
31
+ GITNEXUS_PACKAGE = "gitnexus@1.6.12"
32
+ GRAPHIFY_PACKAGE = "graphifyy[mcp,sql,pdf,openai]==0.9.80"
33
+ SKILLS_PACKAGE = "skills@1.7.0"
34
+ UV_VERSION = "0.12.24"
35
+ ADDON_SOURCES = {
36
+ "caveman": "JuliusBrussee/caveman",
37
+ "ponytail": "DietrichGebert/ponytail",
38
+ }
39
+ SKILLS_AGENT = {
40
+ "codex": "codex", "claude": "claude-code", "cursor": "cursor",
41
+ "antigravity": "antigravity",
42
+ }
43
+ CLIENTS = ("codex", "claude", "cursor", "antigravity")
44
+ GRAPHS = ("adl-drift", "codegraph", "gitnexus", "graphify", "other")
45
+ EXTRAS = ("http", "embeddings")
46
+ MANIFEST_VERSION = 1
47
+ MANIFEST_PATH = Path(".adl-toolkit/install.json")
48
+ MANAGED_FILES = (
49
+ Path(".mcp.json"), Path(".codex/config.toml"), Path(".cursor/mcp.json"),
50
+ Path("AGENTS.md"), Path("skills-lock.json"),
51
+ )
52
+ MANAGED_ROOTS = (
53
+ Path(".agents/skills"), Path(".claude/skills"), Path(".cursor/skills"),
54
+ Path(".cursor/rules"), Path(".agents/rules"),
55
+ )
56
+ ANTIGRAVITY_CONFIGS = {
57
+ "unified": Path(".gemini/config/mcp_config.json"),
58
+ "legacy": Path(".gemini/antigravity/mcp_config.json"),
59
+ }
60
+ ANTIGRAVITY_MIGRATED_MARKER = Path(".gemini/config/.migrated")
61
+ RUNTIME_PATHS = {
62
+ "adl-drift": Path(".adl-toolkit/venv"),
63
+ "other": Path(".adl-toolkit/venv"),
64
+ "codegraph": Path(".adl-toolkit/codegraph"),
65
+ "gitnexus": Path(".adl-toolkit/gitnexus"),
66
+ "graphify": Path(".adl-toolkit/venv"),
67
+ }
68
+ INDEX_PATHS = {
69
+ "adl-drift": (Path(".adl-drift"), Path(".drift")),
70
+ "codegraph": (Path(".codegraph"),),
71
+ "gitnexus": (Path(".gitnexus"),),
72
+ "graphify": (Path("graphify-out"),),
73
+ "other": (),
74
+ }
75
+ INTEGRATION_INSTRUCTION_PATHS = {
76
+ "codex": Path("AGENTS.md"),
77
+ "claude": Path("CLAUDE.md"),
78
+ "cursor": Path(".cursor/rules/adl-toolkit-integrations.mdc"),
79
+ "antigravity": Path("AGENTS.md"),
80
+ }
81
+ INTEGRATION_SKILL_ROOTS = {
82
+ "codex": Path(".agents/skills"),
83
+ "claude": Path(".claude/skills"),
84
+ "cursor": Path(".cursor/skills"),
85
+ "antigravity": Path(".agents/skills"),
86
+ }
87
+ INTEGRATION_BEGIN = "<!-- BEGIN ADL TOOLKIT INTEGRATIONS -->"
88
+ INTEGRATION_END = "<!-- END ADL TOOLKIT INTEGRATIONS -->"
89
+ CLIENT_CONFIG_PATHS = {
90
+ "codex": Path(".codex/config.toml"),
91
+ "claude": Path(".mcp.json"),
92
+ "cursor": Path(".cursor/mcp.json"),
93
+ }
94
+
95
+
96
+ class InstallError(RuntimeError):
97
+ """An installation step failed."""
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Options:
102
+ graph: str
103
+ project: Path
104
+ client: str = "codex"
105
+ addons: tuple[str, ...] = ()
106
+ extras: tuple[str, ...] = ()
107
+ package: str | None = None
108
+ index: bool = True
109
+ dry_run: bool = False
110
+ clients: tuple[str, ...] = ()
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class UninstallOptions:
115
+ project: Path
116
+ graph: str | None = None
117
+ client: str | None = None
118
+ addons: tuple[str, ...] = ()
119
+ yes: bool = False
120
+ dry_run: bool = False
121
+ purge_index: bool = False
122
+ remove_clients: tuple[str, ...] = ()
123
+ remove_addons: tuple[str, ...] = ()
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Step:
128
+ label: str
129
+ command: tuple[str, ...]
130
+ cwd: Path
131
+ timeout: float = 900
132
+ owner: str = "graph"
133
+ quiet: bool = False
134
+ client: str | None = None
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class HealthCheck:
139
+ category: str
140
+ name: str
141
+ state: str
142
+ detail: str
143
+ action: str | None = None
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class HealthReport:
148
+ project: Path
149
+ graph: str | None
150
+ clients: tuple[str, ...]
151
+ addons: tuple[str, ...]
152
+ checks: tuple[HealthCheck, ...]
153
+
154
+ @property
155
+ def healthy(self) -> bool:
156
+ return all(check.state != "error" for check in self.checks)
157
+
158
+
159
+ def _venv_executable(project: Path, name: str) -> Path:
160
+ directory = "Scripts" if os.name == "nt" else "bin"
161
+ suffix = ".exe" if os.name == "nt" else ""
162
+ return project / ".adl-toolkit" / "venv" / directory / f"{name}{suffix}"
163
+
164
+
165
+ def _codegraph_executable(project: Path) -> Path:
166
+ suffix = ".cmd" if os.name == "nt" else ""
167
+ return (
168
+ project
169
+ / ".adl-toolkit"
170
+ / "codegraph"
171
+ / "node_modules"
172
+ / ".bin"
173
+ / f"codegraph{suffix}"
174
+ )
175
+
176
+
177
+ def _codegraph_global_step(project: Path, label: str) -> Step:
178
+ return Step(
179
+ label,
180
+ (
181
+ "npm", "install", "--global", "--bin-links=true", "--no-audit", "--no-fund",
182
+ CODEGRAPH_PACKAGE,
183
+ ),
184
+ project,
185
+ 1800,
186
+ )
187
+
188
+
189
+ def _gitnexus_executable(project: Path) -> Path:
190
+ suffix = ".cmd" if os.name == "nt" else ""
191
+ return project / RUNTIME_PATHS["gitnexus"] / "node_modules" / ".bin" / f"gitnexus{suffix}"
192
+
193
+
194
+ def _graphify_global_step(project: Path) -> Step:
195
+ return Step(
196
+ "Set up uv and Graphify terminal command",
197
+ (sys.executable, str(REPO_ROOT / "scripts/install_toolkit.py"),
198
+ "setup-graphify-global", "--project", str(project)),
199
+ project, 1800,
200
+ )
201
+
202
+
203
+ def _managed_mcp_entry(graph: str, project: Path) -> dict[str, object]:
204
+ if graph == "gitnexus":
205
+ return {"command": str(_gitnexus_executable(project)), "args": ["mcp"]}
206
+ if graph == "graphify":
207
+ return {
208
+ "command": str(_venv_executable(project, "python")),
209
+ "args": ["-m", "graphify.serve", str(project / "graphify-out" / "graph.json")],
210
+ }
211
+ raise InstallError(f"Unsupported managed MCP tool: {graph}")
212
+
213
+
214
+ def _graphify_command(command: Sequence[str]) -> str:
215
+ if os.name == "nt":
216
+ return "& " + " ".join("'" + arg.replace("'", "''") + "'" for arg in command)
217
+ return shlex.join(command)
218
+
219
+
220
+ def _graphify_skill(project: Path, client: str) -> tuple[Path, str]:
221
+ relative = INTEGRATION_SKILL_ROOTS[client] / "graphify" / "SKILL.md"
222
+ executable = str(_venv_executable(project, "graphify"))
223
+ graph = str(project / "graphify-out" / "graph.json")
224
+ commands = (
225
+ ("Build or refresh the code graph", (executable, "extract", str(project), "--code-only", "--out", str(project))),
226
+ ("Generate the report and visualization", (executable, "cluster-only", str(project), "--no-label")),
227
+ ("Query", (executable, "query", "<question>", "--graph", graph)),
228
+ ("Explain", (executable, "explain", "<entity>", "--graph", graph)),
229
+ ("Trace a path", (executable, "path", "<A>", "<B>", "--graph", graph)),
230
+ )
231
+ content = (
232
+ "---\nname: graphify\ndescription: Use Graphify to index code, explore architecture, "
233
+ "query relationships, explain entities, and trace paths in this repository.\n---\n\n"
234
+ "# Graphify\n\n"
235
+ f"Run commands from the repository root: `{project}`. Use the managed executable below; "
236
+ "a global Graphify installation is unnecessary. Replace angle-bracket placeholders "
237
+ "with names or questions from this project's graph.\n\n"
238
+ + "\n\n".join(f"{label}:\n\n```{'powershell' if os.name == 'nt' else 'sh'}\n{_graphify_command(command)}\n```" for label, command in commands)
239
+ + "\n\nIf the graph is missing, build it first. After code changes, refresh it before "
240
+ "querying again. Code-only extraction and `--no-label` require no LLM credentials. "
241
+ "Only enable semantic document/media extraction when explicitly requested.\n\n"
242
+ "Open `graphify-out/graph.html` for visualization and `graphify-out/GRAPH_REPORT.md` "
243
+ "for architecture context. Prefer scoped queries for focused questions. The configured "
244
+ "Graphify MCP server exposes the same project graph. Cite source paths and distinguish "
245
+ "EXTRACTED relationships from INFERRED ones. Verify uncertain results against source "
246
+ "and tests; an empty graph result does not prove a symbol is unused.\n"
247
+ )
248
+ return relative, content
249
+
250
+
251
+ def _graphify_client_files(project: Path, client: str) -> dict[Path, str]:
252
+ skill, content = _graphify_skill(project, client)
253
+ files = {skill: content}
254
+ if client in {"cursor", "antigravity"}:
255
+ relative = (Path(".cursor/rules/graphify.mdc") if client == "cursor"
256
+ else Path(".agents/rules/graphify.md"))
257
+ activation = "alwaysApply: true" if client == "cursor" else "trigger: always_on"
258
+ files[relative] = (
259
+ "---\ndescription: Use the project Graphify graph for code exploration and change planning.\n"
260
+ f"{activation}\n---\n\n# Graphify\n\n"
261
+ f"For architecture, symbols, dependencies, and change planning, load `{skill.as_posix()}` "
262
+ "and query Graphify before exploring raw files. Use the configured `graphify` MCP "
263
+ "server's `query_graph` tool for scoped questions, then inspect nodes, neighbors, "
264
+ "or paths as needed. Use the skill's managed CLI commands if MCP is unavailable. "
265
+ "Build a missing graph and refresh it after code changes with the code-only commands. "
266
+ "Read source for details or verification and as a fallback when Graphify cannot "
267
+ "answer. Cite file paths and distinguish EXTRACTED from INFERRED relationships. "
268
+ "A user request to disable Graphify overrides this default for the conversation.\n"
269
+ )
270
+ return files
271
+
272
+
273
+ def _configure_managed_mcp(graph: str, project: Path, client: str) -> None:
274
+ """Merge only the selected server; existing ownership snapshots handle removal."""
275
+ if graph not in {"gitnexus", "graphify"} or client not in CLIENTS:
276
+ raise InstallError("Invalid managed MCP configuration request")
277
+ project = project.resolve()
278
+ files = _graphify_client_files(project, client) if graph == "graphify" else {}
279
+ for relative, content in files.items():
280
+ _assert_no_symlink(project, relative)
281
+ skill_path = project / relative
282
+ if skill_path.exists() and skill_path.read_text(encoding="utf-8") != content:
283
+ kind = "skill" if relative.name == "SKILL.md" else "rule"
284
+ raise InstallError(f"Existing Graphify {kind} differs: {skill_path}; preserve or move it before installing")
285
+
286
+ def write_client_files() -> None:
287
+ for relative, content in files.items():
288
+ path = project / relative
289
+ path.parent.mkdir(parents=True, exist_ok=True)
290
+ path.write_text(content, encoding="utf-8")
291
+
292
+ entry = _managed_mcp_entry(graph, project)
293
+ if client == "antigravity":
294
+ config, path = _antigravity_preferred_config()
295
+ _, _, document = _read_antigravity_config(config)
296
+ else:
297
+ relative = CLIENT_CONFIG_PATHS[client]
298
+ _assert_no_symlink(project, relative)
299
+ path = project / relative
300
+ if client == "codex":
301
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
302
+ block = f"[mcp_servers.{graph}]\n" + "".join(
303
+ f"{key} = {json.dumps(value, ensure_ascii=False)}\n" for key, value in entry.items()
304
+ )
305
+ # Python 3.10 has no stdlib TOML parser. Refuse existing files there
306
+ # rather than rewriting a configuration we cannot validate.
307
+ try:
308
+ import tomllib
309
+ except ImportError as error:
310
+ if content.strip():
311
+ raise InstallError("Merging existing Codex TOML for this tool requires Python 3.11+; set ADL_TOOLKIT_PYTHON.") from error
312
+ tomllib = None
313
+ document = tomllib.loads(content) if tomllib else {}
314
+ existing = document.get("mcp_servers", {}).get(graph)
315
+ if existing is not None and existing != entry:
316
+ raise InstallError(f"Existing {graph} MCP entry differs; remove it before installing")
317
+ if existing is not None:
318
+ write_client_files()
319
+ return
320
+ content = content.rstrip() + "\n\n" + block
321
+ if tomllib:
322
+ tomllib.loads(content)
323
+ path.parent.mkdir(parents=True, exist_ok=True)
324
+ path.write_text(content, encoding="utf-8")
325
+ write_client_files()
326
+ return
327
+ document = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
328
+ if not isinstance(document, dict) or not isinstance(document.get("mcpServers", {}), dict):
329
+ raise InstallError(f"Invalid MCP configuration: {path}")
330
+ servers = document.setdefault("mcpServers", {})
331
+ existing = servers.get(graph)
332
+ if existing is not None and existing != entry:
333
+ raise InstallError(f"Existing {graph} MCP entry differs; remove it before installing")
334
+ servers[graph] = entry
335
+ path.parent.mkdir(parents=True, exist_ok=True)
336
+ path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
337
+ write_client_files()
338
+
339
+
340
+ def _managed_mcp_step(graph: str, project: Path, client: str) -> Step:
341
+ return Step(
342
+ f"Configure {graph} client ({client})",
343
+ (sys.executable, str(REPO_ROOT / "scripts/install_toolkit.py"),
344
+ "configure-managed-mcp", "--graph", graph, "--project", str(project), "--client", client),
345
+ project, owner=f"client:{client}", client=client,
346
+ )
347
+
348
+
349
+ def validate_options(options: Options) -> Options:
350
+ project = options.project.expanduser().resolve()
351
+ if not project.is_dir():
352
+ raise ValueError(f"Project directory does not exist: {project}")
353
+ if options.graph not in GRAPHS:
354
+ raise ValueError(f"Invalid graph tool: {options.graph}")
355
+ clients = tuple(dict.fromkeys(options.clients or (options.client,)))
356
+ unknown_clients = set(clients) - set(CLIENTS)
357
+ if not clients or unknown_clients:
358
+ raise ValueError(f"Invalid client: {', '.join(sorted(unknown_clients)) or 'none'}")
359
+ unknown_addons = set(options.addons) - ADDON_SOURCES.keys()
360
+ if unknown_addons:
361
+ raise ValueError(f"Invalid add-on: {', '.join(sorted(unknown_addons))}")
362
+ unknown_extras = set(options.extras) - set(EXTRAS)
363
+ if unknown_extras:
364
+ raise ValueError(f"Invalid ADL extra: {', '.join(sorted(unknown_extras))}")
365
+ if options.extras and options.graph != "adl-drift":
366
+ raise ValueError("--extra is only supported with ADL-Drift")
367
+ package = options.package.strip() if options.package else None
368
+ if options.graph == "other":
369
+ if not package:
370
+ raise ValueError("--package is required when selecting Other")
371
+ if package.startswith("-") or any(c.isspace() or c in "/\\:" for c in package):
372
+ raise ValueError("--package must be a pip package name/spec, such as code2graph==1.2.0")
373
+ elif package:
374
+ raise ValueError("--package is only supported when selecting Other")
375
+ return Options(
376
+ graph=options.graph,
377
+ project=project,
378
+ client=clients[0],
379
+ addons=tuple(dict.fromkeys(options.addons)),
380
+ extras=tuple(dict.fromkeys(options.extras)),
381
+ package=package,
382
+ index=options.index if options.graph != "other" else False,
383
+ dry_run=options.dry_run,
384
+ clients=clients,
385
+ )
386
+
387
+
388
+ def build_plan(options: Options) -> tuple[Step, ...]:
389
+ options = validate_options(options)
390
+ project = options.project
391
+ toolkit = project / ".adl-toolkit"
392
+ steps: list[Step] = []
393
+ postflight: Step | None = None
394
+
395
+ if options.graph in {"adl-drift", "graphify", "other"}:
396
+ python = _venv_executable(project, "python")
397
+ steps.append(Step(
398
+ "Create Python environment",
399
+ (sys.executable, "-m", "venv", str(toolkit / "venv")),
400
+ project,
401
+ ))
402
+ install_spec = options.package
403
+ if options.graph == "graphify":
404
+ install_spec = GRAPHIFY_PACKAGE
405
+ if options.graph == "adl-drift":
406
+ suffix = f"[{','.join(options.extras)}]" if options.extras else ""
407
+ install_spec = f"{REPO_ROOT}{suffix}"
408
+ pip_args = (str(python), "-m", "pip", "install")
409
+ steps.append(Step(
410
+ "Install graph tool and dependencies",
411
+ pip_args + (install_spec or "",),
412
+ project,
413
+ 1800,
414
+ ))
415
+
416
+ if options.graph == "adl-drift":
417
+ adl = _venv_executable(project, "adl-drift")
418
+ for client in options.clients:
419
+ steps.append(Step(
420
+ "Configure MCP client" if len(options.clients) == 1 else f"Configure MCP client ({client})",
421
+ (str(adl), "-p", str(project), "mcp", "setup", f"--{client}"),
422
+ project, owner=("graph" if len(options.clients) == 1 else f"client:{client}"), client=client,
423
+ ))
424
+ postflight = Step(
425
+ "Check MCP connection",
426
+ (str(adl), "-p", str(project), "mcp", "doctor"),
427
+ project,
428
+ 90,
429
+ )
430
+ if options.index:
431
+ steps.append(Step(
432
+ "Initialize ADL-Drift index",
433
+ (str(adl), "-p", str(project), "graph"),
434
+ project,
435
+ 1800,
436
+ ))
437
+ elif options.graph == "gitnexus":
438
+ steps.append(Step(
439
+ "Install Git Nexus locally",
440
+ ("npm", "install", "--prefix", str(toolkit / "gitnexus"),
441
+ "--no-audit", "--no-fund", GITNEXUS_PACKAGE), project, 1800,
442
+ ))
443
+ else:
444
+ prefix = toolkit / "codegraph"
445
+ codegraph = _codegraph_executable(project)
446
+ steps.append(Step(
447
+ "Install CodeGraph locally",
448
+ (
449
+ "npm", "install", "--prefix", str(prefix),
450
+ "--no-audit", "--no-fund", CODEGRAPH_PACKAGE,
451
+ ),
452
+ project,
453
+ 1800,
454
+ ))
455
+ for client in options.clients:
456
+ steps.append(Step(
457
+ "Configure CodeGraph client" if len(options.clients) == 1 else f"Configure CodeGraph client ({client})",
458
+ (
459
+ str(codegraph), "install", "--target", client,
460
+ "--location", "global" if client == "antigravity" else "local",
461
+ "--yes", "--no-permissions",
462
+ ),
463
+ project, owner=("graph" if len(options.clients) == 1 else f"client:{client}"), client=client,
464
+ ))
465
+ if options.index:
466
+ steps.append(Step(
467
+ "Initialize CodeGraph index",
468
+ (str(codegraph), "init", "--yes", str(project)),
469
+ project,
470
+ 1800,
471
+ ))
472
+ steps.append(_codegraph_global_step(
473
+ project, "Install CodeGraph terminal command globally",
474
+ ))
475
+
476
+ if options.graph in {"gitnexus", "graphify"}:
477
+ steps.extend(_managed_mcp_step(options.graph, project, client) for client in options.clients)
478
+ if options.index:
479
+ command = (
480
+ (str(_gitnexus_executable(project)), "analyze", str(project), "--index-only", "--skip-git")
481
+ if options.graph == "gitnexus" else
482
+ (str(_venv_executable(project, "graphify")), "extract", str(project),
483
+ "--code-only", "--out", str(project))
484
+ )
485
+ steps.append(Step(f"Initialize {options.graph} index", command, project, 1800))
486
+ if options.graph == "graphify":
487
+ steps.append(Step(
488
+ "Generate Graphify report and visualization",
489
+ (str(_venv_executable(project, "graphify")), "cluster-only", str(project), "--no-label"),
490
+ project, 1800,
491
+ ))
492
+
493
+ for addon in options.addons:
494
+ installed_roots: set[Path] = set()
495
+ for client in options.clients:
496
+ skill_root = INTEGRATION_SKILL_ROOTS[client]
497
+ if skill_root in installed_roots:
498
+ continue
499
+ installed_roots.add(skill_root)
500
+ steps.append(Step(
501
+ (f"Install {addon.title()} skills (skills only)" if len(options.clients) == 1
502
+ else f"Install {addon.title()} skills for {client}"),
503
+ (
504
+ "npx", "--yes", SKILLS_PACKAGE, "add", ADDON_SOURCES[addon],
505
+ "--skill", "*", "-a", SKILLS_AGENT[client],
506
+ "--copy", "--yes",
507
+ ),
508
+ project,
509
+ 1200,
510
+ f"addon:{addon}",
511
+ True,
512
+ client,
513
+ ))
514
+ if options.graph == "adl-drift":
515
+ steps.append(Step(
516
+ "Install ADL-Drift terminal command globally",
517
+ ("npm", "install", "--global", "--bin-links=true", "--no-audit", "--no-fund", str(REPO_ROOT)),
518
+ project, 1800,
519
+ ))
520
+ if options.graph == "graphify":
521
+ steps.append(_graphify_global_step(project))
522
+ if postflight:
523
+ steps.append(postflight)
524
+ return tuple(steps)
525
+
526
+
527
+ def _display_command(command: Sequence[str]) -> str:
528
+ return subprocess.list2cmdline(command) if os.name == "nt" else shlex.join(command)
529
+
530
+
531
+ def format_plan(options: Options) -> str:
532
+ options = validate_options(options)
533
+ lines = [
534
+ "\nInstallation plan:",
535
+ f" Project: {options.project}",
536
+ f" Graph tool: {options.graph}",
537
+ f" Clients: {', '.join(options.clients)}",
538
+ f" Add-on: {', '.join(options.addons) or 'none'}",
539
+ f" Initialize index: {'yes' if options.index else 'no'}",
540
+ ]
541
+ if options.extras:
542
+ lines.append(f" ADL extras: {', '.join(options.extras)}")
543
+ if options.package:
544
+ lines.append(f" Pip package: {options.package}")
545
+ lines.append(" Note: Other only installs a pip package; MCP configuration is not automatic.")
546
+ if options.graph == "codegraph":
547
+ lines.append(f" Approved CodeGraph pin: {CODEGRAPH_PACKAGE}")
548
+ lines.append(
549
+ " Note: the shared global CodeGraph command will be aligned to this pin "
550
+ "and remains after project uninstall."
551
+ )
552
+ if options.graph == "adl-drift":
553
+ lines.append(" Note: the shared ADL-Drift terminal launcher is installed globally and remains after project uninstall.")
554
+ if options.graph == "graphify":
555
+ lines.append(" Note: missing uv is installed for the current user; Graphify is upgraded globally and shell PATH is configured. These shared tools remain after project uninstall.")
556
+ for number, step in enumerate(build_plan(options), 1):
557
+ lines.extend((f"\n {number}. {step.label}", f" $ {_display_command(step.command)}"))
558
+ return "\n".join(lines)
559
+
560
+
561
+ def _normalized_path(path: str | Path) -> str:
562
+ return os.path.normcase(str(Path(path).expanduser().resolve()))
563
+
564
+
565
+ def preflight(
566
+ options: Options,
567
+ which: Callable[[str], str | None] = shutil.which,
568
+ *,
569
+ run: Callable[..., subprocess.CompletedProcess[str]] | None = None,
570
+ inherited_path: str | None = None,
571
+ ) -> None:
572
+ required: set[str] = set()
573
+ if options.graph == "gitnexus":
574
+ required.update(("node", "npm", "git"))
575
+ if options.graph in {"adl-drift", "codegraph"}:
576
+ required.update(("node", "npm"))
577
+ if options.addons:
578
+ required.update(("node", "npm", "npx", "git"))
579
+ missing = sorted(tool for tool in required if which(tool) is None)
580
+ if missing:
581
+ raise InstallError(
582
+ "Missing system tools: " + ", ".join(missing)
583
+ + ". Install them and try again; the project has not been changed."
584
+ )
585
+ if options.graph in {"adl-drift", "codegraph"}:
586
+ try:
587
+ result = (run or subprocess.run)(
588
+ (which("npm") or "npm", "prefix", "--global"),
589
+ capture_output=True, text=True, timeout=10, check=True,
590
+ )
591
+ prefix = result.stdout.strip()
592
+ if not prefix:
593
+ raise ValueError("npm returned an empty global prefix")
594
+ except (OSError, ValueError, subprocess.SubprocessError) as error:
595
+ raise InstallError(
596
+ f"Could not determine npm's global executable directory: {error}. "
597
+ "Fix npm and try again; the project has not been changed."
598
+ ) from error
599
+ global_bin = Path(prefix) if os.name == "nt" else Path(prefix) / "bin"
600
+ path_value = os.environ.get("PATH", "") if inherited_path is None else inherited_path
601
+ path_entries = [
602
+ _normalized_path(entry) for entry in path_value.split(os.pathsep) if entry
603
+ ]
604
+ normalized_bin = _normalized_path(global_bin)
605
+ if normalized_bin not in path_entries:
606
+ raise InstallError(
607
+ f"npm's global executable directory is not on PATH: {global_bin}. "
608
+ "Add this directory to PATH before rerunning the installer; PATH is not "
609
+ "changed automatically and the project has not been changed."
610
+ )
611
+ existing_command = which(options.graph)
612
+ own_adl_launcher = (
613
+ options.graph == "adl-drift"
614
+ and existing_command is not None
615
+ and _normalized_path(existing_command) == _normalized_path(REPO_ROOT / "bin" / "adl-drift.mjs")
616
+ )
617
+ if existing_command and not own_adl_launcher:
618
+ command_directory = _normalized_path(Path(existing_command).parent)
619
+ if (
620
+ command_directory in path_entries
621
+ and path_entries.index(command_directory) < path_entries.index(normalized_bin)
622
+ ):
623
+ raise InstallError(
624
+ f"Another {options.graph} command takes precedence on PATH: {existing_command}. "
625
+ f"Move {global_bin} before that directory on PATH and rerun the installer; "
626
+ "the project has not been changed."
627
+ )
628
+ if options.addons:
629
+ try:
630
+ version = subprocess.run(
631
+ (which("node") or "node", "--version"), capture_output=True,
632
+ text=True, timeout=10, check=True,
633
+ ).stdout.strip().lstrip("v")
634
+ numbers = tuple(int(part) for part in version.split(".")[:3])
635
+ except (OSError, ValueError, subprocess.SubprocessError) as error:
636
+ raise InstallError(f"Could not determine Node.js version: {error}") from error
637
+ if numbers < (22, 20, 0):
638
+ raise InstallError("Integration skills require Node.js 22.20.0 or newer.")
639
+
640
+
641
+ def _digest(content: bytes) -> str:
642
+ return hashlib.sha256(content).hexdigest()
643
+
644
+
645
+ def _encoded(content: bytes | None) -> str | None:
646
+ return None if content is None else base64.b64encode(content).decode("ascii")
647
+
648
+
649
+ def _decoded(content: str | None) -> bytes | None:
650
+ return None if content is None else base64.b64decode(content, validate=True)
651
+
652
+
653
+ def _safe_relative(value: str) -> Path:
654
+ if not isinstance(value, str):
655
+ raise InstallError("Unsafe path in installation manifest")
656
+ path = Path(value)
657
+ if path.is_absolute() or PureWindowsPath(value).drive or not path.parts or ".." in path.parts or ".." in PureWindowsPath(value).parts:
658
+ raise InstallError(f"Unsafe path in installation manifest: {value!r}")
659
+ return path
660
+
661
+
662
+ def _assert_no_symlink(project: Path, relative: Path) -> None:
663
+ current = project
664
+ for part in relative.parts:
665
+ current /= part
666
+ if current.is_symlink():
667
+ raise InstallError(f"Refusing to follow symlink: {current}")
668
+
669
+
670
+ def _toolkit_dir(project: Path) -> Path:
671
+ _assert_no_symlink(project, Path(".adl-toolkit"))
672
+ return project / ".adl-toolkit"
673
+
674
+
675
+ def _managed_path(relative: Path) -> bool:
676
+ return relative in MANAGED_FILES or any(
677
+ relative == root or root in relative.parents for root in MANAGED_ROOTS
678
+ )
679
+
680
+
681
+ def _snapshot(project: Path) -> dict[str, bytes]:
682
+ files: dict[str, bytes] = {}
683
+ for relative in MANAGED_FILES:
684
+ path = project / relative
685
+ _assert_no_symlink(project, relative)
686
+ if path.is_file():
687
+ files[relative.as_posix()] = path.read_bytes()
688
+ for root in MANAGED_ROOTS:
689
+ path = project / root
690
+ _assert_no_symlink(project, root)
691
+ if not path.is_dir():
692
+ continue
693
+ for child in path.rglob("*"):
694
+ relative = child.relative_to(project)
695
+ _assert_no_symlink(project, relative)
696
+ if child.is_file():
697
+ files[relative.as_posix()] = child.read_bytes()
698
+ return files
699
+
700
+
701
+ def _antigravity_paths() -> dict[str, Path]:
702
+ home = Path.home()
703
+ return {name: home / relative for name, relative in ANTIGRAVITY_CONFIGS.items()}
704
+
705
+
706
+ def _antigravity_preferred_config() -> tuple[str, Path]:
707
+ paths = _antigravity_paths()
708
+ marker = Path.home() / ANTIGRAVITY_MIGRATED_MARKER
709
+ if marker.exists() or paths["unified"].exists():
710
+ return "unified", paths["unified"]
711
+ return "legacy", paths["legacy"]
712
+
713
+
714
+ def _global_server_names(graph: str, client: str) -> tuple[str, ...]:
715
+ if client != "antigravity":
716
+ return ()
717
+ if graph == "adl-drift":
718
+ return ("adl-drift", "drift")
719
+ if graph == "codegraph":
720
+ return ("codegraph",)
721
+ if graph in {"gitnexus", "graphify"}:
722
+ return (graph,)
723
+ return ()
724
+
725
+
726
+ def _expected_antigravity_entry(graph: str, project: Path) -> tuple[str, dict[str, object]]:
727
+ if graph in {"gitnexus", "graphify"}:
728
+ return graph, _managed_mcp_entry(graph, project)
729
+ if graph == "adl-drift":
730
+ return "adl-drift", {
731
+ "command": str(_venv_executable(project, "python")),
732
+ "args": [
733
+ "-m", "adl_drift.server.mcp_stdio", "--project", str(project.resolve()),
734
+ ],
735
+ }
736
+ if graph == "codegraph":
737
+ return "codegraph", {
738
+ "command": str(_codegraph_executable(project)),
739
+ "args": ["serve", "--mcp", "--path", str(project.resolve())],
740
+ }
741
+ raise InstallError(f"{graph} does not configure Antigravity MCP.")
742
+
743
+
744
+ def _ensure_antigravity_slot(options: Options) -> set[str]:
745
+ if "antigravity" not in options.clients or options.graph == "other":
746
+ return set()
747
+ preferred, _ = _antigravity_preferred_config()
748
+ primary, expected = _expected_antigravity_entry(options.graph, options.project)
749
+ found: list[tuple[str, str, object]] = []
750
+ for config in ANTIGRAVITY_CONFIGS:
751
+ _, _, document = _read_antigravity_config(config)
752
+ servers = document.get("mcpServers", {})
753
+ assert isinstance(servers, dict)
754
+ for server in _global_server_names(options.graph, "antigravity"):
755
+ if server in servers:
756
+ found.append((config, server, servers[server]))
757
+ if not found:
758
+ return set()
759
+ if found == [(preferred, primary, expected)]:
760
+ return set()
761
+ if options.graph == "adl-drift" and len(found) == 1:
762
+ config, server, entry = found[0]
763
+ if (config == preferred and server == primary and isinstance(entry, dict)
764
+ and set(entry) == {"command", "args"}
765
+ and isinstance(entry.get("command"), str)
766
+ and entry.get("args") == expected["args"]):
767
+ return {_global_entry_key(config, server)}
768
+ raise InstallError(
769
+ "Antigravity is already configured for this graph tool in another project. "
770
+ "Uninstall that integration before configuring this project."
771
+ )
772
+
773
+
774
+ def _global_entry_key(config: str, server: str) -> str:
775
+ return f"{config}/{server}"
776
+
777
+
778
+ def _read_antigravity_config(config: str) -> tuple[Path, bool, dict[str, object]]:
779
+ if config not in ANTIGRAVITY_CONFIGS:
780
+ raise InstallError("Installation manifest has an invalid global config.")
781
+ relative = ANTIGRAVITY_CONFIGS[config]
782
+ home = Path.home()
783
+ _assert_no_symlink(home, relative)
784
+ path = home / relative
785
+ if not path.exists():
786
+ return path, False, {}
787
+ if not path.is_file():
788
+ raise InstallError(f"Antigravity MCP config is not a file: {path}")
789
+ try:
790
+ document = json.loads(path.read_text(encoding="utf-8"))
791
+ except (OSError, json.JSONDecodeError) as error:
792
+ raise InstallError(f"Cannot read Antigravity MCP config: {error}") from error
793
+ if not isinstance(document, dict):
794
+ raise InstallError(f"Antigravity MCP config must contain a JSON object: {path}")
795
+ servers = document.get("mcpServers", {})
796
+ if not isinstance(servers, dict):
797
+ raise InstallError(f"Antigravity mcpServers must contain a JSON object: {path}")
798
+ return path, True, document
799
+
800
+
801
+ def _global_entry_snapshot(graph: str, client: str) -> dict[str, dict[str, object]]:
802
+ names = _global_server_names(graph, client)
803
+ if not names:
804
+ return {}
805
+ snapshot: dict[str, dict[str, object]] = {}
806
+ for config in ANTIGRAVITY_CONFIGS:
807
+ _, file_present, document = _read_antigravity_config(config)
808
+ servers = document.get("mcpServers", {})
809
+ assert isinstance(servers, dict)
810
+ for server in names:
811
+ snapshot[_global_entry_key(config, server)] = {
812
+ "file_present": file_present,
813
+ "present": server in servers,
814
+ "value": servers.get(server),
815
+ }
816
+ return snapshot
817
+
818
+
819
+ def _record_global_changes(
820
+ manifest: dict[str, object], before: dict[str, dict[str, object]],
821
+ after: dict[str, dict[str, object]], *, force: bool = False,
822
+ adopted: set[str] | None = None,
823
+ ) -> None:
824
+ entries = manifest.setdefault("global_entries", {})
825
+ assert isinstance(entries, dict)
826
+ adopted = adopted or set()
827
+ for key in sorted(set(before) | set(after)):
828
+ if key in adopted and before.get(key) == after.get(key):
829
+ continue
830
+ if before.get(key) == after.get(key):
831
+ state = after.get(key, {})
832
+ if not force or not state.get("present"):
833
+ continue
834
+ old = before.get(key, {"file_present": False, "present": False, "value": None})
835
+ new = after.get(key, {"file_present": False, "present": False, "value": None})
836
+ if key in adopted:
837
+ old = {**old, "present": False, "value": None}
838
+ record = entries.setdefault(key, {
839
+ "baseline_file_present": old["file_present"],
840
+ "baseline_present": old["present"],
841
+ "baseline": old["value"],
842
+ })
843
+ assert isinstance(record, dict)
844
+ record["installed_present"] = new["present"]
845
+ record["installed"] = new["value"]
846
+
847
+
848
+ def _configure_antigravity_codegraph(project: Path) -> None:
849
+ config, path = _antigravity_preferred_config()
850
+ _, _, document = _read_antigravity_config(config)
851
+ servers = document.setdefault("mcpServers", {})
852
+ assert isinstance(servers, dict)
853
+ server, entry = _expected_antigravity_entry("codegraph", project)
854
+ servers[server] = entry
855
+ path.parent.mkdir(parents=True, exist_ok=True)
856
+ path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
857
+
858
+
859
+ def _lock_skills(content: bytes | None) -> dict[str, object]:
860
+ if content is None:
861
+ return {}
862
+ try:
863
+ document = json.loads(content)
864
+ except (UnicodeDecodeError, json.JSONDecodeError):
865
+ return {}
866
+ skills = document.get("skills", {}) if isinstance(document, dict) else {}
867
+ return skills if isinstance(skills, dict) else {}
868
+
869
+
870
+ def _integration_block(
871
+ client: str, addons: Sequence[str], *, codegraph: bool = False,
872
+ ) -> str:
873
+ selected = set(addons)
874
+ skill_root = INTEGRATION_SKILL_ROOTS[client]
875
+ lines = [
876
+ INTEGRATION_BEGIN,
877
+ "## ADL Toolkit integrations",
878
+ ]
879
+ if selected:
880
+ lines.extend((
881
+ "",
882
+ "The selected integrations are active by default.",
883
+ ))
884
+ if "caveman" in selected:
885
+ lines.extend((
886
+ "",
887
+ f"- For responses, load and follow only `{skill_root / 'caveman/SKILL.md'}` "
888
+ "from the Caveman package. Keep every technical fact needed for correctness, "
889
+ "debugging, validation, and review.",
890
+ ))
891
+ if "ponytail" in selected:
892
+ lines.extend((
893
+ "",
894
+ f"- For source-code work, load and follow only `{skill_root / 'ponytail/SKILL.md'}` "
895
+ "in full mode. Preserve correctness, security, validation, tests, data integrity, "
896
+ "accessibility, and explicit user requirements.",
897
+ ))
898
+ if codegraph:
899
+ lines.extend((
900
+ "",
901
+ "### CodeGraph",
902
+ "",
903
+ "For repository structure, dependency, impact, and refactoring questions, call "
904
+ "`codegraph_explore` first. Confirm its index is available and current, then use "
905
+ "the returned source, dependency paths, and blast radius before opening files. "
906
+ "Read files for details the response does not contain, or as the fallback when "
907
+ "CodeGraph is unavailable or unindexed. Base every relationship claim on graph "
908
+ "or source evidence.",
909
+ ))
910
+ if codegraph:
911
+ names = [
912
+ *(addon.title() for addon in addons),
913
+ "CodeGraph",
914
+ ]
915
+ lines.extend((
916
+ "",
917
+ f"Any clear user request to disable {', '.join(names)} or all integrations "
918
+ "overrides these defaults without requiring an exact phrase. Keep that choice "
919
+ "for the current conversation until the user clearly re-enables it.",
920
+ ))
921
+ elif selected:
922
+ lines.extend((
923
+ "",
924
+ "Any clear user request to disable Caveman, Ponytail, or all integrations overrides "
925
+ "these defaults without requiring an exact phrase. Keep that choice for the current "
926
+ "conversation until the user clearly re-enables it.",
927
+ ))
928
+ lines.append(INTEGRATION_END)
929
+ return "\n".join(lines)
930
+
931
+
932
+ def _integration_content(
933
+ client: str, addons: Sequence[str], *, file_preexisting: bool,
934
+ codegraph: bool = False,
935
+ ) -> str:
936
+ block = _integration_block(client, addons, codegraph=codegraph)
937
+ if client == "cursor":
938
+ return "\n".join((
939
+ "---",
940
+ "description: Activate the integration tools selected with ADL Toolkit",
941
+ "alwaysApply: true",
942
+ "---",
943
+ block,
944
+ "",
945
+ ))
946
+ return ("\n" if file_preexisting else "") + block + "\n"
947
+
948
+
949
+ def _activate_integration_instructions(
950
+ project: Path, manifest: dict[str, object], clients: Sequence[str],
951
+ addons: Sequence[str], *, codegraph: bool = False,
952
+ ) -> None:
953
+ if not addons and not codegraph:
954
+ return
955
+ records = manifest.setdefault("integration_instructions", {})
956
+ assert isinstance(records, dict)
957
+ by_path: dict[Path, list[str]] = {}
958
+ for client in clients:
959
+ if addons or (codegraph and client == "antigravity"):
960
+ by_path.setdefault(INTEGRATION_INSTRUCTION_PATHS[client], []).append(client)
961
+ for relative, path_clients in by_path.items():
962
+ _assert_no_symlink(project, relative)
963
+ path = project / relative
964
+ key = relative.as_posix()
965
+ record = records.get(key)
966
+ representative = path_clients[0]
967
+ path_codegraph = codegraph and "antigravity" in path_clients
968
+ if record is not None:
969
+ assert isinstance(record, dict)
970
+ old_content = str(record["content"])
971
+ try:
972
+ current = path.read_text(encoding="utf-8")
973
+ except (OSError, UnicodeError) as error:
974
+ raise InstallError(f"Cannot update integration instructions: {path}") from error
975
+ if current.count(old_content) != 1:
976
+ raise InstallError(f"Integration instructions were modified; preserved {key}.")
977
+ file_preexisting = bool(record["file_preexisting"])
978
+ merged_clients = tuple(dict.fromkeys([*record.get("clients", ()), *path_clients]))
979
+ path_codegraph = path_codegraph or bool(record.get("codegraph", False))
980
+ content = _integration_content(
981
+ representative, addons, file_preexisting=file_preexisting,
982
+ codegraph=path_codegraph,
983
+ )
984
+ path.write_text(current.replace(old_content, content, 1), encoding="utf-8")
985
+ else:
986
+ if path.exists() and not path.is_file():
987
+ raise InstallError(f"Integration instruction path is not a file: {path}")
988
+ if representative == "cursor" and path.exists():
989
+ raise InstallError(f"Integration rule already exists; preserved {key}.")
990
+ try:
991
+ current = path.read_text(encoding="utf-8") if path.exists() else ""
992
+ except (OSError, UnicodeError) as error:
993
+ raise InstallError(f"Cannot read integration instructions: {path}") from error
994
+ if INTEGRATION_BEGIN in current or INTEGRATION_END in current:
995
+ raise InstallError(f"Integration instruction markers already exist in {key}.")
996
+ file_preexisting = path.exists()
997
+ merged_clients = tuple(path_clients)
998
+ content = _integration_content(
999
+ representative, addons, file_preexisting=file_preexisting,
1000
+ codegraph=path_codegraph,
1001
+ )
1002
+ path.parent.mkdir(parents=True, exist_ok=True)
1003
+ path.write_text(current + content, encoding="utf-8")
1004
+ records[key] = {
1005
+ "path": key, "clients": list(merged_clients), "addons": list(addons),
1006
+ "codegraph": path_codegraph, "file_preexisting": file_preexisting,
1007
+ "content": content,
1008
+ }
1009
+
1010
+
1011
+ def _integration_instruction_state(
1012
+ project: Path, manifest: dict[str, object] | None,
1013
+ ) -> list[dict[str, object]]:
1014
+ if not manifest or "integration_instructions" not in manifest:
1015
+ return []
1016
+ records = manifest["integration_instructions"]
1017
+ assert isinstance(records, dict)
1018
+ states: list[dict[str, object]] = []
1019
+ for record in records.values():
1020
+ assert isinstance(record, dict)
1021
+ relative = Path(str(record["path"]))
1022
+ _assert_no_symlink(project, relative)
1023
+ path = project / relative
1024
+ try:
1025
+ current = path.read_text(encoding="utf-8") if path.is_file() else None
1026
+ except (OSError, UnicodeError):
1027
+ current = None
1028
+ content = str(record["content"])
1029
+ removable = current is not None and current.count(content) == 1
1030
+ cleaned: str | None = None
1031
+ if removable:
1032
+ cleaned = current.replace(content, "", 1)
1033
+ if not bool(record["file_preexisting"]) and current == content:
1034
+ cleaned = None
1035
+ states.append({
1036
+ "project": project, "path": path, "relative": relative,
1037
+ "current": current, "cleaned": cleaned, "content": content,
1038
+ "removable": removable,
1039
+ })
1040
+ return states
1041
+
1042
+
1043
+ def _apply_integration_instruction_removal(state: dict[str, object]) -> None:
1044
+ project = state["project"]
1045
+ path = state["path"]
1046
+ assert isinstance(project, Path)
1047
+ assert isinstance(path, Path)
1048
+ cleaned = state["cleaned"]
1049
+ if cleaned is None:
1050
+ if path.is_file():
1051
+ path.unlink()
1052
+ _prune_empty_parents(project, path)
1053
+ else:
1054
+ assert isinstance(cleaned, str)
1055
+ path.write_text(cleaned, encoding="utf-8")
1056
+
1057
+
1058
+ def _restore_integration_instruction_after_failure(state: dict[str, object]) -> None:
1059
+ path = state["path"]
1060
+ assert isinstance(path, Path)
1061
+ content = str(state["content"])
1062
+ try:
1063
+ current = path.read_text(encoding="utf-8") if path.is_file() else ""
1064
+ except (OSError, UnicodeError):
1065
+ return
1066
+ if INTEGRATION_BEGIN in current or INTEGRATION_END in current:
1067
+ return
1068
+ path.parent.mkdir(parents=True, exist_ok=True)
1069
+ path.write_text(current + content, encoding="utf-8")
1070
+
1071
+
1072
+ def _record_changes(
1073
+ manifest: dict[str, object], before: dict[str, bytes], after: dict[str, bytes], owner: str,
1074
+ force_paths: set[str] | None = None,
1075
+ ) -> None:
1076
+ artifacts = manifest.setdefault("artifacts", {})
1077
+ assert isinstance(artifacts, dict)
1078
+ for relative in sorted(set(before) | set(after)):
1079
+ old_content = _without_integration_instructions(
1080
+ manifest, relative, before.get(relative),
1081
+ )
1082
+ new_content = _without_integration_instructions(
1083
+ manifest, relative, after.get(relative),
1084
+ )
1085
+ if relative == "skills-lock.json" or (
1086
+ old_content == new_content
1087
+ and relative not in (force_paths or set())
1088
+ ):
1089
+ continue
1090
+ entry = artifacts.get(relative)
1091
+ if not isinstance(entry, dict):
1092
+ entry = {"baseline": _encoded(old_content), "owner": owner}
1093
+ artifacts[relative] = entry
1094
+ entry["installed_sha256"] = (
1095
+ _digest(new_content) if new_content is not None else None
1096
+ )
1097
+
1098
+ if not owner.startswith("addon:"):
1099
+ return
1100
+ addon = owner.split(":", 1)[1]
1101
+ lock = manifest.setdefault("lock", {"preexisting": "skills-lock.json" in before, "entries": {}})
1102
+ assert isinstance(lock, dict)
1103
+ entries = lock.setdefault("entries", {})
1104
+ assert isinstance(entries, dict)
1105
+ old_skills = _lock_skills(before.get("skills-lock.json"))
1106
+ new_skills = _lock_skills(after.get("skills-lock.json"))
1107
+ for name in sorted(set(old_skills) | set(new_skills)):
1108
+ if old_skills.get(name) == new_skills.get(name):
1109
+ continue
1110
+ installed = new_skills.get(name)
1111
+ if not isinstance(installed, dict) or installed.get("source") != ADDON_SOURCES[addon]:
1112
+ continue
1113
+ if name not in entries:
1114
+ entries[name] = {"addon": addon, "baseline": old_skills.get(name)}
1115
+ entries[name]["installed"] = installed
1116
+
1117
+
1118
+ def _without_integration_instructions(
1119
+ manifest: dict[str, object], relative: str, content: bytes | None,
1120
+ ) -> bytes | None:
1121
+ records = manifest.get("integration_instructions")
1122
+ if content is None or not isinstance(records, dict):
1123
+ return content
1124
+ if records.get("path") == relative:
1125
+ expected = str(records["content"]).encode("utf-8")
1126
+ return content.replace(expected, b"", 1) if content.count(expected) == 1 else content
1127
+ record = records.get(relative)
1128
+ if not isinstance(record, dict):
1129
+ return content
1130
+ expected = str(record["content"]).encode("utf-8")
1131
+ return content.replace(expected, b"", 1) if content.count(expected) == 1 else content
1132
+
1133
+
1134
+ def _validate_manifest(project: Path, manifest: object) -> dict[str, object]:
1135
+ if not isinstance(manifest, dict) or manifest.get("version") != MANIFEST_VERSION:
1136
+ raise InstallError("Unsupported or malformed installation manifest.")
1137
+ if manifest.get("graph") not in GRAPHS or manifest.get("client") not in CLIENTS:
1138
+ raise InstallError("Installation manifest has an invalid graph tool or client.")
1139
+ clients = manifest.get("clients", [manifest["client"]])
1140
+ if (not isinstance(clients, list) or not clients
1141
+ or len(clients) != len(set(clients)) or any(item not in CLIENTS for item in clients)
1142
+ or manifest["client"] != clients[0]):
1143
+ raise InstallError("Installation manifest has invalid clients.")
1144
+ manifest["clients"] = clients
1145
+ configured_clients = manifest.get("configured_clients", clients)
1146
+ if (not isinstance(configured_clients, list)
1147
+ or len(configured_clients) != len(set(configured_clients))
1148
+ or any(item not in clients for item in configured_clients)):
1149
+ raise InstallError("Installation manifest has invalid configured clients.")
1150
+ manifest["configured_clients"] = configured_clients
1151
+ addons = manifest.get("addons")
1152
+ if not isinstance(addons, list) or any(item not in ADDON_SOURCES for item in addons):
1153
+ raise InstallError("Installation manifest has invalid add-ons.")
1154
+ extras = manifest.get("extras", [])
1155
+ if (not isinstance(extras, list) or len(extras) != len(set(extras))
1156
+ or any(item not in EXTRAS for item in extras)
1157
+ or (extras and manifest["graph"] != "adl-drift")):
1158
+ raise InstallError("Installation manifest has invalid ADL extras.")
1159
+ manifest["extras"] = extras
1160
+ package = manifest.get("package")
1161
+ if package is not None and (
1162
+ manifest["graph"] != "other" or not isinstance(package, str)
1163
+ or not package.strip() or package.startswith("-")
1164
+ or any(char.isspace() or char in "/\\:" for char in package)
1165
+ ):
1166
+ raise InstallError("Installation manifest has an invalid pip package spec.")
1167
+ if manifest["graph"] != "other" and package is not None:
1168
+ raise InstallError("Installation manifest has an unexpected pip package spec.")
1169
+ manifest["package"] = package
1170
+ index = manifest.get("index", False)
1171
+ if type(index) is not bool or (index and manifest["graph"] == "other"):
1172
+ raise InstallError("Installation manifest has an invalid index preference.")
1173
+ manifest["index"] = index
1174
+ completed_steps = manifest.get("completed_steps", [])
1175
+ if (not isinstance(completed_steps, list)
1176
+ or any(not isinstance(item, str) or not item for item in completed_steps)):
1177
+ raise InstallError("Installation manifest has invalid completed steps.")
1178
+ manifest["completed_steps"] = completed_steps
1179
+ artifacts = manifest.get("artifacts", {})
1180
+ if not isinstance(artifacts, dict):
1181
+ raise InstallError("Installation manifest has invalid artifacts.")
1182
+ for value, entry in artifacts.items():
1183
+ relative = _safe_relative(value)
1184
+ if not _managed_path(relative) or not isinstance(entry, dict):
1185
+ raise InstallError(f"Installation manifest contains unmanaged path: {value}")
1186
+ _assert_no_symlink(project, relative)
1187
+ if not {"baseline", "owner", "installed_sha256"} <= entry.keys():
1188
+ raise InstallError(f"Incomplete artifact record for {value}")
1189
+ if entry.get("owner") not in {
1190
+ "graph", *(f"addon:{addon}" for addon in addons),
1191
+ *(f"client:{client}" for client in clients),
1192
+ }:
1193
+ raise InstallError(f"Invalid artifact owner for {value}")
1194
+ try:
1195
+ _decoded(entry.get("baseline"))
1196
+ except (TypeError, ValueError) as error:
1197
+ raise InstallError(f"Invalid baseline data for {value}") from error
1198
+ digest = entry.get("installed_sha256")
1199
+ if digest is not None and (
1200
+ not isinstance(digest, str) or len(digest) != 64
1201
+ or any(char not in "0123456789abcdef" for char in digest)
1202
+ ):
1203
+ raise InstallError(f"Invalid digest for {value}")
1204
+ runtimes = manifest.get("runtimes", {})
1205
+ indexes = manifest.get("indexes", {})
1206
+ if not isinstance(runtimes, dict) or set(runtimes) - {
1207
+ path.as_posix() for path in RUNTIME_PATHS.values()
1208
+ }:
1209
+ raise InstallError("Installation manifest has invalid runtime paths.")
1210
+ if not isinstance(indexes, dict) or set(indexes) - {
1211
+ path.as_posix() for paths in INDEX_PATHS.values() for path in paths
1212
+ }:
1213
+ raise InstallError("Installation manifest has invalid index paths.")
1214
+ for value in (*runtimes, *indexes):
1215
+ relative = _safe_relative(value)
1216
+ _assert_no_symlink(project, relative)
1217
+ record = runtimes[value] if value in runtimes else indexes[value]
1218
+ if not isinstance(record, dict) or type(record.get("preexisting")) is not bool:
1219
+ raise InstallError(f"Invalid ownership record for {value}")
1220
+ lock = manifest.get("lock", {})
1221
+ if not isinstance(lock, dict) or not isinstance(lock.get("entries", {}), dict):
1222
+ raise InstallError("Installation manifest has invalid skill lock data.")
1223
+ for name, record in lock.get("entries", {}).items():
1224
+ if (not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name)
1225
+ or not isinstance(record, dict) or record.get("addon") not in addons
1226
+ or not isinstance(record.get("installed"), dict)):
1227
+ raise InstallError("Installation manifest has an invalid skill lock entry.")
1228
+ global_entries = manifest.get("global_entries", {})
1229
+ if not isinstance(global_entries, dict):
1230
+ raise InstallError("Installation manifest has invalid global entries.")
1231
+ allowed_global_entries = {
1232
+ _global_entry_key(config, server)
1233
+ for config in ANTIGRAVITY_CONFIGS
1234
+ for server in _global_server_names(
1235
+ str(manifest["graph"]), "antigravity" if "antigravity" in clients else ""
1236
+ )
1237
+ }
1238
+ if set(global_entries) - allowed_global_entries:
1239
+ raise InstallError("Installation manifest has an invalid global entry.")
1240
+ primary_global_name = None
1241
+ expected_global_entry = None
1242
+ if "antigravity" in clients and manifest["graph"] != "other":
1243
+ primary_global_name, expected_global_entry = _expected_antigravity_entry(
1244
+ str(manifest["graph"]), project,
1245
+ )
1246
+ for key, record in global_entries.items():
1247
+ if (not isinstance(record, dict)
1248
+ or not {"baseline_file_present", "baseline_present", "baseline",
1249
+ "installed_present", "installed"} <= record.keys()
1250
+ or type(record["baseline_file_present"]) is not bool
1251
+ or type(record["baseline_present"]) is not bool
1252
+ or type(record["installed_present"]) is not bool
1253
+ or (record["installed_present"] and not isinstance(record["installed"], dict))):
1254
+ raise InstallError(f"Installation manifest has an invalid global entry: {key}")
1255
+ config, server = key.split("/", 1)
1256
+ expected_installed = expected_global_entry if server == primary_global_name else None
1257
+ if ((record["installed_present"] and record["installed"] != expected_installed)
1258
+ or (not record["installed_present"] and record["installed"] is not None)
1259
+ or (record["baseline_present"] and (
1260
+ server != primary_global_name
1261
+ or record["baseline"] != expected_global_entry
1262
+ or not record["installed_present"]
1263
+ ))
1264
+ or (not record["baseline_present"] and record["baseline"] is not None)):
1265
+ raise InstallError(f"Unsafe global entry in installation manifest: {key}")
1266
+ _read_antigravity_config(config)
1267
+ instructions = manifest.get("integration_instructions")
1268
+ if instructions is not None:
1269
+ if not isinstance(instructions, dict):
1270
+ raise InstallError("Installation manifest has invalid integration instructions.")
1271
+ # Migrate the original single-record schema in memory.
1272
+ if "path" in instructions:
1273
+ old = dict(instructions)
1274
+ old["clients"] = [str(manifest["client"])]
1275
+ instructions = {str(old["path"]): old}
1276
+ manifest["integration_instructions"] = instructions
1277
+ for key, record in instructions.items():
1278
+ base_keys = {"path", "clients", "addons", "file_preexisting", "content"}
1279
+ record_keys = set(record) if isinstance(record, dict) else set()
1280
+ record_clients = record.get("clients") if isinstance(record, dict) else None
1281
+ instruction_addons = record.get("addons") if isinstance(record, dict) else None
1282
+ codegraph_guidance = record.get("codegraph", False) if isinstance(record, dict) else False
1283
+ if (not isinstance(record, dict)
1284
+ or record_keys not in (base_keys, {*base_keys, "codegraph"})
1285
+ or record.get("path") != key
1286
+ or type(record.get("file_preexisting")) is not bool
1287
+ or not isinstance(record_clients, list) or not record_clients
1288
+ or len(record_clients) != len(set(record_clients))
1289
+ or any(client not in clients for client in record_clients)
1290
+ or any(INTEGRATION_INSTRUCTION_PATHS[client].as_posix() != key
1291
+ for client in record_clients)
1292
+ or not isinstance(instruction_addons, list)
1293
+ or len(instruction_addons) != len(set(instruction_addons))
1294
+ or any(addon not in addons for addon in instruction_addons)
1295
+ or type(codegraph_guidance) is not bool
1296
+ or (codegraph_guidance and (
1297
+ manifest["graph"] != "codegraph" or "antigravity" not in record_clients
1298
+ ))
1299
+ or (not instruction_addons and not codegraph_guidance)):
1300
+ raise InstallError("Installation manifest has invalid integration instructions.")
1301
+ representative = str(record_clients[0])
1302
+ if representative == "cursor" and record["file_preexisting"]:
1303
+ raise InstallError("Installation manifest has invalid Cursor integration ownership.")
1304
+ expected_content = _integration_content(
1305
+ representative, instruction_addons,
1306
+ file_preexisting=bool(record["file_preexisting"]),
1307
+ codegraph=codegraph_guidance,
1308
+ )
1309
+ if record.get("content") != expected_content:
1310
+ raise InstallError("Installation manifest has unsafe integration content.")
1311
+ _assert_no_symlink(project, Path(key))
1312
+ _assert_no_symlink(project, Path("skills-lock.json"))
1313
+ _assert_no_symlink(project, MANIFEST_PATH)
1314
+ return manifest
1315
+
1316
+
1317
+ def _load_manifest(project: Path) -> dict[str, object] | None:
1318
+ toolkit = _toolkit_dir(project)
1319
+ path = toolkit / "install.json"
1320
+ _assert_no_symlink(project, MANIFEST_PATH)
1321
+ if not path.exists():
1322
+ return None
1323
+ if not path.is_file():
1324
+ raise InstallError(f"Installation manifest is not a file: {path}")
1325
+ try:
1326
+ manifest = json.loads(path.read_text(encoding="utf-8"))
1327
+ except (OSError, json.JSONDecodeError) as error:
1328
+ raise InstallError(f"Cannot read installation manifest: {error}") from error
1329
+ return _validate_manifest(project, manifest)
1330
+
1331
+
1332
+ def _save_manifest(project: Path, manifest: dict[str, object]) -> None:
1333
+ toolkit = _toolkit_dir(project)
1334
+ toolkit.mkdir(exist_ok=True)
1335
+ path = toolkit / "install.json"
1336
+ temporary = toolkit / "install.json.tmp"
1337
+ temporary.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1338
+ os.replace(temporary, path)
1339
+
1340
+
1341
+ def _install_manifest(options: Options) -> dict[str, object]:
1342
+ options = validate_options(options)
1343
+ existing = _load_manifest(options.project)
1344
+ if existing:
1345
+ if existing["graph"] != options.graph:
1346
+ raise InstallError(
1347
+ f"{existing['graph']} is already managed for this project; uninstall it first."
1348
+ )
1349
+ existing_clients = list(existing.get("clients", [existing["client"]]))
1350
+ existing["clients"] = list(dict.fromkeys([*existing_clients, *options.clients]))
1351
+ existing["client"] = str(existing["clients"][0])
1352
+ existing["addons"] = list(dict.fromkeys([
1353
+ *existing.get("addons", []), *options.addons,
1354
+ ]))
1355
+ existing["extras"] = list(options.extras)
1356
+ existing["package"] = options.package
1357
+ existing["index"] = options.index
1358
+ return existing
1359
+ return {
1360
+ "version": MANIFEST_VERSION,
1361
+ "graph": options.graph,
1362
+ "client": options.client,
1363
+ "clients": list(options.clients),
1364
+ "configured_clients": [],
1365
+ "addons": list(options.addons),
1366
+ "extras": list(options.extras),
1367
+ "package": options.package,
1368
+ "index": options.index,
1369
+ "completed_steps": [],
1370
+ "artifacts": {},
1371
+ "runtimes": {
1372
+ RUNTIME_PATHS[options.graph].as_posix(): {
1373
+ "preexisting": (options.project / RUNTIME_PATHS[options.graph]).exists(),
1374
+ },
1375
+ },
1376
+ "indexes": {
1377
+ path.as_posix(): {"preexisting": (options.project / path).exists()}
1378
+ for path in INDEX_PATHS[options.graph]
1379
+ },
1380
+ "lock": {
1381
+ "preexisting": (options.project / "skills-lock.json").exists(),
1382
+ "entries": {},
1383
+ },
1384
+ "global_entries": {},
1385
+ }
1386
+
1387
+
1388
+ def _stop_process_tree(process: subprocess.Popen[str], grace: float = 5) -> None:
1389
+ if os.name == "nt":
1390
+ try:
1391
+ subprocess.run(
1392
+ (
1393
+ shutil.which("taskkill") or "taskkill",
1394
+ "/PID", str(process.pid), "/T", "/F",
1395
+ ),
1396
+ capture_output=True,
1397
+ text=True,
1398
+ timeout=grace,
1399
+ check=False,
1400
+ )
1401
+ except (OSError, subprocess.TimeoutExpired):
1402
+ if process.poll() is None:
1403
+ process.kill()
1404
+ else:
1405
+ group_alive = True
1406
+ try:
1407
+ os.killpg(process.pid, signal.SIGTERM)
1408
+ except ProcessLookupError:
1409
+ group_alive = False
1410
+ deadline = time.monotonic() + grace
1411
+ while group_alive and time.monotonic() < deadline:
1412
+ process.poll()
1413
+ time.sleep(0.05)
1414
+ try:
1415
+ os.killpg(process.pid, 0)
1416
+ except ProcessLookupError:
1417
+ group_alive = False
1418
+ if group_alive:
1419
+ try:
1420
+ os.killpg(process.pid, signal.SIGKILL)
1421
+ except ProcessLookupError:
1422
+ pass
1423
+ try:
1424
+ process.wait(timeout=grace)
1425
+ except subprocess.TimeoutExpired:
1426
+ process.kill()
1427
+ process.wait()
1428
+
1429
+
1430
+ def run_command(step: Step, emit: Callable[[str], None] = print) -> None:
1431
+ emit(f"\n→ {step.label}")
1432
+ emit(f" $ {_display_command(step.command)}")
1433
+ executable = shutil.which(step.command[0]) or step.command[0]
1434
+ command = (executable, *step.command[1:])
1435
+ try:
1436
+ process = subprocess.Popen(
1437
+ command,
1438
+ cwd=step.cwd,
1439
+ stdout=subprocess.PIPE,
1440
+ stderr=subprocess.STDOUT,
1441
+ text=True,
1442
+ encoding="utf-8",
1443
+ errors="replace",
1444
+ env={**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"},
1445
+ bufsize=1,
1446
+ start_new_session=os.name != "nt",
1447
+ creationflags=(
1448
+ subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
1449
+ ),
1450
+ )
1451
+ except OSError as error:
1452
+ raise InstallError(
1453
+ f"Cannot run step: {step.label}\n"
1454
+ f"Command: {_display_command(step.command)}\n{error}"
1455
+ ) from error
1456
+ output: queue.Queue[str | None] = queue.Queue()
1457
+ tail: deque[str] = deque(maxlen=20)
1458
+
1459
+ def read_output() -> None:
1460
+ assert process.stdout is not None
1461
+ try:
1462
+ for line in process.stdout:
1463
+ output.put(line)
1464
+ finally:
1465
+ process.stdout.close()
1466
+ output.put(None)
1467
+
1468
+ reader = threading.Thread(target=read_output, daemon=True)
1469
+ reader.start()
1470
+ deadline = time.monotonic() + step.timeout
1471
+ stop_tree = False
1472
+ try:
1473
+ while True:
1474
+ remaining = deadline - time.monotonic()
1475
+ if remaining <= 0:
1476
+ raise InstallError(
1477
+ f"Timed out ({step.timeout:g}s): {step.label}\n"
1478
+ f"Command: {_display_command(step.command)}"
1479
+ )
1480
+ try:
1481
+ line = output.get(timeout=min(0.2, remaining))
1482
+ except queue.Empty:
1483
+ continue
1484
+ if line is None:
1485
+ break
1486
+ line = line.rstrip()
1487
+ tail.append(line)
1488
+ if not step.quiet:
1489
+ emit(f" {line}")
1490
+ try:
1491
+ exit_code = process.wait(timeout=max(0.1, deadline - time.monotonic()))
1492
+ except subprocess.TimeoutExpired as error:
1493
+ raise InstallError(
1494
+ f"Timed out ({step.timeout:g}s): {step.label}\n"
1495
+ f"Command: {_display_command(step.command)}"
1496
+ ) from error
1497
+ except BaseException:
1498
+ stop_tree = True
1499
+ raise
1500
+ finally:
1501
+ if stop_tree:
1502
+ _stop_process_tree(process)
1503
+ else:
1504
+ process.wait()
1505
+ reader.join(timeout=5)
1506
+ if exit_code:
1507
+ detail = "\n".join(tail)
1508
+ raise InstallError(
1509
+ f"Step failed (exit {exit_code}): {step.label}\n"
1510
+ f"Command: {_display_command(step.command)}" + (f"\n{detail}" if detail else "")
1511
+ )
1512
+
1513
+
1514
+ def _setup_graphify_global(
1515
+ project: Path, *, emit: Callable[[str], None] = print,
1516
+ runner: Callable[[Step, Callable[[str], None]], None] = run_command,
1517
+ ) -> None:
1518
+ def find_uv() -> str | None:
1519
+ existing = shutil.which("uv")
1520
+ if existing:
1521
+ return existing
1522
+ directories = [Path.home() / ".local" / "bin"]
1523
+ for variable in ("UV_INSTALL_DIR", "XDG_BIN_HOME"):
1524
+ if os.environ.get(variable):
1525
+ directories.insert(0, Path(os.environ[variable]).expanduser())
1526
+ name = "uv.exe" if os.name == "nt" else "uv"
1527
+ return next((str(directory / name) for directory in directories
1528
+ if (directory / name).is_file()), None)
1529
+
1530
+ uv = find_uv()
1531
+ if uv is None:
1532
+ extension = "ps1" if os.name == "nt" else "sh"
1533
+ url = f"https://astral.sh/uv/{UV_VERSION}/install.{extension}"
1534
+ emit(f"Installing uv {UV_VERSION} with the official user-scoped installer: {url}")
1535
+ with tempfile.TemporaryDirectory(prefix="adl-toolkit-uv-") as directory:
1536
+ installer = Path(directory) / f"install.{extension}"
1537
+ for attempt in range(3):
1538
+ try:
1539
+ request = Request(url, headers={"User-Agent": "adl-toolkit"})
1540
+ with urlopen(request, timeout=30) as response:
1541
+ content = response.read()
1542
+ break
1543
+ except (OSError, HTTPException) as error:
1544
+ if attempt == 2:
1545
+ raise InstallError(f"Could not download the uv installer: {error}") from error
1546
+ emit(f"Retrying uv installer download: {error}")
1547
+ installer.write_bytes(content)
1548
+ command = (
1549
+ (shutil.which("powershell") or "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(installer))
1550
+ if os.name == "nt" else
1551
+ (shutil.which("sh") or "sh", str(installer))
1552
+ )
1553
+ runner(Step("Install uv for the current user", command, project, 300), emit)
1554
+ uv = find_uv()
1555
+ if uv is None:
1556
+ raise InstallError("The uv installer finished but its executable was not found; check UV_INSTALL_DIR and rerun installation.")
1557
+ runner(Step("Install or upgrade Graphify terminal command",
1558
+ (uv, "tool", "install", "--upgrade", GRAPHIFY_PACKAGE), project, 1800), emit)
1559
+ runner(Step("Configure Graphify terminal PATH",
1560
+ (uv, "tool", "update-shell"), project, 60), emit)
1561
+ emit("uv and Graphify are ready. Reopen your terminal to load any PATH changes.")
1562
+
1563
+
1564
+ def _install_retry_command(options: Options) -> str:
1565
+ command = [
1566
+ "adl-toolkit", "install", "--graph", options.graph,
1567
+ "--project", str(options.project),
1568
+ ]
1569
+ for client in options.clients:
1570
+ command.extend(("--client", client))
1571
+ for addon in options.addons:
1572
+ command.extend(("--addon", addon))
1573
+ for extra in options.extras:
1574
+ command.extend(("--extra", extra))
1575
+ if options.package:
1576
+ command.extend(("--package", options.package))
1577
+ if not options.index:
1578
+ command.append("--no-index")
1579
+ return _display_command(command)
1580
+
1581
+
1582
+ def _index_summary(graph: str, project: Path, enabled: bool) -> str:
1583
+ if graph == "other":
1584
+ return "not managed for custom packages"
1585
+ present = [path.as_posix() for path in INDEX_PATHS[graph] if (project / path).exists()]
1586
+ if present:
1587
+ return "present: " + ", ".join(present)
1588
+ return "requested; no index path found" if enabled else "skipped by saved preference"
1589
+
1590
+
1591
+ def _format_completion_summary(
1592
+ manifest: dict[str, object], project: Path, *, action: str,
1593
+ ) -> str:
1594
+ graph = str(manifest["graph"])
1595
+ clients = [str(item) for item in manifest.get("clients", [manifest["client"]])]
1596
+ addons = [str(item) for item in manifest.get("addons", ())]
1597
+ lines = [
1598
+ f"\n✓ {action} complete.",
1599
+ f" Graph tool: {graph}",
1600
+ f" Clients: {', '.join(clients)}",
1601
+ f" Add-ons: {', '.join(addons) or 'none'}",
1602
+ f" Index: {_index_summary(graph, project, bool(manifest.get('index', False)))}",
1603
+ f" Restart clients: {', '.join(clients)}",
1604
+ ]
1605
+ if graph == "codegraph" and (
1606
+ not bool(manifest.get("index", False)) or not (project / ".codegraph").exists()
1607
+ ):
1608
+ global_codegraph = shutil.which("codegraph")
1609
+ arguments = (
1610
+ ("codegraph", "init", "--yes", ".")
1611
+ if global_codegraph
1612
+ else (
1613
+ str(_codegraph_executable(project.resolve())),
1614
+ "init", "--yes", ".",
1615
+ )
1616
+ )
1617
+ command = (
1618
+ "& " + " ".join("'" + argument.replace("'", "''") + "'" for argument in arguments)
1619
+ if os.name == "nt" else _display_command(arguments)
1620
+ )
1621
+ if global_codegraph:
1622
+ lines.append(" CodeGraph's terminal command is available on PATH.")
1623
+ else:
1624
+ lines.append(
1625
+ " The global CodeGraph command was not found; using the managed local runtime."
1626
+ )
1627
+ if os.name == "nt":
1628
+ lines.append(" Run the following command in PowerShell.")
1629
+ lines.append(f" Run from the project directory: {project.resolve()}")
1630
+ lines.append(f" Build the index manually: {command}")
1631
+ if graph == "adl-drift":
1632
+ lines.append(f" Run from the project directory: {project.resolve()}")
1633
+ lines.append(" Build the graph manually: adl-drift graph")
1634
+ if graph == "other":
1635
+ lines.append(" Manual setup: configure the custom package in each MCP client if needed.")
1636
+ lines.append(" Sample prompt: Use the installed graph tool to map this project's main modules.")
1637
+ else:
1638
+ tool = {"adl-drift": "ADL-Drift", "codegraph": "CodeGraph", "gitnexus": "Git Nexus", "graphify": "Graphify"}[graph]
1639
+ lines.append(f" Sample prompt: Use {tool} to map the call path for this feature.")
1640
+ if graph in {"gitnexus", "graphify"}:
1641
+ command = (
1642
+ (str(_gitnexus_executable(project)), "analyze", str(project), "--index-only", "--skip-git")
1643
+ if graph == "gitnexus" else
1644
+ (str(_venv_executable(project, "graphify")), "extract", str(project), "--code-only", "--out", str(project))
1645
+ )
1646
+ display = _graphify_command(command) if graph == "graphify" else _display_command(command)
1647
+ lines.append(f" Build the index manually: {display}")
1648
+ if graph == "graphify":
1649
+ executable = str(_venv_executable(project, "graphify"))
1650
+ lines.append(" Graphify's global uv tool is upgraded to the toolkit pin; reopen your terminal if PATH changed.")
1651
+ lines.append(" Run from the project directory: graphify extract . --code-only")
1652
+ lines.append(f" Generate HTML/report: {_graphify_command((executable, 'cluster-only', str(project), '--no-label'))}")
1653
+ lines.append(f" Query: {_graphify_command((executable, 'query', 'show the main modules', '--graph', str(project / 'graphify-out/graph.json')))}")
1654
+ lines.append(" Refresh after code changes: rerun the code-only build and HTML/report commands.")
1655
+ lines.append(f" Outputs: {project / 'graphify-out'} (graph.json, graph.html, GRAPH_REPORT.md)")
1656
+ lines.append(" Assistant skill: $graphify (Codex), /graphify (Claude Code); Cursor/Antigravity use Graphify by default through project rules.")
1657
+ return "\n".join(lines)
1658
+
1659
+
1660
+ def run_install(
1661
+ options: Options,
1662
+ *,
1663
+ emit: Callable[[str], None] = print,
1664
+ runner: Callable[[Step, Callable[[str], None]], None] = run_command,
1665
+ ) -> None:
1666
+ options = validate_options(options)
1667
+ existing = _load_manifest(options.project)
1668
+ if existing:
1669
+ if existing["graph"] != options.graph:
1670
+ raise InstallError(
1671
+ f"{existing['graph']} is already managed for this project; uninstall it first."
1672
+ )
1673
+ clients = tuple(dict.fromkeys([
1674
+ *existing.get("clients", [existing["client"]]), *options.clients,
1675
+ ]))
1676
+ options = replace(
1677
+ options, client=clients[0], clients=clients,
1678
+ addons=tuple(dict.fromkeys([*existing.get("addons", []), *options.addons])),
1679
+ extras=tuple(dict.fromkeys([*existing.get("extras", []), *options.extras])),
1680
+ package=options.package or (
1681
+ str(existing["package"]) if existing.get("package") else None
1682
+ ),
1683
+ index=options.index and bool(existing.get("index", False)),
1684
+ )
1685
+ emit(format_plan(options))
1686
+ if options.dry_run:
1687
+ emit("\nDry-run: no files written, dependencies downloaded, or processes run.")
1688
+ return
1689
+ preflight(options)
1690
+ adopted_global = _ensure_antigravity_slot(options)
1691
+ global_client = "antigravity" if "antigravity" in options.clients else ""
1692
+ manifest = _install_manifest(options)
1693
+ existing_instructions = _integration_instruction_state(options.project, manifest)
1694
+ if any(not state["removable"] for state in existing_instructions):
1695
+ raise InstallError(
1696
+ "Integration instructions were modified; restore or remove the managed block "
1697
+ "before reinstalling."
1698
+ )
1699
+ plan = build_plan(options)
1700
+ codegraph_guidance = (
1701
+ options.graph == "codegraph" and "antigravity" in options.clients
1702
+ )
1703
+ activate_instructions = bool(options.addons or codegraph_guidance)
1704
+ total_steps = len(plan) + int(activate_instructions)
1705
+ manifest["completed_steps"] = []
1706
+ manifest.pop("retry_command", None)
1707
+ _save_manifest(options.project, manifest)
1708
+ for number, step in enumerate(plan, 1):
1709
+ emit(f"\n[{number}/{total_steps}] {step.label}")
1710
+ before = _snapshot(options.project)
1711
+ before_global = _global_entry_snapshot(
1712
+ options.graph, global_client,
1713
+ )
1714
+ config_step = step.client is not None and step.label.startswith("Configure ")
1715
+ force_paths = (
1716
+ {"AGENTS.md", ".agents/rules/drift.md"}
1717
+ if options.graph == "adl-drift" and step.client == "antigravity" and config_step
1718
+ else set()
1719
+ )
1720
+ try:
1721
+ runner(step, emit)
1722
+ if options.graph == "codegraph" and step.client == "antigravity" and config_step:
1723
+ _configure_antigravity_codegraph(options.project)
1724
+ except BaseException:
1725
+ _record_changes(
1726
+ manifest, before, _snapshot(options.project), step.owner, force_paths,
1727
+ )
1728
+ _record_global_changes(
1729
+ manifest, before_global,
1730
+ _global_entry_snapshot(options.graph, global_client),
1731
+ force=config_step and step.client == "antigravity", adopted=adopted_global,
1732
+ )
1733
+ manifest["failed_step"] = step.label
1734
+ manifest["retry_command"] = _install_retry_command(options)
1735
+ _save_manifest(options.project, manifest)
1736
+ emit(
1737
+ f"\n✗ Stopped after {number - 1}/{total_steps} completed steps.\n"
1738
+ f" Retry: {manifest['retry_command']}"
1739
+ )
1740
+ raise
1741
+ _record_changes(
1742
+ manifest, before, _snapshot(options.project), step.owner, force_paths,
1743
+ )
1744
+ _record_global_changes(
1745
+ manifest, before_global,
1746
+ _global_entry_snapshot(options.graph, global_client),
1747
+ force=config_step and step.client == "antigravity", adopted=adopted_global,
1748
+ )
1749
+ manifest.pop("failed_step", None)
1750
+ if config_step and step.client:
1751
+ configured = manifest.setdefault("configured_clients", [])
1752
+ assert isinstance(configured, list)
1753
+ if step.client not in configured:
1754
+ configured.append(step.client)
1755
+ completed = manifest.setdefault("completed_steps", [])
1756
+ assert isinstance(completed, list)
1757
+ completed.append(step.label)
1758
+ _save_manifest(options.project, manifest)
1759
+ emit(f" ✓ Completed {number}/{total_steps} steps.")
1760
+ if activate_instructions:
1761
+ number = len(plan) + 1
1762
+ label = "Activate integration instructions"
1763
+ emit(f"\n[{number}/{total_steps}] {label}")
1764
+ active_addons = tuple(manifest.get("addons", options.addons))
1765
+ try:
1766
+ _activate_integration_instructions(
1767
+ options.project, manifest, options.clients, active_addons,
1768
+ codegraph=codegraph_guidance,
1769
+ )
1770
+ except BaseException:
1771
+ manifest["failed_step"] = label
1772
+ manifest["retry_command"] = _install_retry_command(options)
1773
+ _save_manifest(options.project, manifest)
1774
+ emit(
1775
+ f"\n✗ Stopped after {number - 1}/{total_steps} completed steps.\n"
1776
+ f" Retry: {manifest['retry_command']}"
1777
+ )
1778
+ raise
1779
+ manifest.pop("failed_step", None)
1780
+ manifest.pop("retry_command", None)
1781
+ completed = manifest.setdefault("completed_steps", [])
1782
+ assert isinstance(completed, list)
1783
+ completed.append(label)
1784
+ _save_manifest(options.project, manifest)
1785
+ emit(f" ✓ Completed {number}/{total_steps} steps.")
1786
+ manifest.pop("failed_step", None)
1787
+ manifest.pop("retry_command", None)
1788
+ _save_manifest(options.project, manifest)
1789
+ emit(_format_completion_summary(manifest, options.project, action="Installation"))
1790
+
1791
+
1792
+ def _runtime_update_steps(project: Path, manifest: dict[str, object]) -> tuple[Step, ...]:
1793
+ graph = str(manifest["graph"])
1794
+ if graph == "gitnexus":
1795
+ return (Step(
1796
+ f"Upgrade Git Nexus runtime ({GITNEXUS_PACKAGE})",
1797
+ ("npm", "install", "--prefix", str(project / RUNTIME_PATHS[graph]),
1798
+ "--no-audit", "--no-fund", GITNEXUS_PACKAGE), project, 1800,
1799
+ ),)
1800
+ if graph == "codegraph":
1801
+ return (
1802
+ Step(
1803
+ f"Upgrade CodeGraph runtime ({CODEGRAPH_PACKAGE})",
1804
+ (
1805
+ "npm", "install", "--prefix", str(project / RUNTIME_PATHS[graph]),
1806
+ "--no-audit", "--no-fund", CODEGRAPH_PACKAGE,
1807
+ ),
1808
+ project, 1800,
1809
+ ),
1810
+ _codegraph_global_step(
1811
+ project, f"Upgrade global CodeGraph command ({CODEGRAPH_PACKAGE})",
1812
+ ),
1813
+ )
1814
+ package = manifest.get("package")
1815
+ if graph == "other" and not package:
1816
+ raise InstallError(
1817
+ "This older custom-package installation did not save its pip package spec. "
1818
+ "Re-run `adl-toolkit install --graph other --package <name-or-pin> "
1819
+ f"--project {shlex.quote(str(project))}` once to save it, then retry update."
1820
+ )
1821
+ extras = tuple(str(item) for item in manifest.get("extras", ()))
1822
+ suffix = f"[{','.join(extras)}]" if extras else ""
1823
+ install_spec = str(package) if graph == "other" else f"{REPO_ROOT}{suffix}"
1824
+ if graph == "graphify":
1825
+ install_spec = GRAPHIFY_PACKAGE
1826
+ runtime_step = Step(
1827
+ "Upgrade graph runtime",
1828
+ (
1829
+ str(_venv_executable(project, "python")), "-m", "pip", "install",
1830
+ "--upgrade", install_spec,
1831
+ ),
1832
+ project, 1800,
1833
+ )
1834
+ return (runtime_step, _graphify_global_step(project)) if graph == "graphify" else (runtime_step,)
1835
+
1836
+
1837
+ def run_update(
1838
+ project: Path, *, dry_run: bool = False,
1839
+ emit: Callable[[str], None] = print,
1840
+ runner: Callable[[Step, Callable[[str], None]], None] = run_command,
1841
+ ) -> None:
1842
+ project = project.expanduser().resolve()
1843
+ if not project.is_dir():
1844
+ raise ValueError(f"Project directory does not exist: {project}")
1845
+ manifest = _load_manifest(project)
1846
+ if manifest is None:
1847
+ raise InstallError("No installation manifest found. Run `adl-toolkit install` first.")
1848
+ steps = _runtime_update_steps(project, manifest)
1849
+ runtime_ready, runtime_path = _runtime_ready(str(manifest["graph"]), project)
1850
+ if not runtime_ready:
1851
+ raise InstallError(
1852
+ f"Cannot update because the managed runtime is missing: {runtime_path}. "
1853
+ "Run the saved install command again to restore it."
1854
+ )
1855
+ emit("\nUpdate plan:")
1856
+ emit(f" Project: {project}")
1857
+ emit(f" Graph tool: {manifest['graph']}")
1858
+ if manifest["graph"] == "codegraph":
1859
+ emit(f" Approved CodeGraph pin: {CODEGRAPH_PACKAGE}")
1860
+ emit(" The shared global CodeGraph command will be aligned to this pin")
1861
+ emit(" Runtime only: clients, add-ons, instructions, and index are preserved")
1862
+ for number, step in enumerate(steps, 1):
1863
+ emit(f"\n {number}. {step.label}\n $ {_display_command(step.command)}")
1864
+ if dry_run:
1865
+ emit("\nDry-run: no files, configurations, or runtimes were changed.")
1866
+ return
1867
+ if manifest["graph"] == "codegraph":
1868
+ preflight(Options("codegraph", project, index=False))
1869
+ previous_failed_step = manifest.get("failed_step")
1870
+ previous_retry_command = manifest.get("retry_command")
1871
+ previous_completed_steps = list(manifest.get("completed_steps", ()))
1872
+ manifest["update_completed_steps"] = []
1873
+ manifest.pop("update_failed_step", None)
1874
+ manifest.pop("update_retry_command", None)
1875
+ _save_manifest(project, manifest)
1876
+ for number, step in enumerate(steps, 1):
1877
+ emit(f"\n[{number}/{len(steps)}] {step.label}")
1878
+ try:
1879
+ runner(step, emit)
1880
+ except BaseException:
1881
+ manifest["update_failed_step"] = step.label
1882
+ manifest["update_retry_command"] = _display_command(
1883
+ ("adl-toolkit", "update", "--project", str(project))
1884
+ )
1885
+ _save_manifest(project, manifest)
1886
+ emit(
1887
+ f"\n✗ Stopped after {number - 1}/{len(steps)} completed steps.\n"
1888
+ f" Retry: {manifest['update_retry_command']}"
1889
+ )
1890
+ raise
1891
+ completed = manifest.setdefault("update_completed_steps", [])
1892
+ assert isinstance(completed, list)
1893
+ completed.append(step.label)
1894
+ _save_manifest(project, manifest)
1895
+ emit(f" ✓ Completed {number}/{len(steps)} steps.")
1896
+ manifest.pop("update_failed_step", None)
1897
+ manifest.pop("update_retry_command", None)
1898
+ manifest["completed_steps"] = previous_completed_steps
1899
+ if previous_failed_step is None:
1900
+ manifest.pop("failed_step", None)
1901
+ else:
1902
+ manifest["failed_step"] = previous_failed_step
1903
+ if previous_retry_command is None:
1904
+ manifest.pop("retry_command", None)
1905
+ else:
1906
+ manifest["retry_command"] = previous_retry_command
1907
+ _save_manifest(project, manifest)
1908
+ emit(_format_completion_summary(manifest, project, action="Update"))
1909
+
1910
+
1911
+ def _runtime_ready(graph: str, project: Path) -> tuple[bool, Path]:
1912
+ if graph in {"gitnexus", "graphify"}:
1913
+ path = _gitnexus_executable(project) if graph == "gitnexus" else _venv_executable(project, "graphify")
1914
+ return path.is_file(), path
1915
+ if graph == "adl-drift":
1916
+ path = _venv_executable(project, "adl-drift")
1917
+ return path.is_file(), path
1918
+ if graph == "codegraph":
1919
+ path = _codegraph_executable(project)
1920
+ return path.is_file(), path
1921
+ path = _venv_executable(project, "python")
1922
+ return path.is_file(), path
1923
+
1924
+
1925
+ def _client_configured(graph: str, client: str, project: Path) -> tuple[bool, str]:
1926
+ if graph == "other":
1927
+ return True, "No automatic MCP configuration for custom packages"
1928
+ if graph == "graphify":
1929
+ for relative in _graphify_client_files(project, client):
1930
+ _assert_no_symlink(project, relative)
1931
+ if not (project / relative).is_file():
1932
+ return False, f"Missing Graphify skill or rule: {relative}"
1933
+ server_names = _global_server_names(graph, "antigravity")
1934
+ if client == "antigravity":
1935
+ expected_name, expected_entry = _expected_antigravity_entry(graph, project)
1936
+ found: list[str] = []
1937
+ for config in ANTIGRAVITY_CONFIGS:
1938
+ path, _, document = _read_antigravity_config(config)
1939
+ servers = document.get("mcpServers", {})
1940
+ assert isinstance(servers, dict)
1941
+ for name in server_names:
1942
+ if name in servers:
1943
+ if name == expected_name and servers[name] == expected_entry:
1944
+ return True, str(path)
1945
+ found.append(f"{path} ({name})")
1946
+ if found:
1947
+ return False, "Different project entry in " + ", ".join(found)
1948
+ return False, "Antigravity MCP entry is missing"
1949
+
1950
+ relative = CLIENT_CONFIG_PATHS[client]
1951
+ path = project / relative
1952
+ _assert_no_symlink(project, relative)
1953
+ if not path.is_file():
1954
+ return False, f"Missing {relative.as_posix()}"
1955
+ names = ("adl-drift", "drift") if graph == "adl-drift" else (graph,)
1956
+ try:
1957
+ if client == "codex":
1958
+ text = path.read_text(encoding="utf-8")
1959
+ entries = []
1960
+ for name in names:
1961
+ match = re.search(
1962
+ rf"(?ms)^\[mcp_servers\.{re.escape(name)}\]\s*\n(.*?)"
1963
+ rf"(?=^\[|\Z)", text,
1964
+ )
1965
+ if match:
1966
+ values = dict(re.findall(r"(?m)^(command|args)\s*=\s*(.+)$", match.group(1)))
1967
+ entries.append({key: json.loads(value) for key, value in values.items()})
1968
+ else:
1969
+ document = json.loads(path.read_text(encoding="utf-8"))
1970
+ servers = document.get("mcpServers", {}) if isinstance(document, dict) else {}
1971
+ entries = [servers[name] for name in names if name in servers] if isinstance(servers, dict) else []
1972
+ except (OSError, UnicodeError, json.JSONDecodeError):
1973
+ return False, f"Cannot read a valid MCP configuration from {relative.as_posix()}"
1974
+ if not entries:
1975
+ return False, f"Graph server entry is missing from {relative.as_posix()}"
1976
+ for entry in entries:
1977
+ if not isinstance(entry, dict):
1978
+ continue
1979
+ if graph in {"gitnexus", "graphify"}:
1980
+ expected = _managed_mcp_entry(graph, project)
1981
+ ready = all(entry.get(key) == value for key, value in expected.items())
1982
+ elif graph == "adl-drift":
1983
+ expected = _expected_antigravity_entry(graph, project)[1]
1984
+ if client in {"claude", "cursor"}:
1985
+ expected["args"] = ["-m", "adl_drift.server.mcp_stdio", "--project", "."]
1986
+ ready = all(entry.get(key) == value for key, value in expected.items())
1987
+ else:
1988
+ command, args = entry.get("command"), entry.get("args")
1989
+ ready = (
1990
+ isinstance(command, str)
1991
+ and re.split(r"[/\\]", command)[-1] in {"codegraph", "codegraph.cmd", "codegraph.exe"}
1992
+ and args in (["serve", "--mcp"], ["serve", "--mcp", "--path", str(project)])
1993
+ )
1994
+ if ready:
1995
+ return True, f"Server entry matches this project in {relative.as_posix()} (live handshake not run)"
1996
+ return False, f"Graph server command or arguments do not match this project in {relative.as_posix()}"
1997
+
1998
+
1999
+ def _artifact_digest(project: Path, manifest: dict[str, object], relative: str) -> str | None:
2000
+ path = project / _safe_relative(relative)
2001
+ if not path.is_file():
2002
+ return None
2003
+ content = _without_integration_instructions(manifest, relative, path.read_bytes())
2004
+ return _digest(content) if content is not None else None
2005
+
2006
+
2007
+ def inspect_health(project: Path) -> HealthReport:
2008
+ project = project.expanduser().resolve()
2009
+ if not project.is_dir():
2010
+ raise ValueError(f"Project directory does not exist: {project}")
2011
+ manifest = _load_manifest(project)
2012
+ if manifest is None:
2013
+ return HealthReport(project, None, (), (), (
2014
+ HealthCheck(
2015
+ "installation", "manifest", "error",
2016
+ "No ADL Toolkit installation manifest was found.",
2017
+ "Run `adl-toolkit install` for this project.",
2018
+ ),
2019
+ ))
2020
+
2021
+ graph = str(manifest["graph"])
2022
+ clients = tuple(str(item) for item in manifest.get("clients", [manifest["client"]]))
2023
+ configured = set(str(item) for item in manifest.get("configured_clients", clients))
2024
+ addons = tuple(str(item) for item in manifest.get("addons", ()))
2025
+ checks: list[HealthCheck] = []
2026
+
2027
+ runtime_ready, runtime_path = _runtime_ready(graph, project)
2028
+ checks.append(HealthCheck(
2029
+ "runtime", graph, "ok" if runtime_ready else "error",
2030
+ str(runtime_path) if runtime_ready else f"Missing runtime executable: {runtime_path}",
2031
+ None if runtime_ready else "Run `adl-toolkit install` again to restore the runtime.",
2032
+ ))
2033
+
2034
+ for client in clients:
2035
+ ready, detail = _client_configured(graph, client, project)
2036
+ if graph != "other" and client not in configured:
2037
+ ready = False
2038
+ detail = "Client setup did not complete"
2039
+ checks.append(HealthCheck(
2040
+ "client", client, "ok" if ready else "error", detail,
2041
+ None if ready else "Run `adl-toolkit repair`.",
2042
+ ))
2043
+ relative = CLIENT_CONFIG_PATHS.get(client)
2044
+ if relative is not None:
2045
+ artifacts = manifest.get("artifacts", {})
2046
+ assert isinstance(artifacts, dict)
2047
+ record = artifacts.get(relative.as_posix())
2048
+ if isinstance(record, dict):
2049
+ current = _artifact_digest(project, manifest, relative.as_posix())
2050
+ if current is not None and current != record.get("installed_sha256"):
2051
+ checks.append(HealthCheck(
2052
+ "client", client, "warning",
2053
+ f"{relative.as_posix()} changed after installation; user changes are preserved",
2054
+ ))
2055
+
2056
+ instruction_paths = set()
2057
+ instructions = manifest.get("integration_instructions", {})
2058
+ if isinstance(instructions, dict):
2059
+ instruction_paths = set(instructions)
2060
+ expected_instruction_clients: dict[str, set[str]] = {}
2061
+ for client in clients:
2062
+ if addons or (graph == "codegraph" and client == "antigravity"):
2063
+ key = INTEGRATION_INSTRUCTION_PATHS[client].as_posix()
2064
+ expected_instruction_clients.setdefault(key, set()).add(client)
2065
+ for relative, expected_clients in sorted(expected_instruction_clients.items()):
2066
+ record = instructions.get(relative)
2067
+ if not isinstance(record, dict):
2068
+ checks.append(HealthCheck(
2069
+ "instructions", relative, "error", "Managed instruction block is missing",
2070
+ "Run `adl-toolkit repair`.",
2071
+ ))
2072
+ continue
2073
+ record_clients = set(str(item) for item in record.get("clients", ()))
2074
+ record_addons = set(str(item) for item in record.get("addons", ()))
2075
+ expected_codegraph = graph == "codegraph" and "antigravity" in expected_clients
2076
+ if (not expected_clients <= record_clients or record_addons != set(addons)
2077
+ or bool(record.get("codegraph", False)) != expected_codegraph):
2078
+ checks.append(HealthCheck(
2079
+ "instructions", relative, "error",
2080
+ "Managed instructions do not match the saved selections",
2081
+ "Run `adl-toolkit repair`.",
2082
+ ))
2083
+ states = {
2084
+ state["relative"].as_posix(): state
2085
+ for state in _integration_instruction_state(project, manifest)
2086
+ if isinstance(state.get("relative"), Path)
2087
+ }
2088
+ for relative in sorted(instructions):
2089
+ state = states[relative]
2090
+ if state["removable"]:
2091
+ checks.append(HealthCheck("instructions", relative, "ok", "Managed block is active"))
2092
+ else:
2093
+ path = project / _safe_relative(relative)
2094
+ missing = not path.exists()
2095
+ checks.append(HealthCheck(
2096
+ "instructions", relative, "error",
2097
+ "Managed instruction file is missing" if missing
2098
+ else "Managed instruction block was changed or removed",
2099
+ "Run `adl-toolkit repair`; modified managed blocks are preserved for manual review.",
2100
+ ))
2101
+
2102
+ artifacts = manifest.get("artifacts", {})
2103
+ assert isinstance(artifacts, dict)
2104
+ config_paths = {path.as_posix() for path in CLIENT_CONFIG_PATHS.values()}
2105
+ for relative, record in sorted(artifacts.items()):
2106
+ assert isinstance(record, dict)
2107
+ if relative in instruction_paths or relative in config_paths:
2108
+ continue
2109
+ owner = str(record.get("owner", ""))
2110
+ if owner.startswith("addon:"):
2111
+ continue
2112
+ current = _artifact_digest(project, manifest, relative)
2113
+ expected = record.get("installed_sha256")
2114
+ if current == expected:
2115
+ checks.append(HealthCheck("managed file", relative, "ok", "Present and unchanged"))
2116
+ elif current is None:
2117
+ checks.append(HealthCheck(
2118
+ "managed file", relative, "error", "Managed file is missing",
2119
+ "Run `adl-toolkit repair`.",
2120
+ ))
2121
+ else:
2122
+ checks.append(HealthCheck(
2123
+ "managed file", relative, "warning",
2124
+ "File changed after installation; repair will preserve it",
2125
+ ))
2126
+
2127
+ checked_skills: set[tuple[Path, str]] = set()
2128
+ for addon in addons:
2129
+ for client in clients:
2130
+ root = INTEGRATION_SKILL_ROOTS[client]
2131
+ key = (root, addon)
2132
+ if key in checked_skills:
2133
+ continue
2134
+ checked_skills.add(key)
2135
+ relative = root / addon / "SKILL.md"
2136
+ path = project / relative
2137
+ if path.is_file():
2138
+ checks.append(HealthCheck("integration", f"{addon} ({root})", "ok", str(relative)))
2139
+ else:
2140
+ source = ADDON_SOURCES[addon]
2141
+ agent = SKILLS_AGENT[client]
2142
+ command = _display_command((
2143
+ "npx", "--yes", SKILLS_PACKAGE, "add", source,
2144
+ "--skill", "*", "-a", agent, "--copy", "--yes",
2145
+ ))
2146
+ checks.append(HealthCheck(
2147
+ "integration", f"{addon} ({root})", "error",
2148
+ f"Missing {relative.as_posix()}",
2149
+ f"Reinstall the selected skills: `{command}`",
2150
+ ))
2151
+
2152
+ index_paths = INDEX_PATHS[graph]
2153
+ if not index_paths:
2154
+ checks.append(HealthCheck("index", graph, "info", "Not managed for custom packages"))
2155
+ else:
2156
+ present = [path for path in index_paths if (project / path).exists()]
2157
+ detail = (
2158
+ "Present (freshness is not checked): " + ", ".join(path.as_posix() for path in present)
2159
+ if present else "Not found (it may have been skipped during installation)"
2160
+ )
2161
+ checks.append(HealthCheck("index", graph, "info", detail))
2162
+
2163
+ if manifest.get("failed_step"):
2164
+ checks.append(HealthCheck(
2165
+ "installation", "last attempt", "error",
2166
+ f"Incomplete step: {manifest['failed_step']}",
2167
+ "Run `adl-toolkit repair`; reinstall if it reports a missing runtime or skill.",
2168
+ ))
2169
+ if manifest.get("update_failed_step"):
2170
+ retry = str(manifest.get(
2171
+ "update_retry_command",
2172
+ _display_command(("adl-toolkit", "update", "--project", str(project))),
2173
+ ))
2174
+ checks.append(HealthCheck(
2175
+ "installation", "last update", "error",
2176
+ f"Incomplete update step: {manifest['update_failed_step']}",
2177
+ f"Retry the runtime update: `{retry}`",
2178
+ ))
2179
+ if manifest.get("uninstall_error"):
2180
+ checks.append(HealthCheck(
2181
+ "installation", "uninstall", "error",
2182
+ f"Previous uninstall failed: {manifest['uninstall_error']}",
2183
+ "Retry `adl-toolkit uninstall`.",
2184
+ ))
2185
+ return HealthReport(project, graph, clients, addons, tuple(checks))
2186
+
2187
+
2188
+ def format_health(report: HealthReport, *, title: str = "Toolkit status") -> str:
2189
+ lines = [
2190
+ f"\n{title}:",
2191
+ f" Project: {report.project}",
2192
+ f" Graph tool: {report.graph or 'not installed'}",
2193
+ f" Clients: {', '.join(report.clients) or 'none'}",
2194
+ f" Integrations: {', '.join(report.addons) or 'none'}",
2195
+ ]
2196
+ symbols = {"ok": "✓", "error": "✗", "warning": "!", "info": "•"}
2197
+ for check in report.checks:
2198
+ lines.append(
2199
+ f" {symbols[check.state]} {check.category} / {check.name}: {check.detail}"
2200
+ )
2201
+ if check.action:
2202
+ lines.append(f" → {check.action}")
2203
+ lines.append(
2204
+ "\n✓ Installation file checks passed."
2205
+ if report.healthy else "\n✗ Installation needs attention."
2206
+ )
2207
+ lines.append("Live MCP connection and index freshness are not checked.")
2208
+ return "\n".join(lines)
2209
+
2210
+
2211
+ def run_status(
2212
+ project: Path, *, emit: Callable[[str], None] = print, doctor: bool = False,
2213
+ ) -> bool:
2214
+ report = inspect_health(project)
2215
+ emit(format_health(report, title="Toolkit doctor" if doctor else "Toolkit status"))
2216
+ if doctor and report.healthy and report.graph == "adl-drift":
2217
+ step = Step(
2218
+ "Check live MCP handshake",
2219
+ (str(_venv_executable(report.project, "adl-drift")),
2220
+ "-p", str(report.project), "mcp", "doctor"),
2221
+ report.project, 90,
2222
+ )
2223
+ try:
2224
+ run_command(step, emit)
2225
+ except InstallError as error:
2226
+ emit(f"\n✗ Live MCP connection failed:\n{error}")
2227
+ return False
2228
+ return report.healthy
2229
+
2230
+
2231
+ def _client_setup_step(graph: str, project: Path, client: str) -> Step | None:
2232
+ if graph in {"gitnexus", "graphify"}:
2233
+ return _managed_mcp_step(graph, project, client)
2234
+ if graph == "adl-drift":
2235
+ return Step(
2236
+ f"Repair MCP client ({client})",
2237
+ (str(_venv_executable(project, "adl-drift")), "-p", str(project),
2238
+ "mcp", "setup", f"--{client}"),
2239
+ project, owner=f"client:{client}", client=client,
2240
+ )
2241
+ if graph == "codegraph":
2242
+ return Step(
2243
+ f"Repair CodeGraph client ({client})",
2244
+ (str(_codegraph_executable(project)), "install", "--target", client,
2245
+ "--location", "global" if client == "antigravity" else "local",
2246
+ "--yes", "--no-permissions"),
2247
+ project, owner=f"client:{client}", client=client,
2248
+ )
2249
+ return None
2250
+
2251
+
2252
+ def _client_owned_artifacts(
2253
+ manifest: dict[str, object], client: str,
2254
+ ) -> dict[str, dict[str, object]]:
2255
+ artifacts = manifest.get("artifacts", {})
2256
+ assert isinstance(artifacts, dict)
2257
+ owners = {f"client:{client}"}
2258
+ if client == manifest.get("client"):
2259
+ owners.add("graph")
2260
+ return {
2261
+ relative: record for relative, record in artifacts.items()
2262
+ if isinstance(record, dict) and record.get("owner") in owners
2263
+ }
2264
+
2265
+
2266
+ def _repair_instruction_blocks(
2267
+ project: Path, manifest: dict[str, object], *, dry_run: bool,
2268
+ emit: Callable[[str], None],
2269
+ ) -> bool:
2270
+ addons = tuple(str(item) for item in manifest.get("addons", ()))
2271
+ clients = tuple(str(item) for item in manifest.get("clients", [manifest["client"]]))
2272
+ graph = str(manifest["graph"])
2273
+ expected: dict[Path, list[str]] = {}
2274
+ for client in clients:
2275
+ if addons or (graph == "codegraph" and client == "antigravity"):
2276
+ expected.setdefault(INTEGRATION_INSTRUCTION_PATHS[client], []).append(client)
2277
+ if not expected:
2278
+ return True
2279
+
2280
+ records = manifest.setdefault("integration_instructions", {})
2281
+ assert isinstance(records, dict)
2282
+ complete = True
2283
+ for relative, path_clients in expected.items():
2284
+ _assert_no_symlink(project, relative)
2285
+ key = relative.as_posix()
2286
+ path = project / relative
2287
+ record = records.get(key)
2288
+ if record is not None:
2289
+ assert isinstance(record, dict)
2290
+ stored = str(record["content"])
2291
+ try:
2292
+ current = path.read_text(encoding="utf-8") if path.is_file() else ""
2293
+ except (OSError, UnicodeError):
2294
+ emit(f" ! Preserved unreadable integration instructions: {key}")
2295
+ complete = False
2296
+ continue
2297
+ if current.count(stored) == 1:
2298
+ merged_clients = tuple(dict.fromkeys([
2299
+ *(str(item) for item in record.get("clients", ())), *path_clients,
2300
+ ]))
2301
+ desired_codegraph = bool(record.get("codegraph", False)) or (
2302
+ graph == "codegraph" and "antigravity" in merged_clients
2303
+ )
2304
+ desired = _integration_content(
2305
+ merged_clients[0], addons,
2306
+ file_preexisting=bool(record["file_preexisting"]),
2307
+ codegraph=desired_codegraph,
2308
+ )
2309
+ if desired != stored:
2310
+ emit(f" {'$' if dry_run else '✓'} Refresh integration instructions: {key}")
2311
+ if not dry_run:
2312
+ path.write_text(current.replace(stored, desired, 1), encoding="utf-8")
2313
+ if not dry_run:
2314
+ record.update({
2315
+ "clients": list(merged_clients), "addons": list(addons),
2316
+ "codegraph": desired_codegraph, "content": desired,
2317
+ })
2318
+ continue
2319
+ if INTEGRATION_BEGIN in current or INTEGRATION_END in current:
2320
+ emit(f" ! Preserved modified integration instructions: {key}")
2321
+ complete = False
2322
+ continue
2323
+ emit(f" {'$' if dry_run else '✓'} Restore integration instructions: {key}")
2324
+ if not dry_run:
2325
+ path.parent.mkdir(parents=True, exist_ok=True)
2326
+ path.write_text(current + stored, encoding="utf-8")
2327
+ continue
2328
+
2329
+ if path.exists() and not path.is_file():
2330
+ emit(f" ! Preserved non-file integration path: {key}")
2331
+ complete = False
2332
+ continue
2333
+ try:
2334
+ current = path.read_text(encoding="utf-8") if path.is_file() else ""
2335
+ except (OSError, UnicodeError):
2336
+ emit(f" ! Preserved unreadable integration instructions: {key}")
2337
+ complete = False
2338
+ continue
2339
+ if INTEGRATION_BEGIN in current or INTEGRATION_END in current:
2340
+ emit(f" ! Preserved unowned integration markers: {key}")
2341
+ complete = False
2342
+ continue
2343
+ if path_clients[0] == "cursor" and path.exists():
2344
+ emit(f" ! Preserved existing Cursor integration rule: {key}")
2345
+ complete = False
2346
+ continue
2347
+ file_preexisting = path.exists()
2348
+ codegraph = graph == "codegraph" and "antigravity" in path_clients
2349
+ content = _integration_content(
2350
+ path_clients[0], addons, file_preexisting=file_preexisting,
2351
+ codegraph=codegraph,
2352
+ )
2353
+ emit(f" {'$' if dry_run else '✓'} Create integration instructions: {key}")
2354
+ if not dry_run:
2355
+ path.parent.mkdir(parents=True, exist_ok=True)
2356
+ path.write_text(current + content, encoding="utf-8")
2357
+ records[key] = {
2358
+ "path": key, "clients": list(path_clients), "addons": list(addons),
2359
+ "codegraph": codegraph, "file_preexisting": file_preexisting,
2360
+ "content": content,
2361
+ }
2362
+ return complete
2363
+
2364
+
2365
+ def run_repair(
2366
+ project: Path, *, dry_run: bool = False,
2367
+ emit: Callable[[str], None] = print,
2368
+ runner: Callable[[Step, Callable[[str], None]], None] = run_command,
2369
+ ) -> bool:
2370
+ project = project.expanduser().resolve()
2371
+ if not project.is_dir():
2372
+ raise ValueError(f"Project directory does not exist: {project}")
2373
+ manifest = _load_manifest(project)
2374
+ if manifest is None:
2375
+ raise InstallError("No installation manifest found. Run `adl-toolkit install` first.")
2376
+ graph = str(manifest["graph"])
2377
+ clients = tuple(str(item) for item in manifest.get("clients", [manifest["client"]]))
2378
+ configured = manifest.setdefault("configured_clients", [])
2379
+ assert isinstance(configured, list)
2380
+ runtime_ready, runtime_path = _runtime_ready(graph, project)
2381
+ emit("\nRepair plan:" if dry_run else "\nRepairing ADL Toolkit:")
2382
+ emit(f" Project: {project}")
2383
+ if not runtime_ready:
2384
+ emit(f" ! Runtime is missing: {runtime_path}")
2385
+ emit(" Run `adl-toolkit install` again; repair does not download runtimes.")
2386
+
2387
+ instructions = manifest.get("integration_instructions", {})
2388
+ instruction_paths = set(instructions) if isinstance(instructions, dict) else set()
2389
+ for client in clients:
2390
+ ready, detail = _client_configured(graph, client, project)
2391
+ owned = _client_owned_artifacts(manifest, client)
2392
+ missing_owned = [
2393
+ relative for relative in owned
2394
+ if relative not in instruction_paths
2395
+ and _artifact_digest(project, manifest, relative) is None
2396
+ ]
2397
+ needs_repair = client not in configured or not ready or bool(missing_owned)
2398
+ if not needs_repair:
2399
+ continue
2400
+ modified = [
2401
+ relative for relative, record in owned.items()
2402
+ if relative not in instruction_paths
2403
+ and _artifact_digest(project, manifest, relative) is not None
2404
+ and _artifact_digest(project, manifest, relative) != record.get("installed_sha256")
2405
+ ]
2406
+ if modified:
2407
+ emit(
2408
+ f" ! Preserved modified {client} files; skipped client repair: "
2409
+ + ", ".join(modified)
2410
+ )
2411
+ continue
2412
+ if client == "antigravity" and not ready and detail.startswith("Different project entry"):
2413
+ emit(f" ! Preserved Antigravity configuration: {detail}")
2414
+ continue
2415
+ step = _client_setup_step(graph, project, client)
2416
+ if step is None:
2417
+ emit(f" ! {client}: custom packages require manual client configuration")
2418
+ continue
2419
+ if not runtime_ready:
2420
+ continue
2421
+ if dry_run:
2422
+ emit(f" $ {_display_command(step.command)}")
2423
+ continue
2424
+
2425
+ adopted: set[str] = set()
2426
+ if client == "antigravity":
2427
+ adopted = _ensure_antigravity_slot(Options(
2428
+ graph, project, client=client, clients=(client,), index=False,
2429
+ ))
2430
+ before = _snapshot(project)
2431
+ before_global = _global_entry_snapshot(
2432
+ graph, "antigravity" if client == "antigravity" else "",
2433
+ )
2434
+ owner = "graph" if client == manifest.get("client") else f"client:{client}"
2435
+ force_paths = (
2436
+ {"AGENTS.md", ".agents/rules/drift.md"}
2437
+ if graph == "adl-drift" and client == "antigravity" else set()
2438
+ )
2439
+ try:
2440
+ runner(step, emit)
2441
+ if graph == "codegraph" and client == "antigravity":
2442
+ _configure_antigravity_codegraph(project)
2443
+ except BaseException:
2444
+ _record_changes(manifest, before, _snapshot(project), owner, force_paths)
2445
+ _record_global_changes(
2446
+ manifest, before_global,
2447
+ _global_entry_snapshot(graph, "antigravity" if client == "antigravity" else ""),
2448
+ force=client == "antigravity", adopted=adopted,
2449
+ )
2450
+ manifest["failed_step"] = step.label
2451
+ _save_manifest(project, manifest)
2452
+ raise
2453
+ _record_changes(manifest, before, _snapshot(project), owner, force_paths)
2454
+ _record_global_changes(
2455
+ manifest, before_global,
2456
+ _global_entry_snapshot(graph, "antigravity" if client == "antigravity" else ""),
2457
+ force=client == "antigravity", adopted=adopted,
2458
+ )
2459
+ if client not in configured:
2460
+ configured.append(client)
2461
+ emit(f" ✓ Repaired client configuration: {client}")
2462
+ _save_manifest(project, manifest)
2463
+
2464
+ instructions_complete = _repair_instruction_blocks(
2465
+ project, manifest, dry_run=dry_run, emit=emit,
2466
+ )
2467
+ if not dry_run:
2468
+ failed_step = str(manifest.get("failed_step", ""))
2469
+ if (failed_step.startswith(("Configure ", "Repair "))
2470
+ and all(_client_configured(graph, client, project)[0] for client in clients)):
2471
+ manifest.pop("failed_step", None)
2472
+ elif failed_step == "Activate integration instructions" and instructions_complete:
2473
+ manifest.pop("failed_step", None)
2474
+ _save_manifest(project, manifest)
2475
+
2476
+ report = inspect_health(project)
2477
+ missing_skills = [
2478
+ check for check in report.checks
2479
+ if check.category == "integration" and check.state == "error"
2480
+ ]
2481
+ for check in missing_skills:
2482
+ if check.action:
2483
+ emit(f" ! {check.name}: {check.action}")
2484
+ if dry_run:
2485
+ emit("\nDry-run: no files or configurations were changed.")
2486
+ return report.healthy
2487
+ emit(format_health(report, title="Repair result"))
2488
+ return report.healthy
2489
+
2490
+
2491
+ def _current_digest(project: Path, relative: Path) -> str | None:
2492
+ _assert_no_symlink(project, relative)
2493
+ path = project / relative
2494
+ return _digest(path.read_bytes()) if path.is_file() else None
2495
+
2496
+
2497
+ def _remove_tree(project: Path, relative: Path) -> None:
2498
+ _assert_no_symlink(project, relative)
2499
+ path = project / relative
2500
+ if path.is_dir():
2501
+ shutil.rmtree(path)
2502
+ elif path.exists():
2503
+ raise InstallError(f"Expected a directory but found a file: {path}")
2504
+
2505
+
2506
+ def _prune_empty_parents(project: Path, path: Path) -> None:
2507
+ roots = {project / root for root in MANAGED_ROOTS}
2508
+ parent = path.parent
2509
+ while parent != project and any(parent == root or root in parent.parents for root in roots):
2510
+ try:
2511
+ parent.rmdir()
2512
+ except OSError:
2513
+ break
2514
+ parent = parent.parent
2515
+
2516
+
2517
+ def _graph_uninstall_step(graph: str, project: Path, client: str) -> Step | None:
2518
+ if graph == "adl-drift":
2519
+ return Step(
2520
+ "Remove ADL-Drift client configuration",
2521
+ (str(_venv_executable(project, "adl-drift")), "-p", str(project),
2522
+ "mcp", "uninstall", f"--{client}"),
2523
+ project, client=client,
2524
+ )
2525
+ if graph == "codegraph":
2526
+ return Step(
2527
+ "Remove CodeGraph client configuration",
2528
+ (str(_codegraph_executable(project)), "uninstall", "--target", client,
2529
+ "--location", "global" if client == "antigravity" else "local",
2530
+ "--yes", "--keep-cli"),
2531
+ project, client=client,
2532
+ )
2533
+ return None
2534
+
2535
+
2536
+ def _validate_uninstall_options(options: UninstallOptions) -> UninstallOptions:
2537
+ project = options.project.expanduser().resolve()
2538
+ if not project.is_dir():
2539
+ raise ValueError(f"Project directory does not exist: {project}")
2540
+ if options.graph is not None and options.graph not in GRAPHS:
2541
+ raise ValueError(f"Invalid graph tool: {options.graph}")
2542
+ if options.client is not None and options.client not in CLIENTS:
2543
+ raise ValueError(f"Invalid client: {options.client}")
2544
+ unknown = set(options.addons) - ADDON_SOURCES.keys()
2545
+ if unknown:
2546
+ raise ValueError(f"Invalid add-on: {', '.join(sorted(unknown))}")
2547
+ unknown_clients = set(options.remove_clients) - set(CLIENTS)
2548
+ if unknown_clients:
2549
+ raise ValueError(f"Invalid client: {', '.join(sorted(unknown_clients))}")
2550
+ unknown_addons = set(options.remove_addons) - ADDON_SOURCES.keys()
2551
+ if unknown_addons:
2552
+ raise ValueError(f"Invalid add-on: {', '.join(sorted(unknown_addons))}")
2553
+ if options.purge_index and (options.remove_clients or options.remove_addons):
2554
+ raise ValueError("--purge-index cannot be used with selective removal")
2555
+ return UninstallOptions(
2556
+ project, options.graph, options.client, tuple(dict.fromkeys(options.addons)),
2557
+ options.yes, options.dry_run, options.purge_index,
2558
+ tuple(dict.fromkeys(options.remove_clients)),
2559
+ tuple(dict.fromkeys(options.remove_addons)),
2560
+ )
2561
+
2562
+
2563
+ def _preflight_uninstall(steps: Sequence[Step]) -> None:
2564
+ missing: list[str] = []
2565
+ for step in steps:
2566
+ executable = step.command[0]
2567
+ if os.path.sep in executable or (os.altsep and os.altsep in executable):
2568
+ if not Path(executable).is_file():
2569
+ missing.append(executable)
2570
+ elif shutil.which(executable) is None:
2571
+ missing.append(executable)
2572
+ if missing:
2573
+ raise InstallError(
2574
+ "Missing uninstall tools: " + ", ".join(sorted(set(missing)))
2575
+ + ". No files were changed."
2576
+ )
2577
+
2578
+
2579
+ def _restore_lock(
2580
+ project: Path, manifest: dict[str, object], selected: set[str],
2581
+ before_skills: dict[str, object], emit: Callable[[str], None],
2582
+ ) -> set[str]:
2583
+ lock = manifest.get("lock", {})
2584
+ assert isinstance(lock, dict)
2585
+ entries = lock.get("entries", {})
2586
+ assert isinstance(entries, dict)
2587
+ path = project / "skills-lock.json"
2588
+ try:
2589
+ document = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {
2590
+ "version": 1, "skills": {},
2591
+ }
2592
+ except (OSError, json.JSONDecodeError):
2593
+ emit(" ! Preserved modified skills-lock.json")
2594
+ return set(entries)
2595
+ if not isinstance(document, dict) or not isinstance(document.get("skills", {}), dict):
2596
+ emit(" ! Preserved modified skills-lock.json")
2597
+ return set(entries)
2598
+ skills = document.setdefault("skills", {})
2599
+ retained: set[str] = set()
2600
+ for name, record in entries.items():
2601
+ if not isinstance(record, dict) or record.get("addon") not in selected:
2602
+ retained.add(name)
2603
+ continue
2604
+ if before_skills.get(name) != record.get("installed"):
2605
+ emit(f" ! Preserved modified skill lock entry: {name}")
2606
+ retained.add(name)
2607
+ continue
2608
+ baseline = record.get("baseline")
2609
+ if baseline is None:
2610
+ skills.pop(name, None)
2611
+ else:
2612
+ skills[name] = baseline
2613
+ if not skills and not lock.get("preexisting"):
2614
+ if path.exists():
2615
+ path.unlink()
2616
+ else:
2617
+ path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
2618
+ return retained
2619
+
2620
+
2621
+ def _restore_artifacts(
2622
+ project: Path, manifest: dict[str, object], removable_owners: set[str],
2623
+ before_digests: dict[str, str | None], emit: Callable[[str], None],
2624
+ skip_restore: set[str] | None = None,
2625
+ ) -> set[str]:
2626
+ artifacts = manifest.get("artifacts", {})
2627
+ assert isinstance(artifacts, dict)
2628
+ retained: set[str] = set()
2629
+ for value, entry in list(artifacts.items()):
2630
+ assert isinstance(entry, dict)
2631
+ if value in (skip_restore or set()):
2632
+ retained.add(value)
2633
+ continue
2634
+ if entry.get("owner") not in removable_owners:
2635
+ if before_digests[value] != entry.get("installed_sha256"):
2636
+ emit(f" ! Preserved modified file: {value}")
2637
+ retained.add(value)
2638
+ continue
2639
+ if before_digests[value] != entry.get("installed_sha256"):
2640
+ emit(f" ! Preserved modified file: {value}")
2641
+ retained.add(value)
2642
+ continue
2643
+ relative = _safe_relative(value)
2644
+ path = project / relative
2645
+ baseline = _decoded(entry.get("baseline"))
2646
+ if baseline is None:
2647
+ if path.is_file():
2648
+ path.unlink()
2649
+ _prune_empty_parents(project, path)
2650
+ else:
2651
+ path.parent.mkdir(parents=True, exist_ok=True)
2652
+ path.write_bytes(baseline)
2653
+ return retained
2654
+
2655
+
2656
+ def _global_entry_matches(
2657
+ current: dict[str, object], record: dict[str, object], prefix: str,
2658
+ ) -> bool:
2659
+ return (
2660
+ current.get("present") == record.get(f"{prefix}_present")
2661
+ and current.get("value") == record.get(prefix)
2662
+ )
2663
+
2664
+
2665
+ def _codex_uses_adl_runtime(project: Path, *, workflow_only: bool = False) -> bool:
2666
+ path = project / ".codex/config.toml"
2667
+ if not path.is_file():
2668
+ return False
2669
+ try:
2670
+ text = path.read_text(encoding="utf-8")
2671
+ except OSError:
2672
+ return False
2673
+ expected = _expected_antigravity_entry("adl-drift", project)
2674
+ expected_entry = expected[1]
2675
+ for name in ("adl-drift", "drift"):
2676
+ match = re.search(
2677
+ rf"(?ms)^\[mcp_servers\.{re.escape(name)}\]\n(.*?)"
2678
+ rf"(?=^\[(?!mcp_servers\.{re.escape(name)}(?:\.|\]))|\Z)",
2679
+ text,
2680
+ )
2681
+ if not match:
2682
+ continue
2683
+ values = dict(re.findall(r"(?m)^(command|args)\s*=\s*(.+)$", match.group(1)))
2684
+ try:
2685
+ entry = {"command": json.loads(values["command"]), "args": json.loads(values["args"])}
2686
+ except (KeyError, json.JSONDecodeError):
2687
+ continue
2688
+ if entry == expected_entry or (workflow_only and entry.get("args") == expected_entry["args"]):
2689
+ return True
2690
+ return False
2691
+
2692
+
2693
+ def _restore_global_entries(
2694
+ manifest: dict[str, object], current: dict[str, dict[str, object]],
2695
+ emit: Callable[[str], None],
2696
+ ) -> set[str]:
2697
+ entries = manifest.get("global_entries", {})
2698
+ assert isinstance(entries, dict)
2699
+ retained: set[str] = set()
2700
+ by_config: dict[str, list[tuple[str, str, dict[str, object]]]] = {}
2701
+ for key, record in entries.items():
2702
+ assert isinstance(record, dict)
2703
+ config, server = key.split("/", 1)
2704
+ by_config.setdefault(config, []).append((key, server, record))
2705
+
2706
+ for config, records in by_config.items():
2707
+ path, _, document = _read_antigravity_config(config)
2708
+ servers = document.setdefault("mcpServers", {})
2709
+ assert isinstance(servers, dict)
2710
+ for key, server, record in records:
2711
+ state = current[key]
2712
+ if record["baseline_present"]:
2713
+ # The wrapper found an identical, already-working entry and
2714
+ # never owned it. Leave it in place rather than reconstructing
2715
+ # a global command from project-local manifest data.
2716
+ if not _global_entry_matches(state, record, "installed"):
2717
+ emit(f" ! Preserved modified Antigravity MCP entry: {server}")
2718
+ retained.add(key)
2719
+ continue
2720
+ if state.get("present") and not _global_entry_matches(state, record, "installed"):
2721
+ emit(f" ! Preserved modified Antigravity MCP entry: {server}")
2722
+ retained.add(key)
2723
+ continue
2724
+ if state.get("present"):
2725
+ servers.pop(server, None)
2726
+ if not servers:
2727
+ document.pop("mcpServers", None)
2728
+ if not document and all(not record["baseline_file_present"] for _, _, record in records):
2729
+ if path.exists():
2730
+ path.unlink()
2731
+ else:
2732
+ path.parent.mkdir(parents=True, exist_ok=True)
2733
+ path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
2734
+ return retained
2735
+
2736
+
2737
+ def _artifact_can_be_removed(
2738
+ project: Path, manifest: dict[str, object], relative: str,
2739
+ record: dict[str, object],
2740
+ ) -> bool:
2741
+ current = _artifact_digest(project, manifest, relative)
2742
+ return current == record.get("installed_sha256") or (
2743
+ current is None and record.get("baseline") is None
2744
+ )
2745
+
2746
+
2747
+ def _restore_selected_artifact(
2748
+ project: Path, manifest: dict[str, object], relative: str,
2749
+ record: dict[str, object], emit: Callable[[str], None],
2750
+ ) -> bool:
2751
+ if not _artifact_can_be_removed(project, manifest, relative, record):
2752
+ emit(f" ! Preserved modified file: {relative}")
2753
+ return False
2754
+ path = project / _safe_relative(relative)
2755
+ baseline = _decoded(record.get("baseline"))
2756
+ if baseline is None:
2757
+ if path.is_file():
2758
+ path.unlink()
2759
+ _prune_empty_parents(project, path)
2760
+ else:
2761
+ path.parent.mkdir(parents=True, exist_ok=True)
2762
+ path.write_bytes(baseline)
2763
+ return True
2764
+
2765
+
2766
+ def _desired_instruction_clients(
2767
+ graph: str, clients: Sequence[str], addons: Sequence[str], relative: str,
2768
+ ) -> list[str]:
2769
+ return [
2770
+ client for client in clients
2771
+ if INTEGRATION_INSTRUCTION_PATHS[client].as_posix() == relative
2772
+ and (addons or (graph == "codegraph" and client == "antigravity"))
2773
+ ]
2774
+
2775
+
2776
+ def _selective_instruction_changes(
2777
+ project: Path, manifest: dict[str, object], clients: Sequence[str],
2778
+ addons: Sequence[str], *, apply: bool,
2779
+ ) -> bool:
2780
+ records = manifest.get("integration_instructions", {})
2781
+ if not isinstance(records, dict):
2782
+ return True
2783
+ graph = str(manifest["graph"])
2784
+ states = {
2785
+ state["relative"].as_posix(): state
2786
+ for state in _integration_instruction_state(project, manifest)
2787
+ if isinstance(state.get("relative"), Path)
2788
+ }
2789
+ for relative, record in records.items():
2790
+ assert isinstance(record, dict)
2791
+ desired_clients = _desired_instruction_clients(
2792
+ graph, clients, addons, relative,
2793
+ )
2794
+ state = states[relative]
2795
+ if state["removable"]:
2796
+ continue
2797
+ if state["current"] is None and not record["file_preexisting"] and not desired_clients:
2798
+ continue
2799
+ return False
2800
+ if not apply:
2801
+ return True
2802
+ for relative, record in list(records.items()):
2803
+ assert isinstance(record, dict)
2804
+ desired_clients = _desired_instruction_clients(
2805
+ graph, clients, addons, relative,
2806
+ )
2807
+ state = states[relative]
2808
+ if not desired_clients:
2809
+ if state["removable"]:
2810
+ _apply_integration_instruction_removal(state)
2811
+ records.pop(relative, None)
2812
+ continue
2813
+ old_content = str(record["content"])
2814
+ current = str(state["current"])
2815
+ codegraph = graph == "codegraph" and "antigravity" in desired_clients
2816
+ content = _integration_content(
2817
+ desired_clients[0], addons,
2818
+ file_preexisting=bool(record["file_preexisting"]),
2819
+ codegraph=codegraph,
2820
+ )
2821
+ path = project / _safe_relative(relative)
2822
+ path.write_text(current.replace(old_content, content, 1), encoding="utf-8")
2823
+ record.update({
2824
+ "clients": list(desired_clients), "addons": list(addons),
2825
+ "codegraph": codegraph, "content": content,
2826
+ })
2827
+ if not records:
2828
+ manifest.pop("integration_instructions", None)
2829
+ return True
2830
+
2831
+
2832
+ def _selective_uninstall_plan(
2833
+ options: UninstallOptions, manifest: dict[str, object],
2834
+ ) -> tuple[list[str], list[str], list[str], list[str]]:
2835
+ clients = [str(item) for item in manifest.get("clients", [manifest["client"]])]
2836
+ addons = [str(item) for item in manifest.get("addons", ())]
2837
+ remove_clients = list(options.remove_clients)
2838
+ remove_addons = list(options.remove_addons)
2839
+ if not remove_clients and not remove_addons:
2840
+ raise InstallError("Select at least one --remove-client or --remove-addon.")
2841
+ missing_clients = sorted(set(remove_clients) - set(clients))
2842
+ missing_addons = sorted(set(remove_addons) - set(addons))
2843
+ if missing_clients:
2844
+ raise InstallError("Clients are not installed: " + ", ".join(missing_clients))
2845
+ if missing_addons:
2846
+ raise InstallError("Add-ons are not installed: " + ", ".join(missing_addons))
2847
+ remaining_clients = [client for client in clients if client not in remove_clients]
2848
+ if not remaining_clients:
2849
+ raise InstallError(
2850
+ "Selective removal must keep at least one client. "
2851
+ "Run `adl-toolkit uninstall --yes` for a full uninstall."
2852
+ )
2853
+ remaining_addons = [addon for addon in addons if addon not in remove_addons]
2854
+ return remove_clients, remove_addons, remaining_clients, remaining_addons
2855
+
2856
+
2857
+ def _run_selective_uninstall(
2858
+ options: UninstallOptions, manifest: dict[str, object], *,
2859
+ emit: Callable[[str], None],
2860
+ runner: Callable[[Step, Callable[[str], None]], None],
2861
+ ) -> bool:
2862
+ remove_clients, remove_addons, remaining_clients, remaining_addons = (
2863
+ _selective_uninstall_plan(options, manifest)
2864
+ )
2865
+ graph = str(manifest["graph"])
2866
+ configured = [str(item) for item in manifest.get("configured_clients", ())]
2867
+ artifacts = manifest.get("artifacts", {})
2868
+ assert isinstance(artifacts, dict)
2869
+ selected_owners = {f"client:{client}" for client in remove_clients}
2870
+ if str(manifest["client"]) in remove_clients:
2871
+ selected_owners.add("graph")
2872
+ orphan_roots = {
2873
+ INTEGRATION_SKILL_ROOTS[client] for client in remove_clients
2874
+ if INTEGRATION_SKILL_ROOTS[client]
2875
+ not in {INTEGRATION_SKILL_ROOTS[item] for item in remaining_clients}
2876
+ }
2877
+ selected_artifacts = {
2878
+ relative: record for relative, record in artifacts.items()
2879
+ if isinstance(record, dict) and (
2880
+ record.get("owner") in selected_owners
2881
+ or record.get("owner") in {f"addon:{addon}" for addon in remove_addons}
2882
+ or (
2883
+ str(record.get("owner", "")).startswith("addon:")
2884
+ and any(root == _safe_relative(relative)
2885
+ or root in _safe_relative(relative).parents for root in orphan_roots)
2886
+ )
2887
+ )
2888
+ }
2889
+ retained_graphify_skills: dict[str, str] = {}
2890
+ if graph == "graphify":
2891
+ for client in remaining_clients:
2892
+ relative, _ = _graphify_skill(options.project, client)
2893
+ key = relative.as_posix()
2894
+ if key in selected_artifacts:
2895
+ selected_artifacts.pop(key)
2896
+ retained_graphify_skills[key] = client
2897
+ steps = [
2898
+ step for client in remove_clients if client in configured and client != "antigravity"
2899
+ for step in [_graph_uninstall_step(graph, options.project, client)] if step is not None
2900
+ ]
2901
+ emit("\nSelective uninstall plan:")
2902
+ emit(f" Project: {options.project}")
2903
+ emit(f" Remove clients: {', '.join(remove_clients) or 'none'}")
2904
+ emit(f" Remove add-ons: {', '.join(remove_addons) or 'none'}")
2905
+ emit(" Runtime and index: preserved")
2906
+ for step in steps:
2907
+ emit(f" $ {_display_command(step.command)}")
2908
+ if options.dry_run:
2909
+ emit("\nDry-run: no files, configurations, runtimes, or indexes were changed.")
2910
+ return True
2911
+ shared_agents = bool(
2912
+ {"codex", "antigravity"} & set(remaining_clients)
2913
+ )
2914
+ blocked_artifacts = [
2915
+ relative for relative, record in selected_artifacts.items()
2916
+ if not (relative == "AGENTS.md" and shared_agents)
2917
+ and not _artifact_can_be_removed(options.project, manifest, relative, record)
2918
+ ]
2919
+ if blocked_artifacts:
2920
+ emit(" ! Preserved modified files: " + ", ".join(sorted(blocked_artifacts)))
2921
+ manifest["uninstall_error"] = "Selective removal blocked by modified files"
2922
+ _save_manifest(options.project, manifest)
2923
+ return False
2924
+ if not _selective_instruction_changes(
2925
+ options.project, manifest, remaining_clients, remaining_addons, apply=False,
2926
+ ):
2927
+ emit(" ! Preserved modified integration instructions; manifest retained for retry.")
2928
+ manifest["uninstall_error"] = "Selective removal blocked by modified integration instructions"
2929
+ _save_manifest(options.project, manifest)
2930
+ return False
2931
+
2932
+ before_skills = _lock_skills(
2933
+ (options.project / "skills-lock.json").read_bytes()
2934
+ if (options.project / "skills-lock.json").is_file() else None
2935
+ )
2936
+ lock = manifest.get("lock", {})
2937
+ assert isinstance(lock, dict)
2938
+ lock_entries = lock.get("entries", {})
2939
+ assert isinstance(lock_entries, dict)
2940
+ changed_lock = [
2941
+ name for name, record in lock_entries.items()
2942
+ if isinstance(record, dict) and record.get("addon") in remove_addons
2943
+ and before_skills.get(name) != record.get("installed")
2944
+ and not (name not in before_skills and record.get("baseline") is None)
2945
+ ]
2946
+ if changed_lock:
2947
+ emit(" ! Preserved modified skill lock entries: " + ", ".join(sorted(changed_lock)))
2948
+ manifest["uninstall_error"] = "Selective removal blocked by modified skill lock entries"
2949
+ _save_manifest(options.project, manifest)
2950
+ return False
2951
+
2952
+ _preflight_uninstall(steps)
2953
+ completed_clients: list[str] = []
2954
+ try:
2955
+ for step in steps:
2956
+ runner(step, emit)
2957
+ if step.client:
2958
+ if graph != "other" and _client_configured(
2959
+ graph, step.client, options.project,
2960
+ )[0]:
2961
+ raise InstallError(
2962
+ f"Native uninstall did not remove the {step.client} client entry"
2963
+ )
2964
+ completed_owners = {f"client:{step.client}"}
2965
+ if step.client == manifest["client"]:
2966
+ completed_owners.add("graph")
2967
+ for relative, record in artifacts.items():
2968
+ if (isinstance(record, dict)
2969
+ and record.get("owner") in completed_owners
2970
+ and not (relative == "AGENTS.md" and shared_agents)):
2971
+ record["installed_sha256"] = _artifact_digest(
2972
+ options.project, manifest, relative,
2973
+ )
2974
+ completed_clients.append(step.client)
2975
+ if "antigravity" in remove_clients:
2976
+ retained_global = _restore_global_entries(
2977
+ manifest, _global_entry_snapshot(graph, "antigravity"), emit,
2978
+ )
2979
+ if retained_global:
2980
+ raise InstallError("Antigravity configuration changed after installation")
2981
+ manifest["global_entries"] = {}
2982
+ except BaseException as error:
2983
+ manifest["configured_clients"] = [
2984
+ client for client in configured if client not in completed_clients
2985
+ ]
2986
+ manifest["uninstall_error"] = str(error)
2987
+ _save_manifest(options.project, manifest)
2988
+ raise
2989
+
2990
+ for relative, client in retained_graphify_skills.items():
2991
+ artifacts[relative]["owner"] = f"client:{client}"
2992
+ for relative, record in selected_artifacts.items():
2993
+ assert isinstance(record, dict)
2994
+ if relative == "AGENTS.md" and shared_agents:
2995
+ remaining_owner = next(
2996
+ client for client in remaining_clients
2997
+ if client in {"codex", "antigravity"}
2998
+ )
2999
+ record["owner"] = f"client:{remaining_owner}"
3000
+ continue
3001
+ if not _restore_selected_artifact(
3002
+ options.project, manifest, relative, record, emit,
3003
+ ):
3004
+ manifest["uninstall_error"] = f"Could not remove {relative}"
3005
+ _save_manifest(options.project, manifest)
3006
+ return False
3007
+ artifacts.pop(relative, None)
3008
+
3009
+ if remove_addons:
3010
+ retained_lock = _restore_lock(
3011
+ options.project, manifest, set(remove_addons), before_skills, emit,
3012
+ )
3013
+ removed_names = {
3014
+ name for name, record in lock_entries.items()
3015
+ if isinstance(record, dict) and record.get("addon") in remove_addons
3016
+ }
3017
+ if retained_lock & removed_names:
3018
+ manifest["uninstall_error"] = "Some skill lock entries could not be removed"
3019
+ _save_manifest(options.project, manifest)
3020
+ return False
3021
+ lock["entries"] = {
3022
+ name: record for name, record in lock_entries.items()
3023
+ if name not in removed_names
3024
+ }
3025
+
3026
+ if not _selective_instruction_changes(
3027
+ options.project, manifest, remaining_clients, remaining_addons, apply=True,
3028
+ ):
3029
+ manifest["uninstall_error"] = "Could not refresh integration instructions"
3030
+ _save_manifest(options.project, manifest)
3031
+ return False
3032
+ manifest["clients"] = remaining_clients
3033
+ manifest["client"] = remaining_clients[0]
3034
+ manifest["configured_clients"] = [
3035
+ client for client in configured if client in remaining_clients
3036
+ ]
3037
+ manifest["addons"] = remaining_addons
3038
+ manifest.pop("uninstall_error", None)
3039
+ _save_manifest(options.project, manifest)
3040
+ emit("\n✓ Selective uninstall complete. Runtime and index were preserved.")
3041
+ return True
3042
+
3043
+
3044
+ def format_uninstall_plan(options: UninstallOptions) -> str:
3045
+ options = _validate_uninstall_options(options)
3046
+ manifest = _load_manifest(options.project)
3047
+ graph = manifest["graph"] if manifest else options.graph
3048
+ clients = (
3049
+ list(options.remove_clients)
3050
+ if manifest and options.remove_clients
3051
+ else manifest.get("clients", [manifest["client"]]) if manifest else [options.client]
3052
+ )
3053
+ addons = (
3054
+ list(options.remove_addons)
3055
+ if manifest and options.remove_addons else manifest["addons"] if manifest else list(options.addons)
3056
+ )
3057
+ return "\n".join((
3058
+ "\nUninstall plan:",
3059
+ f" Project: {options.project}",
3060
+ f" Graph tool: {graph or 'none'}",
3061
+ f" Clients: {', '.join(str(client) for client in clients if client) or 'none'}",
3062
+ f" Add-ons: {', '.join(addons) or 'none'}",
3063
+ f" Purge index: {'yes' if options.purge_index else 'no (preserved)'}",
3064
+ ))
3065
+
3066
+
3067
+ def run_uninstall(
3068
+ options: UninstallOptions,
3069
+ *,
3070
+ emit: Callable[[str], None] = print,
3071
+ runner: Callable[[Step, Callable[[str], None]], None] = run_command,
3072
+ ) -> bool:
3073
+ options = _validate_uninstall_options(options)
3074
+ manifest = _load_manifest(options.project)
3075
+ if manifest and (options.graph or options.client or options.addons):
3076
+ raise InstallError("Graph, client, and add-on selectors are only for legacy installs without a manifest.")
3077
+ if not manifest and (options.remove_clients or options.remove_addons):
3078
+ raise InstallError(
3079
+ "--remove-client and --remove-addon require an installation manifest. "
3080
+ "Use legacy --client/--addon selectors only for legacy cleanup."
3081
+ )
3082
+ if not manifest and not (options.graph or options.addons):
3083
+ raise InstallError("No installation manifest found; select --graph and/or --addon for legacy removal.")
3084
+ if not manifest and options.addons:
3085
+ raise InstallError("Cannot safely remove legacy skills without ownership records. Use the Skills CLI to remove those skills explicitly.")
3086
+ if not options.yes and not options.dry_run:
3087
+ raise InstallError("Uninstall requires --yes.")
3088
+ if manifest and (options.remove_clients or options.remove_addons):
3089
+ return _run_selective_uninstall(
3090
+ options, manifest, emit=emit, runner=runner,
3091
+ )
3092
+
3093
+ graph = str(manifest["graph"]) if manifest else options.graph
3094
+ clients = (
3095
+ [str(client) for client in manifest.get("clients", [manifest["client"]])]
3096
+ if manifest else [options.client or "codex"]
3097
+ )
3098
+ configured_clients = (
3099
+ [str(client) for client in manifest.get("configured_clients", clients)]
3100
+ if manifest else clients
3101
+ )
3102
+ addons = set(manifest["addons"] if manifest else options.addons)
3103
+ emit(format_uninstall_plan(options))
3104
+
3105
+ artifacts = manifest.get("artifacts", {}) if manifest else {}
3106
+ assert isinstance(artifacts, dict)
3107
+ instruction_states = _integration_instruction_state(options.project, manifest)
3108
+ instruction_by_path = {
3109
+ state["relative"].as_posix(): state for state in instruction_states
3110
+ if isinstance(state["relative"], Path)
3111
+ }
3112
+ before_digests = {
3113
+ value: (
3114
+ _digest(str(instruction_by_path[value]["cleaned"]).encode("utf-8"))
3115
+ if value in instruction_by_path and instruction_by_path[value]["removable"]
3116
+ and instruction_by_path[value]["cleaned"] is not None
3117
+ else None
3118
+ if value in instruction_by_path and instruction_by_path[value]["removable"]
3119
+ else _current_digest(options.project, _safe_relative(value))
3120
+ )
3121
+ for value in artifacts
3122
+ }
3123
+ modified_owners = {
3124
+ str(entry.get("owner")) for value, entry in artifacts.items()
3125
+ if isinstance(entry, dict) and before_digests[value] != entry.get("installed_sha256")
3126
+ }
3127
+ global_entries = manifest.get("global_entries", {}) if manifest else {}
3128
+ assert isinstance(global_entries, dict)
3129
+ current_global = _global_entry_snapshot(
3130
+ graph or "", "antigravity" if "antigravity" in clients else "",
3131
+ )
3132
+ modified_global = {
3133
+ key for key, record in global_entries.items()
3134
+ if isinstance(record, dict)
3135
+ and not _global_entry_matches(current_global[key], record, "installed")
3136
+ }
3137
+ global_blocked = bool(modified_global)
3138
+ client_owners = {f"client:{client}" for client in clients}
3139
+ graph_blocked = bool(modified_owners & ({"graph"} | client_owners)) or global_blocked
3140
+ instruction_retained = any(not state["removable"] for state in instruction_states)
3141
+ removable_addons = {
3142
+ addon for addon in addons
3143
+ if f"addon:{addon}" not in modified_owners and not instruction_retained
3144
+ }
3145
+ steps: list[Step] = []
3146
+ graph_managed = (
3147
+ not manifest or bool(global_entries)
3148
+ or any(entry.get("owner") in ({"graph"} | client_owners) for entry in artifacts.values())
3149
+ )
3150
+ managed_antigravity = bool(manifest and "antigravity" in clients)
3151
+ if graph and graph_managed and not global_blocked:
3152
+ steps.extend(
3153
+ step for client in configured_clients
3154
+ if client != "antigravity"
3155
+ for step in [_graph_uninstall_step(graph, options.project, client)]
3156
+ if step is not None
3157
+ )
3158
+ if options.dry_run:
3159
+ for step in steps:
3160
+ emit(f" $ {_display_command(step.command)}")
3161
+ emit("\nDry-run: no files or runtimes were removed.")
3162
+ return True
3163
+ _preflight_uninstall(steps)
3164
+ before_skills = _lock_skills(
3165
+ (options.project / "skills-lock.json").read_bytes()
3166
+ if (options.project / "skills-lock.json").is_file() else None
3167
+ )
3168
+ if instruction_retained:
3169
+ for instruction_state in instruction_states:
3170
+ if not instruction_state["removable"]:
3171
+ emit(
3172
+ " ! Preserved modified integration instructions: "
3173
+ f"{instruction_state['relative']}"
3174
+ )
3175
+ else:
3176
+ for instruction_state in instruction_states:
3177
+ _apply_integration_instruction_removal(instruction_state)
3178
+
3179
+ def cleanup(action: Callable[[], object]) -> object:
3180
+ try:
3181
+ return action()
3182
+ except BaseException:
3183
+ for instruction_state in instruction_states:
3184
+ if instruction_state["removable"]:
3185
+ _restore_integration_instruction_after_failure(instruction_state)
3186
+ raise
3187
+
3188
+ completed_clients: set[str] = set()
3189
+ try:
3190
+ for step in steps:
3191
+ runner(step, emit)
3192
+ if step.client:
3193
+ completed_clients.add(step.client)
3194
+ except BaseException as error:
3195
+ try:
3196
+ if manifest:
3197
+ for value, entry in artifacts.items():
3198
+ if (entry.get("owner") in (
3199
+ {"graph"} | {f"client:{client}" for client in completed_clients}
3200
+ )
3201
+ and before_digests[value] == entry.get("installed_sha256")):
3202
+ entry["installed_sha256"] = _current_digest(
3203
+ options.project, _safe_relative(value),
3204
+ )
3205
+ failed_global = _global_entry_snapshot(
3206
+ graph or "", "antigravity" if "antigravity" in clients else "",
3207
+ )
3208
+ for key, entry in global_entries.items():
3209
+ assert isinstance(entry, dict)
3210
+ entry["installed_present"] = failed_global[key]["present"]
3211
+ entry["installed"] = failed_global[key]["value"]
3212
+ manifest["uninstall_error"] = str(error)
3213
+ configured = manifest.get("configured_clients", [])
3214
+ if isinstance(configured, list):
3215
+ manifest["configured_clients"] = [
3216
+ client for client in configured if client not in completed_clients
3217
+ ]
3218
+ _save_manifest(options.project, manifest)
3219
+ finally:
3220
+ for instruction_state in instruction_states:
3221
+ if instruction_state["removable"]:
3222
+ _restore_integration_instruction_after_failure(instruction_state)
3223
+ raise
3224
+
3225
+ retained_artifacts: set[str] = set()
3226
+ retained_lock: set[str] = set()
3227
+ retained_global: set[str] = set(global_entries)
3228
+ if manifest:
3229
+ removable_owners = ({"graph", *client_owners} if not graph_blocked else set()) | {
3230
+ f"addon:{addon}" for addon in removable_addons
3231
+ }
3232
+ native_preserved = set()
3233
+ codex_uses_runtime = (
3234
+ graph == "adl-drift" and "antigravity" in clients
3235
+ and _codex_uses_adl_runtime(options.project, workflow_only=True)
3236
+ )
3237
+ if codex_uses_runtime and "AGENTS.md" in artifacts:
3238
+ native_preserved.add("AGENTS.md")
3239
+ retained_artifacts = cleanup(lambda: _restore_artifacts(
3240
+ options.project, manifest, removable_owners, before_digests, emit,
3241
+ native_preserved,
3242
+ ))
3243
+ assert isinstance(retained_artifacts, set)
3244
+ retained_artifacts -= native_preserved
3245
+ if steps:
3246
+ # Native removal edits only its own client entry. Do not restore a
3247
+ # whole-file baseline over settings added since installation.
3248
+ retained_artifacts = {
3249
+ name for name in retained_artifacts
3250
+ if artifacts[name].get("owner") != "graph"
3251
+ }
3252
+ graph_blocked = False
3253
+ retained_global = cleanup(lambda: _restore_global_entries(
3254
+ manifest, _global_entry_snapshot(
3255
+ graph or "", "antigravity" if "antigravity" in clients else ""
3256
+ ), emit,
3257
+ ))
3258
+ assert isinstance(retained_global, set)
3259
+ graph_blocked = bool(retained_global)
3260
+ elif managed_antigravity and global_entries and not global_blocked:
3261
+ retained_global = cleanup(lambda: _restore_global_entries(
3262
+ manifest, _global_entry_snapshot(graph or "", "antigravity"), emit,
3263
+ ))
3264
+ assert isinstance(retained_global, set)
3265
+ graph_blocked = bool(retained_global)
3266
+ retained_lock = cleanup(lambda: _restore_lock(
3267
+ options.project, manifest, removable_addons, before_skills, emit,
3268
+ ))
3269
+ assert isinstance(retained_lock, set)
3270
+ artifacts = manifest["artifacts"]
3271
+ assert isinstance(artifacts, dict)
3272
+ manifest["artifacts"] = {
3273
+ name: entry for name, entry in artifacts.items() if name in retained_artifacts
3274
+ }
3275
+ lock = manifest["lock"]
3276
+ assert isinstance(lock, dict)
3277
+ entries = lock.get("entries", {})
3278
+ assert isinstance(entries, dict)
3279
+ lock["entries"] = {name: entry for name, entry in entries.items() if name in retained_lock}
3280
+ manifest["global_entries"] = {
3281
+ name: entry for name, entry in global_entries.items() if name in retained_global
3282
+ }
3283
+ if not instruction_retained:
3284
+ manifest.pop("integration_instructions", None)
3285
+
3286
+ graph_ok = not graph_blocked
3287
+ if graph_blocked:
3288
+ emit(" ! Preserved modified graph configuration; runtime retained for retry.")
3289
+ if graph == "graphify" and graph_ok:
3290
+ skill = Path.home() / ".agents" / "skills" / "graphify"
3291
+ if skill.is_symlink():
3292
+ cleanup(skill.unlink)
3293
+ elif skill.is_dir():
3294
+ cleanup(lambda: shutil.rmtree(skill))
3295
+ runtime = RUNTIME_PATHS.get(graph or "")
3296
+ if runtime and graph_ok:
3297
+ preexisting = False
3298
+ if manifest:
3299
+ runtime_record = manifest.get("runtimes", {}).get(runtime.as_posix(), {})
3300
+ preexisting = runtime_record.get("preexisting", True) if isinstance(runtime_record, dict) else True
3301
+ global_runtime_in_use = any(
3302
+ isinstance(record, dict) and record.get("baseline_present")
3303
+ for record in global_entries.values()
3304
+ )
3305
+ codex_runtime_in_use = (
3306
+ graph == "adl-drift" and "antigravity" in clients
3307
+ and _codex_uses_adl_runtime(options.project)
3308
+ )
3309
+ if not preexisting and not global_runtime_in_use and not codex_runtime_in_use:
3310
+ cleanup(lambda: _remove_tree(options.project, runtime))
3311
+ if options.purge_index and graph:
3312
+ for index in INDEX_PATHS[graph]:
3313
+ preexisting = False
3314
+ if manifest:
3315
+ record = manifest.get("indexes", {}).get(index.as_posix(), {})
3316
+ preexisting = record.get("preexisting", True) if isinstance(record, dict) else True
3317
+ if not preexisting:
3318
+ cleanup(lambda: _remove_tree(options.project, index))
3319
+
3320
+ retained = graph_blocked or instruction_retained or bool(
3321
+ retained_artifacts or retained_lock or retained_global
3322
+ )
3323
+ if manifest and retained:
3324
+ manifest.pop("uninstall_error", None)
3325
+ cleanup(lambda: _save_manifest(options.project, manifest))
3326
+ emit("\nUninstall incomplete; the manifest was retained for a safe retry.")
3327
+ return False
3328
+ elif manifest:
3329
+ path = options.project / MANIFEST_PATH
3330
+ cleanup(path.unlink)
3331
+ try:
3332
+ path.parent.rmdir()
3333
+ except OSError:
3334
+ pass
3335
+ emit("\n✓ Uninstall complete." + ("" if options.purge_index else " Preserved graph index data."))
3336
+ return True
3337
+ else:
3338
+ emit("\n✓ Legacy uninstall complete." + ("" if options.purge_index else " Preserved graph index data."))
3339
+ return True
3340
+
3341
+
3342
+ def _choose(title: str, choices: Sequence[tuple[str, str]], default: int = 1) -> str:
3343
+ while True:
3344
+ print(f"\n{title}")
3345
+ for number, (_, label) in enumerate(choices, 1):
3346
+ print(f" {number}. {label}")
3347
+ answer = input(f"Select [{default}]: ").strip()
3348
+ if not answer:
3349
+ return choices[default - 1][0]
3350
+ if answer.isdigit() and 1 <= int(answer) <= len(choices):
3351
+ return choices[int(answer) - 1][0]
3352
+ print("Invalid selection.")
3353
+
3354
+
3355
+ def _checkboxes(title: str, choices: Sequence[tuple[str, str]]) -> tuple[str, ...]:
3356
+ selected: set[str] = set()
3357
+ while True:
3358
+ print(f"\n{title} (enter a number to toggle, Enter to continue)")
3359
+ for number, (value, label) in enumerate(choices, 1):
3360
+ print(f" {number}. [{'x' if value in selected else ' '}] {label}")
3361
+ answer = input("Select: ").strip()
3362
+ if not answer:
3363
+ return tuple(value for value, _ in choices if value in selected)
3364
+ if answer.isdigit() and 1 <= int(answer) <= len(choices):
3365
+ value = choices[int(answer) - 1][0]
3366
+ selected.symmetric_difference_update((value,))
3367
+ else:
3368
+ print("Invalid selection.")
3369
+
3370
+
3371
+ def _yes_no(question: str, default: bool = True) -> bool:
3372
+ suffix = "[Y/n]" if default else "[y/N]"
3373
+ while True:
3374
+ answer = input(f"{question} {suffix}: ").strip().lower()
3375
+ if not answer:
3376
+ return default
3377
+ if answer in {"y", "yes"}:
3378
+ return True
3379
+ if answer in {"n", "no"}:
3380
+ return False
3381
+ print("Please enter y or n.")
3382
+
3383
+
3384
+ def wizard(*, dry_run: bool = False) -> Options | None:
3385
+ print("\nInstall ADL Toolkit")
3386
+ graph = _choose(
3387
+ "Choose 1 graph tool:",
3388
+ (
3389
+ ("adl-drift", "ADL-Drift"), ("codegraph", "CodeGraph"),
3390
+ ("graphify", "Graphify"), ("gitnexus", "GitNexus"),
3391
+ ),
3392
+ )
3393
+ addons = _checkboxes(
3394
+ "Choose integration skills",
3395
+ (("caveman", "Caveman"), ("ponytail", "Ponytail")),
3396
+ )
3397
+ clients = _checkboxes(
3398
+ "Choose one or more MCP/agent clients",
3399
+ tuple((item, item.title()) for item in CLIENTS),
3400
+ ) or ("codex",)
3401
+ project_text = input(f"Project [{Path.cwd()}]: ").strip()
3402
+ project = Path(project_text or Path.cwd())
3403
+ extras: tuple[str, ...] = ()
3404
+ package = None
3405
+ if graph == "other":
3406
+ package = input("Pip package name/spec (e.g. code2graph==1.2.0): ").strip()
3407
+ index = graph != "other" and _yes_no("Initialize the index after installation?", True)
3408
+ options = validate_options(
3409
+ Options(graph, project, clients[0], addons, extras, package, index, dry_run, clients)
3410
+ )
3411
+ if dry_run:
3412
+ return options
3413
+ print(format_plan(options))
3414
+ return options if _yes_no("Proceed with installation?", False) else None
3415
+
3416
+
3417
+ def build_parser() -> argparse.ArgumentParser:
3418
+ parser = argparse.ArgumentParser(
3419
+ description="Install, inspect, repair, or uninstall one graph tool and selected agent skills."
3420
+ )
3421
+ parser.add_argument(
3422
+ "action", nargs="?",
3423
+ choices=("install", "update", "uninstall", "status", "doctor", "repair"),
3424
+ default="install",
3425
+ help="Action to run (default: install)",
3426
+ )
3427
+ parser.add_argument(
3428
+ "--graph", choices=GRAPHS, help="Graph tool (omit to open the wizard)"
3429
+ )
3430
+ parser.add_argument(
3431
+ "--addon", action="append", choices=tuple(ADDON_SOURCES),
3432
+ help="Add-on skill; repeat as needed (requires --graph)",
3433
+ )
3434
+ parser.add_argument(
3435
+ "--client", action="append", choices=(*CLIENTS, "all"),
3436
+ help="Agent client; repeat or use 'all' (requires --graph)"
3437
+ )
3438
+ parser.add_argument(
3439
+ "--remove-client", action="append", choices=CLIENTS,
3440
+ help="Installed client to remove; repeat as needed (selective uninstall only)",
3441
+ )
3442
+ parser.add_argument(
3443
+ "--remove-addon", action="append", choices=tuple(ADDON_SOURCES),
3444
+ help="Installed add-on to remove; repeat as needed (selective uninstall only)",
3445
+ )
3446
+ parser.add_argument(
3447
+ "--project", type=Path, help="Target project (requires --graph)"
3448
+ )
3449
+ parser.add_argument(
3450
+ "--extra", action="append", choices=EXTRAS,
3451
+ help="ADL optional dependency; repeat as needed (requires --graph)",
3452
+ )
3453
+ parser.add_argument(
3454
+ "--package", help="Pip package spec for --graph other (requires --graph)"
3455
+ )
3456
+ parser.add_argument(
3457
+ "--no-index", dest="index", action="store_false", default=None,
3458
+ help="Skip index initialization (requires --graph)",
3459
+ )
3460
+ parser.add_argument(
3461
+ "--dry-run", action="store_true",
3462
+ help="Print the full plan without changing the system",
3463
+ )
3464
+ parser.add_argument(
3465
+ "--yes", action="store_true", help="Confirm uninstall without prompting",
3466
+ )
3467
+ parser.add_argument(
3468
+ "--purge-index", action="store_true",
3469
+ help="Also remove index data created by this installer",
3470
+ )
3471
+ return parser
3472
+
3473
+
3474
+ def main(argv: Sequence[str] | None = None) -> int:
3475
+ arguments = list(sys.argv[1:] if argv is None else argv)
3476
+ if arguments and arguments[0] == "setup-graphify-global":
3477
+ internal = argparse.ArgumentParser()
3478
+ internal.add_argument("--project", type=Path, required=True)
3479
+ args = internal.parse_args(arguments[1:])
3480
+ try:
3481
+ _setup_graphify_global(args.project.resolve())
3482
+ except (OSError, InstallError) as error:
3483
+ print(f"Error: {error}", file=sys.stderr)
3484
+ return 1
3485
+ return 0
3486
+ if arguments and arguments[0] == "configure-managed-mcp":
3487
+ internal = argparse.ArgumentParser()
3488
+ internal.add_argument("--graph", choices=("gitnexus", "graphify"), required=True)
3489
+ internal.add_argument("--client", choices=CLIENTS, required=True)
3490
+ internal.add_argument("--project", type=Path, required=True)
3491
+ args = internal.parse_args(arguments[1:])
3492
+ _configure_managed_mcp(args.graph, args.project, args.client)
3493
+ return 0
3494
+ parser = build_parser()
3495
+ args = parser.parse_args(argv)
3496
+ if args.action in {"status", "doctor", "repair", "update"}:
3497
+ unsupported = (
3498
+ args.graph, args.addon, args.client, args.extra, args.package,
3499
+ args.index, args.yes, args.purge_index,
3500
+ args.remove_client, args.remove_addon,
3501
+ )
3502
+ if any(value not in (None, False) for value in unsupported):
3503
+ parser.error(
3504
+ "status, doctor, repair, and update only support --project; "
3505
+ "repair and update also support --dry-run"
3506
+ )
3507
+ if args.dry_run and args.action not in {"repair", "update"}:
3508
+ parser.error("--dry-run is only supported with repair or update")
3509
+ try:
3510
+ project = args.project or Path.cwd()
3511
+ if args.action == "update":
3512
+ run_update(project, dry_run=args.dry_run)
3513
+ return 0
3514
+ if args.action == "repair":
3515
+ return 0 if run_repair(project, dry_run=args.dry_run) else 1
3516
+ return 0 if run_status(project, doctor=args.action == "doctor") else 1
3517
+ except (EOFError, KeyboardInterrupt):
3518
+ print("\nCancelled.", file=sys.stderr)
3519
+ return 130
3520
+ except (ValueError, InstallError) as error:
3521
+ print(f"\nError: {error}", file=sys.stderr)
3522
+ return 1
3523
+ if args.action == "uninstall":
3524
+ if args.extra or args.package or args.index is not None:
3525
+ parser.error("--extra, --package, and --no-index are install options")
3526
+ try:
3527
+ options = UninstallOptions(
3528
+ args.project or Path.cwd(), args.graph,
3529
+ (args.client[0] if args.client and args.client[0] != "all" else None),
3530
+ tuple(args.addon or ()), args.yes, args.dry_run, args.purge_index,
3531
+ tuple(args.remove_client or ()), tuple(args.remove_addon or ()),
3532
+ )
3533
+ if not options.yes and not options.dry_run:
3534
+ print(format_uninstall_plan(options))
3535
+ if not _yes_no("Proceed with uninstall?", False):
3536
+ print("Cancelled; no changes were made.")
3537
+ return 0
3538
+ options = replace(options, yes=True)
3539
+ return 0 if run_uninstall(options) else 1
3540
+ except (EOFError, KeyboardInterrupt):
3541
+ print("\nCancelled.", file=sys.stderr)
3542
+ return 130
3543
+ except (ValueError, InstallError) as error:
3544
+ print(f"\nError: {error}", file=sys.stderr)
3545
+ return 1
3546
+ if args.yes or args.purge_index or args.remove_client or args.remove_addon:
3547
+ parser.error(
3548
+ "--yes, --purge-index, --remove-client, and --remove-addon are uninstall options"
3549
+ )
3550
+ selections = (
3551
+ args.addon, args.client, args.project, args.extra, args.package, args.index,
3552
+ )
3553
+ if not args.graph and any(value is not None for value in selections):
3554
+ parser.error(
3555
+ "--graph is required when using --addon, --client, --project, "
3556
+ "--extra, --package, or --no-index"
3557
+ )
3558
+ try:
3559
+ if args.graph:
3560
+ selected_clients = tuple(
3561
+ CLIENTS if "all" in (args.client or ()) else (args.client or ("codex",))
3562
+ )
3563
+ options = Options(
3564
+ args.graph, args.project or Path.cwd(), selected_clients[0],
3565
+ tuple(args.addon or ()), tuple(args.extra or ()), args.package,
3566
+ True if args.index is None else args.index, args.dry_run, selected_clients,
3567
+ )
3568
+ else:
3569
+ options = wizard(dry_run=args.dry_run)
3570
+ if options is None:
3571
+ print("Cancelled; no changes were made.")
3572
+ return 0
3573
+ run_install(options)
3574
+ return 0
3575
+ except (ValueError, InstallError) as error:
3576
+ print(f"\nError: {error}", file=sys.stderr)
3577
+ return 1
3578
+ except (EOFError, KeyboardInterrupt):
3579
+ print("\nCancelled.", file=sys.stderr)
3580
+ return 130
3581
+
3582
+
3583
+ if __name__ == "__main__":
3584
+ raise SystemExit(main())