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.
- package/INSTALL_TOOLKIT.md +163 -0
- package/README.md +269 -2
- package/adl_drift/__init__.py +6 -0
- package/adl_drift/cli/__init__.py +1 -0
- package/adl_drift/cli/commands.py +993 -0
- package/adl_drift/cli/main.py +744 -0
- package/adl_drift/cortex/__init__.py +1 -0
- package/adl_drift/cortex/embeddings.py +96 -0
- package/adl_drift/cortex/memory.py +1749 -0
- package/adl_drift/cortex/patterns.py +89 -0
- package/adl_drift/cortex/telemetry.py +192 -0
- package/adl_drift/detectors/__init__.py +1 -0
- package/adl_drift/detectors/async_patterns.py +115 -0
- package/adl_drift/detectors/base.py +121 -0
- package/adl_drift/detectors/config.py +122 -0
- package/adl_drift/detectors/data_access.py +128 -0
- package/adl_drift/detectors/decorators.py +122 -0
- package/adl_drift/detectors/dynamic_patterns.py +260 -0
- package/adl_drift/detectors/errors.py +134 -0
- package/adl_drift/detectors/imports.py +132 -0
- package/adl_drift/detectors/logging_patterns.py +108 -0
- package/adl_drift/detectors/routes.py +257 -0
- package/adl_drift/detectors/security.py +236 -0
- package/adl_drift/detectors/taint.py +406 -0
- package/adl_drift/detectors/tests.py +95 -0
- package/adl_drift/detectors/type_conventions.py +81 -0
- package/adl_drift/graph/__init__.py +1 -0
- package/adl_drift/graph/builder.py +2670 -0
- package/adl_drift/graph/cfg.py +461 -0
- package/adl_drift/graph/cluster.py +40 -0
- package/adl_drift/graph/edges.py +86 -0
- package/adl_drift/graph/node.py +90 -0
- package/adl_drift/harness/__init__.py +10 -0
- package/adl_drift/harness/setup.py +832 -0
- package/adl_drift/harness/skills/drift-antigravity.md +19 -0
- package/adl_drift/harness/skills/drift-generic.md +19 -0
- package/adl_drift/harness/skills/drift.md +25 -0
- package/adl_drift/harness/watch.py +84 -0
- package/adl_drift/lang/__init__.py +8 -0
- package/adl_drift/lang/base.py +464 -0
- package/adl_drift/lang/java/__init__.py +6 -0
- package/adl_drift/lang/java/backend.py +97 -0
- package/adl_drift/lang/java/cfg.py +1424 -0
- package/adl_drift/lang/java/config.py +707 -0
- package/adl_drift/lang/java/detectors/__init__.py +29 -0
- package/adl_drift/lang/java/detectors/additional.py +445 -0
- package/adl_drift/lang/java/detectors/base.py +154 -0
- package/adl_drift/lang/java/detectors/data_access.py +148 -0
- package/adl_drift/lang/java/detectors/imports.py +53 -0
- package/adl_drift/lang/java/detectors/routes.py +159 -0
- package/adl_drift/lang/java/detectors/security.py +386 -0
- package/adl_drift/lang/java/detectors/tests.py +74 -0
- package/adl_drift/lang/java/extractor.py +582 -0
- package/adl_drift/lang/java/ids.py +99 -0
- package/adl_drift/lang/java/parser.py +49 -0
- package/adl_drift/lang/java/resolution/__init__.py +1 -0
- package/adl_drift/lang/java/resolution/constants.py +1619 -0
- package/adl_drift/lang/java/resolution/constructors.py +278 -0
- package/adl_drift/lang/java/resolution/context.py +305 -0
- package/adl_drift/lang/java/resolution/dispatch.py +540 -0
- package/adl_drift/lang/java/resolution/functional.py +1318 -0
- package/adl_drift/lang/java/resolution/generated.py +95 -0
- package/adl_drift/lang/java/resolution/model.py +153 -0
- package/adl_drift/lang/java/resolution/orchestration.py +866 -0
- package/adl_drift/lang/java/resolution/overloads.py +1275 -0
- package/adl_drift/lang/java/resolution/scopes.py +1111 -0
- package/adl_drift/lang/java/resolution/types.py +2947 -0
- package/adl_drift/lang/java/resolver.py +1401 -0
- package/adl_drift/lang/java/scopes.py +69 -0
- package/adl_drift/lang/java/taint.py +500 -0
- package/adl_drift/lang/java/type_provenance.py +342 -0
- package/adl_drift/lang/python/__init__.py +8 -0
- package/adl_drift/lang/python/backend.py +94 -0
- package/adl_drift/lang/python/cfg.py +537 -0
- package/adl_drift/lang/python/extractor.py +454 -0
- package/adl_drift/lang/python/parser.py +41 -0
- package/adl_drift/lang/python/resolver.py +1789 -0
- package/adl_drift/lang/python/taint.py +365 -0
- package/adl_drift/lang/registry.py +117 -0
- package/adl_drift/parser/__init__.py +1 -0
- package/adl_drift/parser/cache.py +38 -0
- package/adl_drift/parser/core.py +33 -0
- package/adl_drift/parser/extractor.py +17 -0
- package/adl_drift/parser/literal_eval.py +326 -0
- package/adl_drift/scanner/__init__.py +1 -0
- package/adl_drift/scanner/discovery.py +338 -0
- package/adl_drift/scanner/incremental.py +261 -0
- package/adl_drift/scanner/parallel.py +224 -0
- package/adl_drift/server/__init__.py +1 -0
- package/adl_drift/server/handlers.py +2849 -0
- package/adl_drift/server/mcp_server.py +127 -0
- package/adl_drift/server/mcp_stdio.py +335 -0
- package/adl_drift/server/protocol.py +71 -0
- package/adl_drift/server/static/index.html +696 -0
- package/adl_drift/server/web.py +350 -0
- package/adl_drift/utils/__init__.py +1 -0
- package/adl_drift/utils/config.py +77 -0
- package/adl_drift/utils/fswalk.py +52 -0
- package/adl_drift/utils/types.py +345 -0
- package/bin/adl-drift.mjs +35 -0
- package/bin/adl-toolkit.mjs +488 -0
- package/package.json +29 -4
- package/pyproject.toml +49 -0
- 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())
|