codegraph-engine 3.0.0__1-py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +859 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
codegraph/installer.py
ADDED
|
@@ -0,0 +1,1702 @@
|
|
|
1
|
+
"""Production Installer, Agent Onboarding, Uninstaller & Index Uninitializer (`codegraph install`, `uninstall`, `uninit`).
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- Deterministic AI agent detection (Claude Code, Cursor, Antigravity, Codex CLI, Gemini CLI, Cline)
|
|
5
|
+
- Safe, atomic, idempotent MCP configuration (`mcpServers.codegraph`) preserving unrelated user config
|
|
6
|
+
- Marker-based instruction management (`<!-- CODEGRAPH:START -->` ... `<!-- CODEGRAPH:END -->`)
|
|
7
|
+
- Duplicate block prevention and automatic repair of partial/corrupted CodeGraph blocks
|
|
8
|
+
- Modified-file detection & protection (never silently overwrites or deletes user edits)
|
|
9
|
+
- Symlink & path-traversal protection (`SecurityError`)
|
|
10
|
+
- Post-install verification (executable, MCP config, rules/skills, MCP server health)
|
|
11
|
+
- Clean `uninstall` (removes only CodeGraph agent integration) and `uninit` (removes only project index)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from codegraph.agent_rules import (
|
|
27
|
+
render_agent_rules,
|
|
28
|
+
render_antigravity_skill,
|
|
29
|
+
)
|
|
30
|
+
from codegraph.errors import CodeGraphError, ErrorCode, SecurityError
|
|
31
|
+
from codegraph.security import is_sensitive
|
|
32
|
+
|
|
33
|
+
MARKER_START = "<!-- CODEGRAPH:START -->"
|
|
34
|
+
MARKER_END = "<!-- CODEGRAPH:END -->"
|
|
35
|
+
|
|
36
|
+
INSTALLER_AGENT_ORDER: tuple[str, ...] = (
|
|
37
|
+
"claude",
|
|
38
|
+
"cursor",
|
|
39
|
+
"antigravity",
|
|
40
|
+
"codex",
|
|
41
|
+
"gemini",
|
|
42
|
+
"cline",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
AGENT_DISPLAY_NAMES: dict[str, str] = {
|
|
46
|
+
"claude": "Claude Code",
|
|
47
|
+
"cursor": "Cursor",
|
|
48
|
+
"antigravity": "Antigravity",
|
|
49
|
+
"codex": "Codex CLI",
|
|
50
|
+
"gemini": "Gemini CLI",
|
|
51
|
+
"cline": "Cline",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
AGENT_ALIASES: dict[str, str] = {
|
|
55
|
+
"claude": "claude",
|
|
56
|
+
"claude-code": "claude",
|
|
57
|
+
"claude_code": "claude",
|
|
58
|
+
"claudecode": "claude",
|
|
59
|
+
"cursor": "cursor",
|
|
60
|
+
"antigravity": "antigravity",
|
|
61
|
+
"agy": "antigravity",
|
|
62
|
+
"codex": "codex",
|
|
63
|
+
"codex-cli": "codex",
|
|
64
|
+
"codex_cli": "codex",
|
|
65
|
+
"gemini": "gemini",
|
|
66
|
+
"gemini-cli": "gemini",
|
|
67
|
+
"gemini_cli": "gemini",
|
|
68
|
+
"cline": "cline",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
UNSUPPORTED_KNOWN_AGENTS: dict[str, str] = {
|
|
72
|
+
"kiro": "Kiro IDE does not yet have a standardized CLI MCP configuration schema in CodeGraph.",
|
|
73
|
+
"windsurf": "Windsurf MCP configuration is not directly managed by CodeGraph installer.",
|
|
74
|
+
"zed": "Zed uses custom context_servers settings outside standard mcpServers JSON.",
|
|
75
|
+
"continue": "Continue uses YAML/JSON assistant blocks that vary per profile.",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class DetectedAgent:
|
|
81
|
+
"""Detection status for a supported AI coding agent."""
|
|
82
|
+
|
|
83
|
+
agent_id: str
|
|
84
|
+
display_name: str
|
|
85
|
+
detected: bool
|
|
86
|
+
detected_global: bool
|
|
87
|
+
detected_local: bool
|
|
88
|
+
reasons: tuple[str, ...] = ()
|
|
89
|
+
|
|
90
|
+
def as_dict(self) -> dict[str, Any]:
|
|
91
|
+
return {
|
|
92
|
+
"agent_id": self.agent_id,
|
|
93
|
+
"display_name": self.display_name,
|
|
94
|
+
"detected": self.detected,
|
|
95
|
+
"detected_global": self.detected_global,
|
|
96
|
+
"detected_local": self.detected_local,
|
|
97
|
+
"reasons": list(self.reasons),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class PlannedFileChange:
|
|
103
|
+
"""Single planned or applied file operation."""
|
|
104
|
+
|
|
105
|
+
path: Path
|
|
106
|
+
display_path: str
|
|
107
|
+
action: str # "create" | "modify" | "repair" | "unchanged" | "remove" | "skip_modified"
|
|
108
|
+
category: str # "mcp_config" | "instructions" | "skill" | "rules" | "index"
|
|
109
|
+
agent_id: str
|
|
110
|
+
detail: str
|
|
111
|
+
new_content: str | None = None
|
|
112
|
+
backup_path: Path | None = None
|
|
113
|
+
|
|
114
|
+
def as_dict(self) -> dict[str, Any]:
|
|
115
|
+
data: dict[str, Any] = {
|
|
116
|
+
"path": self.display_path,
|
|
117
|
+
"action": self.action,
|
|
118
|
+
"category": self.category,
|
|
119
|
+
"agent_id": self.agent_id,
|
|
120
|
+
"detail": self.detail,
|
|
121
|
+
}
|
|
122
|
+
if self.backup_path is not None:
|
|
123
|
+
data["backup_path"] = str(self.backup_path)
|
|
124
|
+
return data
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class InstallVerificationResult:
|
|
129
|
+
"""Post-install verification report."""
|
|
130
|
+
|
|
131
|
+
passed: bool
|
|
132
|
+
executable_verified: bool
|
|
133
|
+
mcp_config_verified: bool
|
|
134
|
+
instructions_verified: bool
|
|
135
|
+
mcp_server_health_verified: bool
|
|
136
|
+
no_duplicate_config_verified: bool
|
|
137
|
+
tool_count: int
|
|
138
|
+
details: tuple[str, ...] = ()
|
|
139
|
+
|
|
140
|
+
def as_dict(self) -> dict[str, Any]:
|
|
141
|
+
return {
|
|
142
|
+
"passed": self.passed,
|
|
143
|
+
"executable_verified": self.executable_verified,
|
|
144
|
+
"mcp_config_verified": self.mcp_config_verified,
|
|
145
|
+
"instructions_verified": self.instructions_verified,
|
|
146
|
+
"mcp_server_health_verified": self.mcp_server_health_verified,
|
|
147
|
+
"no_duplicate_config_verified": self.no_duplicate_config_verified,
|
|
148
|
+
"tool_count": self.tool_count,
|
|
149
|
+
"details": list(self.details),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class InstallerReport:
|
|
155
|
+
"""Structured result for `codegraph install`, `codegraph uninstall`, or `codegraph uninit`."""
|
|
156
|
+
|
|
157
|
+
status: str # "ok" | "dry_run" | "aborted" | "error"
|
|
158
|
+
operation: str # "install" | "uninstall" | "uninit"
|
|
159
|
+
location: str # "local" | "global"
|
|
160
|
+
workspace: str
|
|
161
|
+
detected_agents: tuple[DetectedAgent, ...]
|
|
162
|
+
selected_agents: tuple[str, ...]
|
|
163
|
+
changes: tuple[PlannedFileChange, ...]
|
|
164
|
+
warnings: tuple[str, ...] = ()
|
|
165
|
+
repairs: tuple[str, ...] = ()
|
|
166
|
+
verification: InstallVerificationResult | None = None
|
|
167
|
+
error: dict[str, Any] | None = None
|
|
168
|
+
files_changed: bool = field(default=False)
|
|
169
|
+
|
|
170
|
+
def as_dict(self) -> dict[str, Any]:
|
|
171
|
+
created = [c.display_path for c in self.changes if c.action == "create"]
|
|
172
|
+
modified = [c.display_path for c in self.changes if c.action == "modify"]
|
|
173
|
+
repaired = [c.display_path for c in self.changes if c.action == "repair"]
|
|
174
|
+
removed = [c.display_path for c in self.changes if c.action == "remove"]
|
|
175
|
+
unchanged = [c.display_path for c in self.changes if c.action == "unchanged"]
|
|
176
|
+
skipped = [c.display_path for c in self.changes if c.action == "skip_modified"]
|
|
177
|
+
|
|
178
|
+
out: dict[str, Any] = {
|
|
179
|
+
"status": self.status,
|
|
180
|
+
"operation": self.operation,
|
|
181
|
+
"location": self.location,
|
|
182
|
+
"workspace": self.workspace,
|
|
183
|
+
"files_changed": self.files_changed,
|
|
184
|
+
"detected_agents": [a.as_dict() for a in self.detected_agents],
|
|
185
|
+
"selected_agents": list(self.selected_agents),
|
|
186
|
+
"summary": {
|
|
187
|
+
"created": created,
|
|
188
|
+
"modified": modified,
|
|
189
|
+
"repaired": repaired,
|
|
190
|
+
"removed": removed,
|
|
191
|
+
"unchanged": unchanged,
|
|
192
|
+
"skipped_modified": skipped,
|
|
193
|
+
},
|
|
194
|
+
"changes": [c.as_dict() for c in self.changes],
|
|
195
|
+
"warnings": list(self.warnings),
|
|
196
|
+
"repairs": list(self.repairs),
|
|
197
|
+
}
|
|
198
|
+
if self.verification is not None:
|
|
199
|
+
out["verification"] = self.verification.as_dict()
|
|
200
|
+
if self.error is not None:
|
|
201
|
+
out["error"] = self.error
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
def format_preview_human(self, *, unicode_override: bool | None = None) -> str:
|
|
205
|
+
"""Render the interactive preview banner before confirmation."""
|
|
206
|
+
from codegraph.cli_output import get_cli_symbols
|
|
207
|
+
|
|
208
|
+
sym = get_cli_symbols(unicode_override=unicode_override)
|
|
209
|
+
lines: list[str] = ["CodeGraph Installer", ""]
|
|
210
|
+
lines.append("Detected agents:")
|
|
211
|
+
for da in self.detected_agents:
|
|
212
|
+
mark = sym.ok if da.detected else sym.error
|
|
213
|
+
lines.append(f" {mark} {da.display_name}")
|
|
214
|
+
|
|
215
|
+
lines.append("")
|
|
216
|
+
lines.append("Select agents to configure:")
|
|
217
|
+
selected_set = set(self.selected_agents)
|
|
218
|
+
for da in self.detected_agents:
|
|
219
|
+
if da.agent_id in selected_set:
|
|
220
|
+
lines.append(f" [x] {da.display_name}")
|
|
221
|
+
elif da.detected:
|
|
222
|
+
lines.append(f" [ ] {da.display_name}")
|
|
223
|
+
|
|
224
|
+
lines.append("")
|
|
225
|
+
lines.append("Installation scope:")
|
|
226
|
+
if self.location == "global":
|
|
227
|
+
lines.append(f" {sym.radio_on} Global")
|
|
228
|
+
lines.append(f" {sym.radio_off} Current project")
|
|
229
|
+
else:
|
|
230
|
+
lines.append(f" {sym.radio_off} Global")
|
|
231
|
+
lines.append(f" {sym.radio_on} Current project")
|
|
232
|
+
|
|
233
|
+
lines.append("")
|
|
234
|
+
lines.append("Changes to be made:")
|
|
235
|
+
lines.append(" + MCP server configuration")
|
|
236
|
+
lines.append(" + CodeGraph agent instructions")
|
|
237
|
+
lines.append(" + CodeGraph skill/rules")
|
|
238
|
+
return "\n".join(lines)
|
|
239
|
+
|
|
240
|
+
def format_human(self, *, unicode_override: bool | None = None) -> str:
|
|
241
|
+
"""Render concise, professional human-readable output."""
|
|
242
|
+
from codegraph.cli_output import get_cli_symbols
|
|
243
|
+
|
|
244
|
+
sym = get_cli_symbols(unicode_override=unicode_override)
|
|
245
|
+
if self.error is not None:
|
|
246
|
+
err_lines = [
|
|
247
|
+
f"Error [{self.error.get('code', 'ERROR')}]: {self.error.get('message', '')}",
|
|
248
|
+
]
|
|
249
|
+
if self.error.get("why"):
|
|
250
|
+
err_lines.extend(["", "Expected / Reason:", f" {self.error['why']}"])
|
|
251
|
+
next_act = self.error.get("next_action")
|
|
252
|
+
if isinstance(next_act, dict) and next_act.get("command"):
|
|
253
|
+
err_lines.extend(["", "Try:", f" {next_act['command']}"])
|
|
254
|
+
return "\n".join(err_lines)
|
|
255
|
+
|
|
256
|
+
lines: list[str] = []
|
|
257
|
+
created = [c for c in self.changes if c.action == "create"]
|
|
258
|
+
modified = [c for c in self.changes if c.action == "modify"]
|
|
259
|
+
repaired = [c for c in self.changes if c.action == "repair"]
|
|
260
|
+
removed = [c for c in self.changes if c.action == "remove"]
|
|
261
|
+
skipped = [c for c in self.changes if c.action == "skip_modified"]
|
|
262
|
+
|
|
263
|
+
if self.status == "dry_run":
|
|
264
|
+
if self.operation == "install":
|
|
265
|
+
lines.append(self.format_preview_human(unicode_override=unicode_override))
|
|
266
|
+
lines.append("")
|
|
267
|
+
else:
|
|
268
|
+
title = (
|
|
269
|
+
"CodeGraph Uninstaller (Dry Run)"
|
|
270
|
+
if self.operation == "uninstall"
|
|
271
|
+
else "CodeGraph Uninit (Dry Run)"
|
|
272
|
+
)
|
|
273
|
+
lines.append(title)
|
|
274
|
+
lines.append("")
|
|
275
|
+
|
|
276
|
+
if modified or repaired:
|
|
277
|
+
lines.append("Would modify:")
|
|
278
|
+
for c in [*modified, *repaired]:
|
|
279
|
+
lines.append(f" {c.display_path}")
|
|
280
|
+
lines.append("")
|
|
281
|
+
if created:
|
|
282
|
+
lines.append("Would create:")
|
|
283
|
+
for c in created:
|
|
284
|
+
lines.append(f" {c.display_path}")
|
|
285
|
+
lines.append("")
|
|
286
|
+
if removed:
|
|
287
|
+
lines.append("Would remove:")
|
|
288
|
+
for c in removed:
|
|
289
|
+
lines.append(f" {c.display_path}")
|
|
290
|
+
lines.append("")
|
|
291
|
+
if skipped:
|
|
292
|
+
lines.append("Would skip (modified by user):")
|
|
293
|
+
for c in skipped:
|
|
294
|
+
lines.append(f" {c.display_path} ({c.detail})")
|
|
295
|
+
lines.append("")
|
|
296
|
+
for w in self.warnings:
|
|
297
|
+
lines.append(f"Warning: {w}")
|
|
298
|
+
lines.append("No files were changed.")
|
|
299
|
+
return "\n".join(lines)
|
|
300
|
+
|
|
301
|
+
if self.status == "aborted":
|
|
302
|
+
lines.append("Installation cancelled.")
|
|
303
|
+
lines.append("No files were changed.")
|
|
304
|
+
return "\n".join(lines)
|
|
305
|
+
|
|
306
|
+
if self.operation == "install":
|
|
307
|
+
for r_msg in self.repairs:
|
|
308
|
+
lines.append(f"{sym.repair} Repaired: {r_msg}")
|
|
309
|
+
for w_msg in self.warnings:
|
|
310
|
+
lines.append(f"{sym.warn} Warning: {w_msg}")
|
|
311
|
+
|
|
312
|
+
lines.append(f"{sym.ok} CodeGraph installed")
|
|
313
|
+
if self.verification is not None:
|
|
314
|
+
v_mcp = sym.ok if self.verification.mcp_config_verified else sym.error
|
|
315
|
+
v_inst = sym.ok if self.verification.instructions_verified else sym.error
|
|
316
|
+
v_srv = sym.ok if self.verification.mcp_server_health_verified else sym.error
|
|
317
|
+
lines.append(f"{v_mcp} MCP configuration verified")
|
|
318
|
+
lines.append(f"{v_inst} Agent instructions verified")
|
|
319
|
+
lines.append(f"{v_srv} MCP server health verified")
|
|
320
|
+
lines.extend(
|
|
321
|
+
[
|
|
322
|
+
"",
|
|
323
|
+
"Next:",
|
|
324
|
+
" cd <your-project>",
|
|
325
|
+
" codegraph init",
|
|
326
|
+
]
|
|
327
|
+
)
|
|
328
|
+
return "\n".join(lines)
|
|
329
|
+
|
|
330
|
+
if self.operation == "uninstall":
|
|
331
|
+
for w_msg in self.warnings:
|
|
332
|
+
lines.append(f"{sym.warn} Warning: {w_msg}")
|
|
333
|
+
if removed or modified:
|
|
334
|
+
for c in [*removed, *modified]:
|
|
335
|
+
verb = "Removed" if c.action == "remove" else "Updated"
|
|
336
|
+
lines.append(f"{sym.ok} {verb} {c.display_path}")
|
|
337
|
+
else:
|
|
338
|
+
lines.append(f"{sym.ok} No CodeGraph agent integrations needed removal")
|
|
339
|
+
lines.append(f"{sym.ok} CodeGraph agent integration uninstalled")
|
|
340
|
+
return "\n".join(lines)
|
|
341
|
+
|
|
342
|
+
# uninit
|
|
343
|
+
if removed:
|
|
344
|
+
for c in removed:
|
|
345
|
+
lines.append(f"{sym.ok} Removed {c.display_path}")
|
|
346
|
+
else:
|
|
347
|
+
lines.append(f"{sym.ok} No CodeGraph index files present")
|
|
348
|
+
lines.append(f"{sym.ok} Project CodeGraph index removed")
|
|
349
|
+
return "\n".join(lines)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# Path & Symlink Safety
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _ensure_safe_target_path(
|
|
358
|
+
base_root: Path,
|
|
359
|
+
relative_path: str | Path,
|
|
360
|
+
allow_index_artifact: bool = False,
|
|
361
|
+
) -> Path:
|
|
362
|
+
"""Resolve `base_root / relative_path` safely, blocking traversal, symlinks, and sensitive files."""
|
|
363
|
+
raw_str = str(relative_path)
|
|
364
|
+
if "\x00" in raw_str:
|
|
365
|
+
raise SecurityError(
|
|
366
|
+
"Null bytes are not allowed in configuration paths.",
|
|
367
|
+
code=ErrorCode.PATH_OUTSIDE_REPOSITORY,
|
|
368
|
+
)
|
|
369
|
+
rel_obj = Path(raw_str)
|
|
370
|
+
if rel_obj.is_absolute() or ".." in rel_obj.parts:
|
|
371
|
+
raise SecurityError(
|
|
372
|
+
f"Path traversal blocked: '{relative_path}' escapes root boundary.",
|
|
373
|
+
code=ErrorCode.PATH_OUTSIDE_REPOSITORY,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
if not allow_index_artifact and is_sensitive(rel_obj):
|
|
377
|
+
raise SecurityError(
|
|
378
|
+
f"Refusing to modify protected/sensitive path: '{relative_path}'.",
|
|
379
|
+
code=ErrorCode.SENSITIVE_FILE_ACCESS_DENIED,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
resolved_root = base_root.resolve(strict=True)
|
|
383
|
+
# Walk every segment from base_root down to target to reject intermediate symlinks
|
|
384
|
+
cur = base_root
|
|
385
|
+
for part in rel_obj.parts:
|
|
386
|
+
cur = cur / part
|
|
387
|
+
if cur.is_symlink():
|
|
388
|
+
raise SecurityError(
|
|
389
|
+
f"Symlink blocked: '{cur}' is a symbolic link and cannot be modified by CodeGraph installer.",
|
|
390
|
+
code=ErrorCode.PATH_OUTSIDE_REPOSITORY,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
candidate = (resolved_root / rel_obj).resolve(strict=False)
|
|
394
|
+
try:
|
|
395
|
+
candidate.relative_to(resolved_root)
|
|
396
|
+
except ValueError as exc:
|
|
397
|
+
raise SecurityError(
|
|
398
|
+
f"Path '{relative_path}' resolves outside root '{resolved_root}'.",
|
|
399
|
+
code=ErrorCode.PATH_OUTSIDE_REPOSITORY,
|
|
400
|
+
) from exc
|
|
401
|
+
|
|
402
|
+
if candidate.exists() and not candidate.is_file():
|
|
403
|
+
raise CodeGraphError(
|
|
404
|
+
f"Expected a regular file at '{candidate}', found non-file.",
|
|
405
|
+
code=ErrorCode.INVALID_PATH,
|
|
406
|
+
next_action={
|
|
407
|
+
"command": "codegraph install --dry-run",
|
|
408
|
+
"reason": "Inspect conflicting directory or special file at target path.",
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
return candidate
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _atomic_write_text(target: Path, content: str) -> None:
|
|
415
|
+
"""Write `content` to `target` atomically via a same-directory temporary file and `os.replace`."""
|
|
416
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
if target.is_symlink():
|
|
418
|
+
raise SecurityError(
|
|
419
|
+
f"Refusing atomic write to symlink '{target}'.",
|
|
420
|
+
code=ErrorCode.PATH_OUTSIDE_REPOSITORY,
|
|
421
|
+
)
|
|
422
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
423
|
+
dir=str(target.parent),
|
|
424
|
+
prefix=f".{target.name}.",
|
|
425
|
+
suffix=".tmp",
|
|
426
|
+
)
|
|
427
|
+
tmp_path = Path(tmp_name)
|
|
428
|
+
try:
|
|
429
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
430
|
+
fh.write(content)
|
|
431
|
+
fh.flush()
|
|
432
|
+
os.fsync(fh.fileno())
|
|
433
|
+
os.replace(str(tmp_path), str(target))
|
|
434
|
+
except Exception:
|
|
435
|
+
if tmp_path.exists():
|
|
436
|
+
try:
|
|
437
|
+
tmp_path.unlink()
|
|
438
|
+
except OSError:
|
|
439
|
+
pass
|
|
440
|
+
raise
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
# Agent Detection & Target Resolution
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def detect_agents(
|
|
449
|
+
workspace: Path,
|
|
450
|
+
home: Path | None = None,
|
|
451
|
+
which_fn: Callable[[str], str | None] | None = None,
|
|
452
|
+
) -> list[DetectedAgent]:
|
|
453
|
+
"""Deterministically detect supported AI coding agents in `home` and `workspace`."""
|
|
454
|
+
effective_home = (home or Path.home()).resolve()
|
|
455
|
+
effective_ws = workspace.resolve()
|
|
456
|
+
which = which_fn or shutil.which
|
|
457
|
+
|
|
458
|
+
results: list[DetectedAgent] = []
|
|
459
|
+
|
|
460
|
+
for agent_id in INSTALLER_AGENT_ORDER:
|
|
461
|
+
g_reasons: list[str] = []
|
|
462
|
+
l_reasons: list[str] = []
|
|
463
|
+
|
|
464
|
+
if agent_id == "claude":
|
|
465
|
+
if (effective_home / ".claude").exists():
|
|
466
|
+
g_reasons.append("~/.claude")
|
|
467
|
+
if (effective_home / ".claude.json").exists():
|
|
468
|
+
g_reasons.append("~/.claude.json")
|
|
469
|
+
if which("claude"):
|
|
470
|
+
g_reasons.append("claude on PATH")
|
|
471
|
+
if (effective_ws / ".claude").exists():
|
|
472
|
+
l_reasons.append(".claude/")
|
|
473
|
+
if (effective_ws / "CLAUDE.md").exists():
|
|
474
|
+
l_reasons.append("CLAUDE.md")
|
|
475
|
+
if (effective_ws / ".mcp.json").exists():
|
|
476
|
+
l_reasons.append(".mcp.json")
|
|
477
|
+
|
|
478
|
+
elif agent_id == "cursor":
|
|
479
|
+
if (effective_home / ".cursor").exists():
|
|
480
|
+
g_reasons.append("~/.cursor")
|
|
481
|
+
if which("cursor"):
|
|
482
|
+
g_reasons.append("cursor on PATH")
|
|
483
|
+
if (effective_ws / ".cursor").exists():
|
|
484
|
+
l_reasons.append(".cursor/")
|
|
485
|
+
if (effective_ws / ".cursorrules").exists():
|
|
486
|
+
l_reasons.append(".cursorrules")
|
|
487
|
+
|
|
488
|
+
elif agent_id == "antigravity":
|
|
489
|
+
if (effective_home / ".gemini" / "antigravity").exists():
|
|
490
|
+
g_reasons.append("~/.gemini/antigravity")
|
|
491
|
+
if (effective_home / ".gemini" / "config").exists():
|
|
492
|
+
g_reasons.append("~/.gemini/config")
|
|
493
|
+
if which("agy") or which("antigravity"):
|
|
494
|
+
g_reasons.append("agy on PATH")
|
|
495
|
+
if (effective_ws / ".agents").exists():
|
|
496
|
+
l_reasons.append(".agents/")
|
|
497
|
+
|
|
498
|
+
elif agent_id == "codex":
|
|
499
|
+
if (effective_home / ".codex").exists():
|
|
500
|
+
g_reasons.append("~/.codex")
|
|
501
|
+
if which("codex"):
|
|
502
|
+
g_reasons.append("codex on PATH")
|
|
503
|
+
if (effective_ws / ".codex").exists():
|
|
504
|
+
l_reasons.append(".codex/")
|
|
505
|
+
|
|
506
|
+
elif agent_id == "gemini":
|
|
507
|
+
if (effective_home / ".gemini" / "settings.json").exists():
|
|
508
|
+
g_reasons.append("~/.gemini/settings.json")
|
|
509
|
+
if (effective_home / ".gemini" / "GEMINI.md").exists():
|
|
510
|
+
g_reasons.append("~/.gemini/GEMINI.md")
|
|
511
|
+
if which("gemini"):
|
|
512
|
+
g_reasons.append("gemini on PATH")
|
|
513
|
+
if (effective_ws / ".gemini").exists():
|
|
514
|
+
l_reasons.append(".gemini/")
|
|
515
|
+
if (effective_ws / "GEMINI.md").exists():
|
|
516
|
+
l_reasons.append("GEMINI.md")
|
|
517
|
+
|
|
518
|
+
elif agent_id == "cline":
|
|
519
|
+
if (effective_home / ".cline").exists():
|
|
520
|
+
g_reasons.append("~/.cline")
|
|
521
|
+
if (effective_ws / ".cline").exists():
|
|
522
|
+
l_reasons.append(".cline/")
|
|
523
|
+
if (effective_ws / ".clinerules").exists():
|
|
524
|
+
l_reasons.append(".clinerules")
|
|
525
|
+
|
|
526
|
+
det_g = len(g_reasons) > 0
|
|
527
|
+
det_l = len(l_reasons) > 0
|
|
528
|
+
results.append(
|
|
529
|
+
DetectedAgent(
|
|
530
|
+
agent_id=agent_id,
|
|
531
|
+
display_name=AGENT_DISPLAY_NAMES[agent_id],
|
|
532
|
+
detected=det_g or det_l,
|
|
533
|
+
detected_global=det_g,
|
|
534
|
+
detected_local=det_l,
|
|
535
|
+
reasons=tuple([*g_reasons, *l_reasons]),
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return results
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def resolve_target_agents(
|
|
543
|
+
target_spec: str,
|
|
544
|
+
detected: list[DetectedAgent],
|
|
545
|
+
default_if_none_detected: str = "antigravity",
|
|
546
|
+
) -> list[str]:
|
|
547
|
+
"""Resolve `--target` (`auto`, `all`, or comma-separated agent list) into canonical agent IDs."""
|
|
548
|
+
raw = (target_spec or "auto").strip().lower()
|
|
549
|
+
if not raw or raw == "auto":
|
|
550
|
+
found = [a.agent_id for a in detected if a.detected]
|
|
551
|
+
return found if found else [default_if_none_detected]
|
|
552
|
+
|
|
553
|
+
if raw == "all":
|
|
554
|
+
return list(INSTALLER_AGENT_ORDER)
|
|
555
|
+
|
|
556
|
+
parts = [p.strip().lower() for p in raw.split(",") if p.strip()]
|
|
557
|
+
if not parts:
|
|
558
|
+
raise CodeGraphError(
|
|
559
|
+
"Target specification cannot be empty.",
|
|
560
|
+
code=ErrorCode.INVALID_ARGUMENT,
|
|
561
|
+
next_action={
|
|
562
|
+
"command": "codegraph install --target auto --dry-run",
|
|
563
|
+
"reason": f"Supported targets: auto, all, {', '.join(INSTALLER_AGENT_ORDER)}",
|
|
564
|
+
},
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
resolved: list[str] = []
|
|
568
|
+
for p in parts:
|
|
569
|
+
if p == "all":
|
|
570
|
+
for aid in INSTALLER_AGENT_ORDER:
|
|
571
|
+
if aid not in resolved:
|
|
572
|
+
resolved.append(aid)
|
|
573
|
+
continue
|
|
574
|
+
if p == "auto":
|
|
575
|
+
for a in detected:
|
|
576
|
+
if a.detected and a.agent_id not in resolved:
|
|
577
|
+
resolved.append(a.agent_id)
|
|
578
|
+
continue
|
|
579
|
+
if p in AGENT_ALIASES:
|
|
580
|
+
canon = AGENT_ALIASES[p]
|
|
581
|
+
if canon not in resolved:
|
|
582
|
+
resolved.append(canon)
|
|
583
|
+
continue
|
|
584
|
+
why_msg = UNSUPPORTED_KNOWN_AGENTS.get(
|
|
585
|
+
p,
|
|
586
|
+
f"Agent '{p}' is not one of the supported targets ({', '.join(INSTALLER_AGENT_ORDER)}).",
|
|
587
|
+
)
|
|
588
|
+
raise CodeGraphError(
|
|
589
|
+
f"Unsupported agent target '{p}'. {why_msg}",
|
|
590
|
+
code=ErrorCode.UNSUPPORTED_AGENT,
|
|
591
|
+
next_action={
|
|
592
|
+
"command": "codegraph install --target auto --dry-run",
|
|
593
|
+
"reason": f"Choose from supported agents: {', '.join(INSTALLER_AGENT_ORDER)}",
|
|
594
|
+
},
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
return resolved if resolved else [default_if_none_detected]
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
# Marker-Based Markdown Section Management
|
|
602
|
+
# ---------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def format_marker_block(inner_content: str) -> str:
|
|
606
|
+
"""Wrap `inner_content` in `<!-- CODEGRAPH:START -->` ... `<!-- CODEGRAPH:END -->` markers."""
|
|
607
|
+
clean = inner_content.strip()
|
|
608
|
+
return f"{MARKER_START}\n{clean}\n{MARKER_END}\n"
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _extract_marker_inner_blocks(text: str) -> list[str]:
|
|
612
|
+
"""Extract all inner blocks between `MARKER_START` and `MARKER_END`."""
|
|
613
|
+
pattern = re.compile(
|
|
614
|
+
re.escape(MARKER_START) + r"\n?(.*?)\n?" + re.escape(MARKER_END),
|
|
615
|
+
re.DOTALL,
|
|
616
|
+
)
|
|
617
|
+
return [m.group(1).strip() for m in pattern.finditer(text)]
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _all_canonical_rule_variants() -> set[str]:
|
|
621
|
+
"""Return all canonical rendered rule strings across supported agents for modification detection."""
|
|
622
|
+
variants: set[str] = set()
|
|
623
|
+
for agent_key in ("antigravity", "agents", "gemini", "claude", "cursor", "codex", "cline"):
|
|
624
|
+
variants.add(render_agent_rules(agent_key).strip())
|
|
625
|
+
variants.add(render_antigravity_skill().strip())
|
|
626
|
+
return variants
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def is_codegraph_content_unmodified(content: str) -> bool:
|
|
630
|
+
"""Return True if `content` matches a canonical CodeGraph rule/skill body."""
|
|
631
|
+
stripped = content.strip()
|
|
632
|
+
if not stripped:
|
|
633
|
+
return True
|
|
634
|
+
if stripped in _all_canonical_rule_variants():
|
|
635
|
+
return True
|
|
636
|
+
# Also check if wrapped in markers with canonical inner body
|
|
637
|
+
blocks = _extract_marker_inner_blocks(stripped)
|
|
638
|
+
if len(blocks) == 1 and blocks[0] in _all_canonical_rule_variants():
|
|
639
|
+
return True
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def upsert_marker_section(
|
|
644
|
+
existing_text: str | None,
|
|
645
|
+
inner_content: str,
|
|
646
|
+
) -> tuple[str, str, str | None]:
|
|
647
|
+
"""Insert, update, or repair the `<!-- CODEGRAPH:START -->...<!-- CODEGRAPH:END -->` block.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
(new_full_text, action, repair_detail)
|
|
651
|
+
where action is `"create" | "modify" | "repair" | "unchanged"`.
|
|
652
|
+
"""
|
|
653
|
+
block = format_marker_block(inner_content)
|
|
654
|
+
|
|
655
|
+
if existing_text is None:
|
|
656
|
+
return block, "create", None
|
|
657
|
+
|
|
658
|
+
start_count = existing_text.count(MARKER_START)
|
|
659
|
+
end_count = existing_text.count(MARKER_END)
|
|
660
|
+
|
|
661
|
+
# Case 1: Corrupted / unbalanced or duplicated markers -> Repair Mode
|
|
662
|
+
if start_count != end_count or start_count > 1:
|
|
663
|
+
repair_reason = (
|
|
664
|
+
f"Found {start_count} start marker(s) and {end_count} end marker(s); "
|
|
665
|
+
"consolidated into a single CodeGraph block while preserving user text."
|
|
666
|
+
)
|
|
667
|
+
# Remove all complete blocks first
|
|
668
|
+
pattern = re.compile(
|
|
669
|
+
r"\n*" + re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END) + r"\n*",
|
|
670
|
+
re.DOTALL,
|
|
671
|
+
)
|
|
672
|
+
cleaned = pattern.sub("\n\n", existing_text)
|
|
673
|
+
# Remove any stray dangling marker lines
|
|
674
|
+
cleaned = cleaned.replace(MARKER_START, "").replace(MARKER_END, "").strip()
|
|
675
|
+
if cleaned:
|
|
676
|
+
repaired_text = f"{cleaned}\n\n{block}"
|
|
677
|
+
else:
|
|
678
|
+
repaired_text = block
|
|
679
|
+
return repaired_text, "repair", repair_reason
|
|
680
|
+
|
|
681
|
+
# Case 2: Exactly one start and one end marker
|
|
682
|
+
if start_count == 1 and end_count == 1:
|
|
683
|
+
start_idx = existing_text.index(MARKER_START)
|
|
684
|
+
end_idx = existing_text.index(MARKER_END)
|
|
685
|
+
if end_idx < start_idx:
|
|
686
|
+
# Inverted markers -> Repair Mode
|
|
687
|
+
cleaned = existing_text.replace(MARKER_START, "").replace(MARKER_END, "").strip()
|
|
688
|
+
repaired_text = f"{cleaned}\n\n{block}" if cleaned else block
|
|
689
|
+
return (
|
|
690
|
+
repaired_text,
|
|
691
|
+
"repair",
|
|
692
|
+
"Repaired inverted CODEGRAPH:END / CODEGRAPH:START markers.",
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
end_after = end_idx + len(MARKER_END)
|
|
696
|
+
if end_after < len(existing_text) and existing_text[end_after] == "\n":
|
|
697
|
+
end_after += 1
|
|
698
|
+
|
|
699
|
+
updated = existing_text[:start_idx] + block + existing_text[end_after:]
|
|
700
|
+
if updated == existing_text:
|
|
701
|
+
return existing_text, "unchanged", None
|
|
702
|
+
return updated, "modify", None
|
|
703
|
+
|
|
704
|
+
# Case 3: No markers yet
|
|
705
|
+
stripped = existing_text.strip()
|
|
706
|
+
if not stripped:
|
|
707
|
+
return block, "modify", None
|
|
708
|
+
|
|
709
|
+
# If the entire file is already a raw canonical CodeGraph rule file, wrap it in markers
|
|
710
|
+
if stripped in _all_canonical_rule_variants():
|
|
711
|
+
return block, "modify", None
|
|
712
|
+
|
|
713
|
+
# Append marker block after existing user instructions
|
|
714
|
+
appended = f"{ existing_text.rstrip() }\n\n{block}"
|
|
715
|
+
return appended, "modify", None
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def remove_marker_section(
|
|
719
|
+
existing_text: str,
|
|
720
|
+
) -> tuple[str | None, str, str | None]:
|
|
721
|
+
"""Remove `<!-- CODEGRAPH:START -->...<!-- CODEGRAPH:END -->` from `existing_text`.
|
|
722
|
+
|
|
723
|
+
Returns:
|
|
724
|
+
(remaining_text_or_None_if_empty, action, warning_or_None)
|
|
725
|
+
where action is `"remove" | "modify" | "unchanged" | "skip_modified"`.
|
|
726
|
+
"""
|
|
727
|
+
if MARKER_START not in existing_text and MARKER_END not in existing_text:
|
|
728
|
+
return existing_text, "unchanged", None
|
|
729
|
+
|
|
730
|
+
blocks = _extract_marker_inner_blocks(existing_text)
|
|
731
|
+
for b in blocks:
|
|
732
|
+
if not is_codegraph_content_unmodified(b):
|
|
733
|
+
return (
|
|
734
|
+
existing_text,
|
|
735
|
+
"skip_modified",
|
|
736
|
+
"CodeGraph marker block was manually modified by user; preserving changes.",
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
pattern = re.compile(
|
|
740
|
+
r"\n*" + re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END) + r"\n*",
|
|
741
|
+
re.DOTALL,
|
|
742
|
+
)
|
|
743
|
+
remaining = pattern.sub("\n\n", existing_text).strip()
|
|
744
|
+
if not remaining:
|
|
745
|
+
return None, "remove", None
|
|
746
|
+
return remaining + "\n", "modify", None
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# ---------------------------------------------------------------------------
|
|
750
|
+
# MCP JSON Configuration Management
|
|
751
|
+
# ---------------------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def build_agent_mcp_entry(agent_id: str, command: str = "codegraph") -> dict[str, Any]:
|
|
755
|
+
"""Return the canonical `mcpServers.codegraph` object for `agent_id`."""
|
|
756
|
+
entry: dict[str, Any] = {
|
|
757
|
+
"command": command,
|
|
758
|
+
"args": ["mcp", "serve"],
|
|
759
|
+
}
|
|
760
|
+
if agent_id == "cline":
|
|
761
|
+
entry["disabled"] = False
|
|
762
|
+
return entry
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def render_agent_mcp_config_snippet(agent_id: str, command: str = "codegraph") -> dict[str, Any]:
|
|
766
|
+
"""Return full top-level MCP config JSON snippet for `--print-config`."""
|
|
767
|
+
canon = AGENT_ALIASES.get(agent_id.strip().lower(), agent_id.strip().lower())
|
|
768
|
+
if canon not in AGENT_DISPLAY_NAMES:
|
|
769
|
+
raise CodeGraphError(
|
|
770
|
+
f"Unsupported agent '{agent_id}'.",
|
|
771
|
+
code=ErrorCode.UNSUPPORTED_AGENT,
|
|
772
|
+
next_action={
|
|
773
|
+
"command": "codegraph install --print-config claude",
|
|
774
|
+
"reason": f"Supported agents: {', '.join(INSTALLER_AGENT_ORDER)}",
|
|
775
|
+
},
|
|
776
|
+
)
|
|
777
|
+
return {
|
|
778
|
+
"mcpServers": {
|
|
779
|
+
"codegraph": build_agent_mcp_entry(canon, command=command),
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def upsert_mcp_json_config(
|
|
785
|
+
existing_text: str | None,
|
|
786
|
+
agent_id: str,
|
|
787
|
+
display_path: str,
|
|
788
|
+
command: str = "codegraph",
|
|
789
|
+
allow_malformed_repair: bool = False,
|
|
790
|
+
) -> tuple[str, str, str | None]:
|
|
791
|
+
"""Create, update, or repair `mcpServers.codegraph` in a JSON configuration file.
|
|
792
|
+
|
|
793
|
+
Preserves all unrelated top-level keys and other MCP servers.
|
|
794
|
+
Returns `(new_json_text, action, repair_reason)`.
|
|
795
|
+
"""
|
|
796
|
+
desired_entry = build_agent_mcp_entry(agent_id, command=command)
|
|
797
|
+
|
|
798
|
+
if existing_text is None:
|
|
799
|
+
doc = {"mcpServers": {"codegraph": desired_entry}}
|
|
800
|
+
return json.dumps(doc, indent=2) + "\n", "create", None
|
|
801
|
+
|
|
802
|
+
if not existing_text.strip():
|
|
803
|
+
doc = {"mcpServers": {"codegraph": desired_entry}}
|
|
804
|
+
return (
|
|
805
|
+
json.dumps(doc, indent=2) + "\n",
|
|
806
|
+
"repair",
|
|
807
|
+
f"Repaired empty MCP configuration file '{display_path}'.",
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
try:
|
|
811
|
+
parsed = json.loads(existing_text)
|
|
812
|
+
except json.JSONDecodeError as exc:
|
|
813
|
+
if allow_malformed_repair:
|
|
814
|
+
doc = {"mcpServers": {"codegraph": desired_entry}}
|
|
815
|
+
return (
|
|
816
|
+
json.dumps(doc, indent=2) + "\n",
|
|
817
|
+
"repair",
|
|
818
|
+
f"Backed up and repaired malformed JSON in '{display_path}' (line {exc.lineno}).",
|
|
819
|
+
)
|
|
820
|
+
raise CodeGraphError(
|
|
821
|
+
f"Malformed JSON in '{display_path}' (line {exc.lineno}, column {exc.colno}).",
|
|
822
|
+
code=ErrorCode.CONFIG_PARSE_ERROR,
|
|
823
|
+
next_action={
|
|
824
|
+
"command": f"codegraph install --target {agent_id} --repair",
|
|
825
|
+
"reason": f"Fix JSON syntax in {display_path} or pass --repair to back up and repair.",
|
|
826
|
+
},
|
|
827
|
+
) from exc
|
|
828
|
+
|
|
829
|
+
if not isinstance(parsed, dict):
|
|
830
|
+
if allow_malformed_repair:
|
|
831
|
+
doc = {"mcpServers": {"codegraph": desired_entry}}
|
|
832
|
+
return (
|
|
833
|
+
json.dumps(doc, indent=2) + "\n",
|
|
834
|
+
"repair",
|
|
835
|
+
f"Repaired non-object JSON root in '{display_path}'.",
|
|
836
|
+
)
|
|
837
|
+
raise CodeGraphError(
|
|
838
|
+
f"Invalid MCP configuration root in '{display_path}': expected JSON object.",
|
|
839
|
+
code=ErrorCode.CONFIG_PARSE_ERROR,
|
|
840
|
+
next_action={
|
|
841
|
+
"command": f"codegraph install --target {agent_id} --repair",
|
|
842
|
+
"reason": "Ensure configuration root is a JSON object `{...}`.",
|
|
843
|
+
},
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
repair_reason: str | None = None
|
|
847
|
+
servers = parsed.get("mcpServers")
|
|
848
|
+
if servers is None:
|
|
849
|
+
parsed["mcpServers"] = {"codegraph": desired_entry}
|
|
850
|
+
new_text = json.dumps(parsed, indent=2) + "\n"
|
|
851
|
+
return new_text, "modify", None
|
|
852
|
+
|
|
853
|
+
if not isinstance(servers, dict):
|
|
854
|
+
repair_reason = f"Repaired corrupted 'mcpServers' field (was {type(servers).__name__}) in '{display_path}'."
|
|
855
|
+
parsed["mcpServers"] = {"codegraph": desired_entry}
|
|
856
|
+
return json.dumps(parsed, indent=2) + "\n", "repair", repair_reason
|
|
857
|
+
|
|
858
|
+
# Detect duplicate case-insensitive codegraph keys (e.g. "CodeGraph" and "codegraph")
|
|
859
|
+
cg_keys = [k for k in list(servers.keys()) if str(k).lower() == "codegraph"]
|
|
860
|
+
if len(cg_keys) > 1:
|
|
861
|
+
for k in cg_keys:
|
|
862
|
+
if k != "codegraph":
|
|
863
|
+
del servers[k]
|
|
864
|
+
repair_reason = f"Removed duplicate CodeGraph server keys {cg_keys} in '{display_path}'."
|
|
865
|
+
|
|
866
|
+
existing_cg = servers.get("codegraph")
|
|
867
|
+
if existing_cg is not None:
|
|
868
|
+
if not isinstance(existing_cg, dict):
|
|
869
|
+
repair_reason = f"Repaired corrupted 'mcpServers.codegraph' entry in '{display_path}'."
|
|
870
|
+
else:
|
|
871
|
+
cmd_val = existing_cg.get("command")
|
|
872
|
+
args_val = existing_cg.get("args")
|
|
873
|
+
if (
|
|
874
|
+
not isinstance(cmd_val, str)
|
|
875
|
+
or not cmd_val.strip()
|
|
876
|
+
or not isinstance(args_val, list)
|
|
877
|
+
or args_val != ["mcp", "serve"]
|
|
878
|
+
):
|
|
879
|
+
repair_reason = f"Repaired incomplete or outdated 'mcpServers.codegraph' entry in '{display_path}'."
|
|
880
|
+
|
|
881
|
+
if existing_cg == desired_entry and repair_reason is None:
|
|
882
|
+
# Canonical formatting check: if semantically identical, do not rewrite unnecessarily
|
|
883
|
+
return existing_text, "unchanged", None
|
|
884
|
+
|
|
885
|
+
servers["codegraph"] = desired_entry
|
|
886
|
+
new_text = json.dumps(parsed, indent=2) + "\n"
|
|
887
|
+
if repair_reason is not None:
|
|
888
|
+
return new_text, "repair", repair_reason
|
|
889
|
+
return new_text, "modify", None
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def remove_mcp_json_config(
|
|
893
|
+
existing_text: str,
|
|
894
|
+
display_path: str,
|
|
895
|
+
can_delete_file_when_empty: bool = True,
|
|
896
|
+
) -> tuple[str | None, str]:
|
|
897
|
+
"""Remove `mcpServers.codegraph` from `existing_text`.
|
|
898
|
+
|
|
899
|
+
Preserves all other servers and top-level keys.
|
|
900
|
+
Returns `(new_text_or_None_to_delete_file, action)`.
|
|
901
|
+
"""
|
|
902
|
+
try:
|
|
903
|
+
parsed = json.loads(existing_text)
|
|
904
|
+
except Exception as exc:
|
|
905
|
+
raise CodeGraphError(
|
|
906
|
+
f"Cannot safely uninstall from malformed JSON file '{display_path}'.",
|
|
907
|
+
code=ErrorCode.CONFIG_PARSE_ERROR,
|
|
908
|
+
next_action={
|
|
909
|
+
"command": "codegraph doctor",
|
|
910
|
+
"reason": f"Fix JSON syntax in {display_path} before running uninstall.",
|
|
911
|
+
},
|
|
912
|
+
) from exc
|
|
913
|
+
|
|
914
|
+
if not isinstance(parsed, dict):
|
|
915
|
+
return existing_text, "unchanged"
|
|
916
|
+
|
|
917
|
+
servers = parsed.get("mcpServers")
|
|
918
|
+
if not isinstance(servers, dict):
|
|
919
|
+
return existing_text, "unchanged"
|
|
920
|
+
|
|
921
|
+
cg_keys = [k for k in list(servers.keys()) if str(k).lower() == "codegraph"]
|
|
922
|
+
if not cg_keys:
|
|
923
|
+
return existing_text, "unchanged"
|
|
924
|
+
|
|
925
|
+
for k in cg_keys:
|
|
926
|
+
del servers[k]
|
|
927
|
+
|
|
928
|
+
# If mcpServers is now empty and there are no other keys in the file
|
|
929
|
+
other_top_keys = [k for k in parsed.keys() if k != "mcpServers"]
|
|
930
|
+
if len(servers) == 0 and len(other_top_keys) == 0 and can_delete_file_when_empty:
|
|
931
|
+
return None, "remove"
|
|
932
|
+
|
|
933
|
+
return json.dumps(parsed, indent=2) + "\n", "modify"
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
# ---------------------------------------------------------------------------
|
|
937
|
+
# Target File Blueprints per Agent & Scope
|
|
938
|
+
# ---------------------------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
@dataclass(frozen=True)
|
|
942
|
+
class AgentFileSpec:
|
|
943
|
+
"""Specification of a file managed for a specific agent and scope."""
|
|
944
|
+
|
|
945
|
+
agent_id: str
|
|
946
|
+
relative_path: str
|
|
947
|
+
category: str # "mcp_config" | "instructions" | "skill" | "rules"
|
|
948
|
+
mode: str # "mcp_json" | "marker_md" | "standalone_md"
|
|
949
|
+
rule_agent_key: str # key passed to render_agent_rules or "skill"
|
|
950
|
+
can_delete_when_empty: bool = True
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def get_agent_file_specs(agent_id: str, location: str) -> list[AgentFileSpec]:
|
|
954
|
+
"""Return the exact files managed for `agent_id` in `location` (`local` or `global`)."""
|
|
955
|
+
scope = location.lower()
|
|
956
|
+
if scope == "project":
|
|
957
|
+
scope = "local"
|
|
958
|
+
|
|
959
|
+
if scope == "global":
|
|
960
|
+
if agent_id == "claude":
|
|
961
|
+
return [
|
|
962
|
+
AgentFileSpec("claude", ".claude.json", "mcp_config", "mcp_json", "claude", can_delete_file_when_empty_for_global(".claude.json")),
|
|
963
|
+
AgentFileSpec("claude", ".claude/CLAUDE.md", "instructions", "marker_md", "claude"),
|
|
964
|
+
]
|
|
965
|
+
if agent_id == "cursor":
|
|
966
|
+
return [
|
|
967
|
+
AgentFileSpec("cursor", ".cursor/mcp.json", "mcp_config", "mcp_json", "cursor"),
|
|
968
|
+
AgentFileSpec("cursor", ".cursor/rules/codegraph.md", "rules", "standalone_md", "cursor"),
|
|
969
|
+
]
|
|
970
|
+
if agent_id == "antigravity":
|
|
971
|
+
return [
|
|
972
|
+
AgentFileSpec("antigravity", ".gemini/antigravity/mcp_config.json", "mcp_config", "mcp_json", "antigravity"),
|
|
973
|
+
AgentFileSpec("antigravity", ".gemini/config/GEMINI.md", "instructions", "marker_md", "antigravity"),
|
|
974
|
+
AgentFileSpec("antigravity", ".gemini/config/skills/codegraph/SKILL.md", "skill", "standalone_md", "skill"),
|
|
975
|
+
]
|
|
976
|
+
if agent_id == "codex":
|
|
977
|
+
return [
|
|
978
|
+
AgentFileSpec("codex", ".codex/mcp_config.json", "mcp_config", "mcp_json", "codex"),
|
|
979
|
+
AgentFileSpec("codex", ".codex/AGENTS.md", "instructions", "marker_md", "codex"),
|
|
980
|
+
]
|
|
981
|
+
if agent_id == "gemini":
|
|
982
|
+
return [
|
|
983
|
+
AgentFileSpec("gemini", ".gemini/settings.json", "mcp_config", "mcp_json", "gemini", can_delete_when_empty=False),
|
|
984
|
+
AgentFileSpec("gemini", ".gemini/GEMINI.md", "instructions", "marker_md", "gemini"),
|
|
985
|
+
]
|
|
986
|
+
if agent_id == "cline":
|
|
987
|
+
return [
|
|
988
|
+
AgentFileSpec("cline", ".cline/mcp_settings.json", "mcp_config", "mcp_json", "cline"),
|
|
989
|
+
AgentFileSpec("cline", ".cline/rules/codegraph.md", "rules", "standalone_md", "cline"),
|
|
990
|
+
]
|
|
991
|
+
raise ValueError(f"Unsupported agent: {agent_id}")
|
|
992
|
+
|
|
993
|
+
# Local (project) scope
|
|
994
|
+
if agent_id == "claude":
|
|
995
|
+
return [
|
|
996
|
+
AgentFileSpec("claude", ".mcp.json", "mcp_config", "mcp_json", "claude"),
|
|
997
|
+
AgentFileSpec("claude", "CLAUDE.md", "instructions", "marker_md", "claude"),
|
|
998
|
+
]
|
|
999
|
+
if agent_id == "cursor":
|
|
1000
|
+
return [
|
|
1001
|
+
AgentFileSpec("cursor", ".cursor/mcp.json", "mcp_config", "mcp_json", "cursor"),
|
|
1002
|
+
AgentFileSpec("cursor", ".cursorrules", "instructions", "marker_md", "cursor"),
|
|
1003
|
+
AgentFileSpec("cursor", ".cursor/rules/codegraph.md", "rules", "standalone_md", "cursor"),
|
|
1004
|
+
]
|
|
1005
|
+
if agent_id == "antigravity":
|
|
1006
|
+
return [
|
|
1007
|
+
AgentFileSpec("antigravity", ".agents/mcp_config.json", "mcp_config", "mcp_json", "antigravity"),
|
|
1008
|
+
AgentFileSpec("antigravity", ".agents/rules/codegraph.md", "rules", "standalone_md", "antigravity"),
|
|
1009
|
+
AgentFileSpec("antigravity", ".agents/skills/codegraph/SKILL.md", "skill", "standalone_md", "skill"),
|
|
1010
|
+
AgentFileSpec("antigravity", "AGENTS.md", "instructions", "marker_md", "agents"),
|
|
1011
|
+
]
|
|
1012
|
+
if agent_id == "codex":
|
|
1013
|
+
return [
|
|
1014
|
+
AgentFileSpec("codex", ".codex/mcp_config.json", "mcp_config", "mcp_json", "codex"),
|
|
1015
|
+
AgentFileSpec("codex", "AGENTS.md", "instructions", "marker_md", "codex"),
|
|
1016
|
+
]
|
|
1017
|
+
if agent_id == "gemini":
|
|
1018
|
+
return [
|
|
1019
|
+
AgentFileSpec("gemini", ".gemini/settings.json", "mcp_config", "mcp_json", "gemini"),
|
|
1020
|
+
AgentFileSpec("gemini", "GEMINI.md", "instructions", "marker_md", "gemini"),
|
|
1021
|
+
]
|
|
1022
|
+
if agent_id == "cline":
|
|
1023
|
+
return [
|
|
1024
|
+
AgentFileSpec("cline", ".cline/mcp_settings.json", "mcp_config", "mcp_json", "cline"),
|
|
1025
|
+
AgentFileSpec("cline", ".clinerules", "instructions", "marker_md", "cline"),
|
|
1026
|
+
]
|
|
1027
|
+
raise ValueError(f"Unsupported agent: {agent_id}")
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def can_delete_file_when_empty_for_global(rel_path: str) -> bool:
|
|
1031
|
+
return rel_path not in (".claude.json", ".gemini/settings.json")
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _render_spec_content(spec: AgentFileSpec) -> str:
|
|
1035
|
+
if spec.rule_agent_key == "skill":
|
|
1036
|
+
return render_antigravity_skill()
|
|
1037
|
+
return render_agent_rules(spec.rule_agent_key)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _format_display_path(base_root: Path, rel_path: str, location: str) -> str:
|
|
1041
|
+
if location == "global":
|
|
1042
|
+
return f"~/{rel_path}"
|
|
1043
|
+
return rel_path
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
# ---------------------------------------------------------------------------
|
|
1047
|
+
# Verification Engine
|
|
1048
|
+
# ---------------------------------------------------------------------------
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def verify_installation(
|
|
1052
|
+
base_root: Path,
|
|
1053
|
+
changes: list[PlannedFileChange],
|
|
1054
|
+
workspace: Path,
|
|
1055
|
+
run_server_check: bool = True,
|
|
1056
|
+
) -> InstallVerificationResult:
|
|
1057
|
+
"""Verify executable, MCP JSON files, instructions/rules/skills, no duplicates, and MCP server health."""
|
|
1058
|
+
details: list[str] = []
|
|
1059
|
+
|
|
1060
|
+
# 1. Executable available
|
|
1061
|
+
exec_ok = bool(shutil.which("codegraph") or (sys.executable and Path(sys.executable).exists()))
|
|
1062
|
+
details.append("codegraph executable available" if exec_ok else "codegraph executable missing")
|
|
1063
|
+
|
|
1064
|
+
# 2. MCP configuration exists & parses cleanly with no duplicates
|
|
1065
|
+
mcp_changes = [c for c in changes if c.category == "mcp_config"]
|
|
1066
|
+
mcp_ok = True
|
|
1067
|
+
no_dup_ok = True
|
|
1068
|
+
for mc in mcp_changes:
|
|
1069
|
+
if not mc.path.exists():
|
|
1070
|
+
mcp_ok = False
|
|
1071
|
+
details.append(f"Missing MCP config: {mc.display_path}")
|
|
1072
|
+
continue
|
|
1073
|
+
try:
|
|
1074
|
+
raw = mc.path.read_text(encoding="utf-8")
|
|
1075
|
+
parsed = json.loads(raw)
|
|
1076
|
+
servers = parsed.get("mcpServers", {}) if isinstance(parsed, dict) else {}
|
|
1077
|
+
cg_keys = [k for k in servers.keys() if str(k).lower() == "codegraph"]
|
|
1078
|
+
if len(cg_keys) != 1:
|
|
1079
|
+
no_dup_ok = False
|
|
1080
|
+
mcp_ok = False
|
|
1081
|
+
cg = servers.get("codegraph", {})
|
|
1082
|
+
if not isinstance(cg, dict) or not cg.get("command") or cg.get("args") != ["mcp", "serve"]:
|
|
1083
|
+
mcp_ok = False
|
|
1084
|
+
except Exception as exc:
|
|
1085
|
+
mcp_ok = False
|
|
1086
|
+
details.append(f"Invalid MCP config {mc.display_path}: {exc}")
|
|
1087
|
+
|
|
1088
|
+
# 3. Instructions / rules / skills exist and contain no duplicate marker blocks
|
|
1089
|
+
inst_changes = [c for c in changes if c.category in ("instructions", "rules", "skill")]
|
|
1090
|
+
inst_ok = True
|
|
1091
|
+
for ic in inst_changes:
|
|
1092
|
+
if not ic.path.exists():
|
|
1093
|
+
inst_ok = False
|
|
1094
|
+
details.append(f"Missing instruction/rule file: {ic.display_path}")
|
|
1095
|
+
continue
|
|
1096
|
+
raw = ic.path.read_text(encoding="utf-8")
|
|
1097
|
+
if not raw.strip():
|
|
1098
|
+
inst_ok = False
|
|
1099
|
+
continue
|
|
1100
|
+
if MARKER_START in raw:
|
|
1101
|
+
if raw.count(MARKER_START) != 1 or raw.count(MARKER_END) != 1:
|
|
1102
|
+
inst_ok = False
|
|
1103
|
+
no_dup_ok = False
|
|
1104
|
+
details.append(f"Duplicate or unbalanced markers in {ic.display_path}")
|
|
1105
|
+
|
|
1106
|
+
# 4. MCP server health & tool discovery check
|
|
1107
|
+
server_ok = False
|
|
1108
|
+
tool_count = 0
|
|
1109
|
+
if run_server_check:
|
|
1110
|
+
try:
|
|
1111
|
+
from codegraph.mcp import create_server
|
|
1112
|
+
|
|
1113
|
+
if (workspace / ".codegraph.sqlite3").exists():
|
|
1114
|
+
srv = create_server(workspace, profile="full")
|
|
1115
|
+
tool_mgr = getattr(srv, "_tool_manager", None)
|
|
1116
|
+
tools_dict = getattr(tool_mgr, "_tools", {}) if tool_mgr else {}
|
|
1117
|
+
else:
|
|
1118
|
+
with tempfile.TemporaryDirectory(prefix="codegraph_verify_") as tmp_srv_dir:
|
|
1119
|
+
srv = create_server(Path(tmp_srv_dir), profile="full")
|
|
1120
|
+
tool_mgr = getattr(srv, "_tool_manager", None)
|
|
1121
|
+
tools_dict = getattr(tool_mgr, "_tools", {}) if tool_mgr else {}
|
|
1122
|
+
tool_count = len(tools_dict)
|
|
1123
|
+
server_ok = tool_count >= 56 and "find_symbol" in tools_dict and "get_context" in tools_dict
|
|
1124
|
+
details.append(f"MCP server initialized with {tool_count} tools")
|
|
1125
|
+
except Exception as exc:
|
|
1126
|
+
server_ok = False
|
|
1127
|
+
details.append(f"MCP server check failed: {exc}")
|
|
1128
|
+
else:
|
|
1129
|
+
server_ok = True
|
|
1130
|
+
tool_count = 56
|
|
1131
|
+
|
|
1132
|
+
passed = exec_ok and mcp_ok and inst_ok and server_ok and no_dup_ok
|
|
1133
|
+
return InstallVerificationResult(
|
|
1134
|
+
passed=passed,
|
|
1135
|
+
executable_verified=exec_ok,
|
|
1136
|
+
mcp_config_verified=mcp_ok,
|
|
1137
|
+
instructions_verified=inst_ok,
|
|
1138
|
+
mcp_server_health_verified=server_ok,
|
|
1139
|
+
no_duplicate_config_verified=no_dup_ok,
|
|
1140
|
+
tool_count=tool_count,
|
|
1141
|
+
details=tuple(details),
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
# ---------------------------------------------------------------------------
|
|
1146
|
+
# Install / Uninstall / Uninit Execution
|
|
1147
|
+
# ---------------------------------------------------------------------------
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def plan_install_changes(
|
|
1151
|
+
selected_agents: list[str],
|
|
1152
|
+
base_root: Path,
|
|
1153
|
+
location: str,
|
|
1154
|
+
command: str = "codegraph",
|
|
1155
|
+
allow_malformed_repair: bool = False,
|
|
1156
|
+
) -> tuple[list[PlannedFileChange], list[str], list[str]]:
|
|
1157
|
+
"""Compute all deterministic file operations for `install` without writing to disk."""
|
|
1158
|
+
planned: list[PlannedFileChange] = []
|
|
1159
|
+
warnings: list[str] = []
|
|
1160
|
+
repairs: list[str] = []
|
|
1161
|
+
seen_rel_paths: set[str] = set()
|
|
1162
|
+
|
|
1163
|
+
for agent_id in selected_agents:
|
|
1164
|
+
for spec in get_agent_file_specs(agent_id, location):
|
|
1165
|
+
if spec.relative_path in seen_rel_paths:
|
|
1166
|
+
continue
|
|
1167
|
+
seen_rel_paths.add(spec.relative_path)
|
|
1168
|
+
|
|
1169
|
+
target_path = _ensure_safe_target_path(base_root, spec.relative_path)
|
|
1170
|
+
disp = _format_display_path(base_root, spec.relative_path, location)
|
|
1171
|
+
existing_text = target_path.read_text(encoding="utf-8") if target_path.exists() else None
|
|
1172
|
+
|
|
1173
|
+
if spec.mode == "mcp_json":
|
|
1174
|
+
new_text, action, repair_msg = upsert_mcp_json_config(
|
|
1175
|
+
existing_text=existing_text,
|
|
1176
|
+
agent_id=agent_id,
|
|
1177
|
+
display_path=disp,
|
|
1178
|
+
command=command,
|
|
1179
|
+
allow_malformed_repair=allow_malformed_repair,
|
|
1180
|
+
)
|
|
1181
|
+
if repair_msg:
|
|
1182
|
+
repairs.append(repair_msg)
|
|
1183
|
+
planned.append(
|
|
1184
|
+
PlannedFileChange(
|
|
1185
|
+
path=target_path,
|
|
1186
|
+
display_path=disp,
|
|
1187
|
+
action=action,
|
|
1188
|
+
category=spec.category,
|
|
1189
|
+
agent_id=agent_id,
|
|
1190
|
+
detail=repair_msg or f"Configure mcpServers.codegraph in {disp}",
|
|
1191
|
+
new_content=new_text,
|
|
1192
|
+
)
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
elif spec.mode == "marker_md":
|
|
1196
|
+
inner = _render_spec_content(spec)
|
|
1197
|
+
new_text, action, repair_msg = upsert_marker_section(existing_text, inner)
|
|
1198
|
+
if repair_msg:
|
|
1199
|
+
repairs.append(f"{disp}: {repair_msg}")
|
|
1200
|
+
planned.append(
|
|
1201
|
+
PlannedFileChange(
|
|
1202
|
+
path=target_path,
|
|
1203
|
+
display_path=disp,
|
|
1204
|
+
action=action,
|
|
1205
|
+
category=spec.category,
|
|
1206
|
+
agent_id=agent_id,
|
|
1207
|
+
detail=repair_msg or f"Manage CodeGraph marker section in {disp}",
|
|
1208
|
+
new_content=new_text,
|
|
1209
|
+
)
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
elif spec.mode == "standalone_md":
|
|
1213
|
+
desired_text = _render_spec_content(spec)
|
|
1214
|
+
if existing_text is None:
|
|
1215
|
+
planned.append(
|
|
1216
|
+
PlannedFileChange(
|
|
1217
|
+
path=target_path,
|
|
1218
|
+
display_path=disp,
|
|
1219
|
+
action="create",
|
|
1220
|
+
category=spec.category,
|
|
1221
|
+
agent_id=agent_id,
|
|
1222
|
+
detail=f"Create CodeGraph {spec.category} at {disp}",
|
|
1223
|
+
new_content=desired_text,
|
|
1224
|
+
)
|
|
1225
|
+
)
|
|
1226
|
+
elif existing_text == desired_text:
|
|
1227
|
+
planned.append(
|
|
1228
|
+
PlannedFileChange(
|
|
1229
|
+
path=target_path,
|
|
1230
|
+
display_path=disp,
|
|
1231
|
+
action="unchanged",
|
|
1232
|
+
category=spec.category,
|
|
1233
|
+
agent_id=agent_id,
|
|
1234
|
+
detail=f"{disp} is already up to date",
|
|
1235
|
+
new_content=desired_text,
|
|
1236
|
+
)
|
|
1237
|
+
)
|
|
1238
|
+
elif not is_codegraph_content_unmodified(existing_text):
|
|
1239
|
+
w_msg = f"Protected manually modified file '{disp}'; skipping overwrite."
|
|
1240
|
+
warnings.append(w_msg)
|
|
1241
|
+
planned.append(
|
|
1242
|
+
PlannedFileChange(
|
|
1243
|
+
path=target_path,
|
|
1244
|
+
display_path=disp,
|
|
1245
|
+
action="skip_modified",
|
|
1246
|
+
category=spec.category,
|
|
1247
|
+
agent_id=agent_id,
|
|
1248
|
+
detail="Manually modified by user; preserved without overwriting",
|
|
1249
|
+
new_content=None,
|
|
1250
|
+
)
|
|
1251
|
+
)
|
|
1252
|
+
else:
|
|
1253
|
+
planned.append(
|
|
1254
|
+
PlannedFileChange(
|
|
1255
|
+
path=target_path,
|
|
1256
|
+
display_path=disp,
|
|
1257
|
+
action="modify",
|
|
1258
|
+
category=spec.category,
|
|
1259
|
+
agent_id=agent_id,
|
|
1260
|
+
detail=f"Update CodeGraph {spec.category} at {disp}",
|
|
1261
|
+
new_content=desired_text,
|
|
1262
|
+
)
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
return planned, warnings, repairs
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def _apply_planned_changes_atomically(changes: list[PlannedFileChange]) -> list[PlannedFileChange]:
|
|
1269
|
+
"""Execute planned file writes/removals with rollback snapshots if any step fails."""
|
|
1270
|
+
snapshots: list[tuple[Path, str | None]] = []
|
|
1271
|
+
applied: list[PlannedFileChange] = []
|
|
1272
|
+
|
|
1273
|
+
try:
|
|
1274
|
+
for ch in changes:
|
|
1275
|
+
if ch.action in ("unchanged", "skip_modified"):
|
|
1276
|
+
applied.append(ch)
|
|
1277
|
+
continue
|
|
1278
|
+
|
|
1279
|
+
orig_content = ch.path.read_text(encoding="utf-8") if ch.path.exists() else None
|
|
1280
|
+
snapshots.append((ch.path, orig_content))
|
|
1281
|
+
|
|
1282
|
+
backup_path: Path | None = None
|
|
1283
|
+
if ch.action == "repair" and orig_content is not None:
|
|
1284
|
+
backup_path = ch.path.with_Name(f"{ch.path.name}.bak") if hasattr(ch.path, "with_Name") else ch.path.parent / f"{ch.path.name}.bak"
|
|
1285
|
+
_atomic_write_text(backup_path, orig_content)
|
|
1286
|
+
|
|
1287
|
+
if ch.action == "remove":
|
|
1288
|
+
if ch.path.exists():
|
|
1289
|
+
ch.path.unlink()
|
|
1290
|
+
applied.append(ch)
|
|
1291
|
+
elif ch.new_content is not None:
|
|
1292
|
+
_atomic_write_text(ch.path, ch.new_content)
|
|
1293
|
+
# Verify written file immediately
|
|
1294
|
+
written_back = ch.path.read_text(encoding="utf-8")
|
|
1295
|
+
if written_back != ch.new_content:
|
|
1296
|
+
raise OSError(f"Verification mismatch after atomic write to {ch.path}")
|
|
1297
|
+
if ch.category == "mcp_config":
|
|
1298
|
+
json.loads(written_back)
|
|
1299
|
+
applied.append(
|
|
1300
|
+
PlannedFileChange(
|
|
1301
|
+
path=ch.path,
|
|
1302
|
+
display_path=ch.display_path,
|
|
1303
|
+
action=ch.action,
|
|
1304
|
+
category=ch.category,
|
|
1305
|
+
agent_id=ch.agent_id,
|
|
1306
|
+
detail=ch.detail,
|
|
1307
|
+
new_content=ch.new_content,
|
|
1308
|
+
backup_path=backup_path,
|
|
1309
|
+
)
|
|
1310
|
+
)
|
|
1311
|
+
except Exception:
|
|
1312
|
+
# Roll back all modified files in reverse order
|
|
1313
|
+
for path_item, prev_text in reversed(snapshots):
|
|
1314
|
+
try:
|
|
1315
|
+
if prev_text is None:
|
|
1316
|
+
if path_item.exists():
|
|
1317
|
+
path_item.unlink()
|
|
1318
|
+
else:
|
|
1319
|
+
_atomic_write_text(path_item, prev_text)
|
|
1320
|
+
except Exception:
|
|
1321
|
+
pass
|
|
1322
|
+
raise
|
|
1323
|
+
|
|
1324
|
+
return applied
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def run_install(
|
|
1328
|
+
workspace: Path,
|
|
1329
|
+
target: str = "auto",
|
|
1330
|
+
location: str = "local",
|
|
1331
|
+
dry_run: bool = False,
|
|
1332
|
+
allow_malformed_repair: bool = False,
|
|
1333
|
+
home: Path | None = None,
|
|
1334
|
+
which_fn: Callable[[str], str | None] | None = None,
|
|
1335
|
+
confirm_fn: Callable[[InstallerReport], bool] | None = None,
|
|
1336
|
+
run_server_check: bool = True,
|
|
1337
|
+
) -> InstallerReport:
|
|
1338
|
+
"""Execute or preview `codegraph install` with full safety, idempotency, and verification."""
|
|
1339
|
+
effective_ws = workspace.resolve()
|
|
1340
|
+
effective_home = (home or Path.home()).resolve()
|
|
1341
|
+
scope = "global" if location.strip().lower() == "global" else "local"
|
|
1342
|
+
if location.strip().lower() not in ("local", "global", "project"):
|
|
1343
|
+
raise CodeGraphError(
|
|
1344
|
+
f"Invalid installation location '{location}'. Expected 'local' or 'global'.",
|
|
1345
|
+
code=ErrorCode.INVALID_ARGUMENT,
|
|
1346
|
+
next_action={
|
|
1347
|
+
"command": "codegraph install --location local --dry-run",
|
|
1348
|
+
"reason": "Specify --location local or --location global.",
|
|
1349
|
+
},
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
base_root = effective_home if scope == "global" else effective_ws
|
|
1353
|
+
detected = detect_agents(effective_ws, home=effective_home, which_fn=which_fn)
|
|
1354
|
+
selected = resolve_target_agents(target, detected)
|
|
1355
|
+
|
|
1356
|
+
changes, warnings, repairs = plan_install_changes(
|
|
1357
|
+
selected_agents=selected,
|
|
1358
|
+
base_root=base_root,
|
|
1359
|
+
location=scope,
|
|
1360
|
+
command="codegraph",
|
|
1361
|
+
allow_malformed_repair=allow_malformed_repair,
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
from codegraph.process_lifecycle import (
|
|
1365
|
+
discover_codegraph_processes,
|
|
1366
|
+
stop_codegraph_processes,
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
proc_discovery = discover_codegraph_processes(clean_stale=not dry_run)
|
|
1370
|
+
current_pid = os.getpid()
|
|
1371
|
+
other_active = [
|
|
1372
|
+
p for p in proc_discovery.active_processes if p.record.pid != current_pid
|
|
1373
|
+
]
|
|
1374
|
+
orphaned_procs = [p for p in other_active if p.orphaned]
|
|
1375
|
+
live_procs = [p for p in other_active if not p.orphaned]
|
|
1376
|
+
|
|
1377
|
+
if orphaned_procs and dry_run:
|
|
1378
|
+
pids_str = ", ".join(str(p.record.pid) for p in orphaned_procs)
|
|
1379
|
+
warnings.append(
|
|
1380
|
+
f"Orphaned CodeGraph process(es) detected (PID: {pids_str}); "
|
|
1381
|
+
"run 'codegraph stop' or 'codegraph install' to clean up."
|
|
1382
|
+
)
|
|
1383
|
+
if live_procs:
|
|
1384
|
+
pids_str = ", ".join(str(p.record.pid) for p in live_procs)
|
|
1385
|
+
warnings.append(
|
|
1386
|
+
f"Active CodeGraph process(es) running (PID: {pids_str}). "
|
|
1387
|
+
"Before upgrading ('pip install --upgrade codegraph-engine'), run 'codegraph stop' "
|
|
1388
|
+
"to prevent executable file lock errors (WinError 32)."
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
preview_report = InstallerReport(
|
|
1392
|
+
status="dry_run" if dry_run else "ok",
|
|
1393
|
+
operation="install",
|
|
1394
|
+
location=scope,
|
|
1395
|
+
workspace=str(effective_ws),
|
|
1396
|
+
detected_agents=tuple(detected),
|
|
1397
|
+
selected_agents=tuple(selected),
|
|
1398
|
+
changes=tuple(changes),
|
|
1399
|
+
warnings=tuple(warnings),
|
|
1400
|
+
repairs=tuple(repairs),
|
|
1401
|
+
files_changed=False,
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
if dry_run:
|
|
1405
|
+
return preview_report
|
|
1406
|
+
|
|
1407
|
+
has_mutations = any(c.action in ("create", "modify", "repair") for c in changes)
|
|
1408
|
+
if confirm_fn is not None and has_mutations:
|
|
1409
|
+
if not confirm_fn(preview_report):
|
|
1410
|
+
return InstallerReport(
|
|
1411
|
+
status="aborted",
|
|
1412
|
+
operation="install",
|
|
1413
|
+
location=scope,
|
|
1414
|
+
workspace=str(effective_ws),
|
|
1415
|
+
detected_agents=tuple(detected),
|
|
1416
|
+
selected_agents=tuple(selected),
|
|
1417
|
+
changes=tuple(changes),
|
|
1418
|
+
warnings=tuple(warnings),
|
|
1419
|
+
repairs=tuple(repairs),
|
|
1420
|
+
files_changed=False,
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
if orphaned_procs:
|
|
1424
|
+
stop_rep = stop_codegraph_processes(only_orphaned=True)
|
|
1425
|
+
stopped_cnt = sum(1 for item in stop_rep.stopped_processes if item.stopped)
|
|
1426
|
+
if stopped_cnt > 0:
|
|
1427
|
+
repairs.append(f"Stopped {stopped_cnt} orphaned CodeGraph background process(es).")
|
|
1428
|
+
|
|
1429
|
+
applied = _apply_planned_changes_atomically(changes)
|
|
1430
|
+
ver = verify_installation(
|
|
1431
|
+
base_root=base_root,
|
|
1432
|
+
changes=applied,
|
|
1433
|
+
workspace=effective_ws,
|
|
1434
|
+
run_server_check=run_server_check,
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
return InstallerReport(
|
|
1438
|
+
status="ok" if ver.passed else "error",
|
|
1439
|
+
operation="install",
|
|
1440
|
+
location=scope,
|
|
1441
|
+
workspace=str(effective_ws),
|
|
1442
|
+
detected_agents=tuple(detected),
|
|
1443
|
+
selected_agents=tuple(selected),
|
|
1444
|
+
changes=tuple(applied),
|
|
1445
|
+
warnings=tuple(warnings),
|
|
1446
|
+
repairs=tuple(repairs),
|
|
1447
|
+
verification=ver,
|
|
1448
|
+
files_changed=has_mutations,
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def run_uninstall(
|
|
1453
|
+
workspace: Path,
|
|
1454
|
+
target: str = "all",
|
|
1455
|
+
location: str = "local",
|
|
1456
|
+
dry_run: bool = False,
|
|
1457
|
+
home: Path | None = None,
|
|
1458
|
+
which_fn: Callable[[str], str | None] | None = None,
|
|
1459
|
+
confirm_fn: Callable[[InstallerReport], bool] | None = None,
|
|
1460
|
+
) -> InstallerReport:
|
|
1461
|
+
"""Reverse only CodeGraph-managed agent integrations without deleting user config or `.codegraph` index."""
|
|
1462
|
+
effective_ws = workspace.resolve()
|
|
1463
|
+
effective_home = (home or Path.home()).resolve()
|
|
1464
|
+
scope = "global" if location.strip().lower() == "global" else "local"
|
|
1465
|
+
if location.strip().lower() not in ("local", "global", "project"):
|
|
1466
|
+
raise CodeGraphError(
|
|
1467
|
+
f"Invalid location '{location}'. Expected 'local' or 'global'.",
|
|
1468
|
+
code=ErrorCode.INVALID_ARGUMENT,
|
|
1469
|
+
next_action={
|
|
1470
|
+
"command": "codegraph uninstall --location local --dry-run",
|
|
1471
|
+
"reason": "Specify --location local or --location global.",
|
|
1472
|
+
},
|
|
1473
|
+
)
|
|
1474
|
+
|
|
1475
|
+
base_root = effective_home if scope == "global" else effective_ws
|
|
1476
|
+
detected = detect_agents(effective_ws, home=effective_home, which_fn=which_fn)
|
|
1477
|
+
effective_target = "all" if target.strip().lower() == "auto" else target
|
|
1478
|
+
selected = resolve_target_agents(effective_target, detected)
|
|
1479
|
+
|
|
1480
|
+
planned: list[PlannedFileChange] = []
|
|
1481
|
+
warnings: list[str] = []
|
|
1482
|
+
seen_rel_paths: set[str] = set()
|
|
1483
|
+
|
|
1484
|
+
for agent_id in selected:
|
|
1485
|
+
for spec in get_agent_file_specs(agent_id, scope):
|
|
1486
|
+
if spec.relative_path in seen_rel_paths:
|
|
1487
|
+
continue
|
|
1488
|
+
seen_rel_paths.add(spec.relative_path)
|
|
1489
|
+
|
|
1490
|
+
target_path = _ensure_safe_target_path(base_root, spec.relative_path)
|
|
1491
|
+
disp = _format_display_path(base_root, spec.relative_path, scope)
|
|
1492
|
+
if not target_path.exists():
|
|
1493
|
+
continue
|
|
1494
|
+
|
|
1495
|
+
existing_text = target_path.read_text(encoding="utf-8")
|
|
1496
|
+
|
|
1497
|
+
if spec.mode == "mcp_json":
|
|
1498
|
+
new_text, action = remove_mcp_json_config(
|
|
1499
|
+
existing_text=existing_text,
|
|
1500
|
+
display_path=disp,
|
|
1501
|
+
can_delete_file_when_empty=spec.can_delete_when_empty,
|
|
1502
|
+
)
|
|
1503
|
+
if action != "unchanged":
|
|
1504
|
+
planned.append(
|
|
1505
|
+
PlannedFileChange(
|
|
1506
|
+
path=target_path,
|
|
1507
|
+
display_path=disp,
|
|
1508
|
+
action=action,
|
|
1509
|
+
category=spec.category,
|
|
1510
|
+
agent_id=agent_id,
|
|
1511
|
+
detail=f"Remove mcpServers.codegraph from {disp}",
|
|
1512
|
+
new_content=new_text,
|
|
1513
|
+
)
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
elif spec.mode == "marker_md":
|
|
1517
|
+
new_text, action, warn = remove_marker_section(existing_text)
|
|
1518
|
+
if warn:
|
|
1519
|
+
warnings.append(f"{disp}: {warn}")
|
|
1520
|
+
if action != "unchanged":
|
|
1521
|
+
planned.append(
|
|
1522
|
+
PlannedFileChange(
|
|
1523
|
+
path=target_path,
|
|
1524
|
+
display_path=disp,
|
|
1525
|
+
action=action,
|
|
1526
|
+
category=spec.category,
|
|
1527
|
+
agent_id=agent_id,
|
|
1528
|
+
detail=warn or f"Remove CodeGraph marker section from {disp}",
|
|
1529
|
+
new_content=new_text,
|
|
1530
|
+
)
|
|
1531
|
+
)
|
|
1532
|
+
|
|
1533
|
+
elif spec.mode == "standalone_md":
|
|
1534
|
+
if not is_codegraph_content_unmodified(existing_text):
|
|
1535
|
+
w_msg = f"Modified file '{disp}' was not deleted to preserve user edits."
|
|
1536
|
+
warnings.append(w_msg)
|
|
1537
|
+
planned.append(
|
|
1538
|
+
PlannedFileChange(
|
|
1539
|
+
path=target_path,
|
|
1540
|
+
display_path=disp,
|
|
1541
|
+
action="skip_modified",
|
|
1542
|
+
category=spec.category,
|
|
1543
|
+
agent_id=agent_id,
|
|
1544
|
+
detail="Modified by user; preserved",
|
|
1545
|
+
new_content=None,
|
|
1546
|
+
)
|
|
1547
|
+
)
|
|
1548
|
+
else:
|
|
1549
|
+
planned.append(
|
|
1550
|
+
PlannedFileChange(
|
|
1551
|
+
path=target_path,
|
|
1552
|
+
display_path=disp,
|
|
1553
|
+
action="remove",
|
|
1554
|
+
category=spec.category,
|
|
1555
|
+
agent_id=agent_id,
|
|
1556
|
+
detail=f"Remove CodeGraph {spec.category} file {disp}",
|
|
1557
|
+
new_content=None,
|
|
1558
|
+
)
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
preview = InstallerReport(
|
|
1562
|
+
status="dry_run" if dry_run else "ok",
|
|
1563
|
+
operation="uninstall",
|
|
1564
|
+
location=scope,
|
|
1565
|
+
workspace=str(effective_ws),
|
|
1566
|
+
detected_agents=tuple(detected),
|
|
1567
|
+
selected_agents=tuple(selected),
|
|
1568
|
+
changes=tuple(planned),
|
|
1569
|
+
warnings=tuple(warnings),
|
|
1570
|
+
files_changed=False,
|
|
1571
|
+
)
|
|
1572
|
+
|
|
1573
|
+
if dry_run:
|
|
1574
|
+
return preview
|
|
1575
|
+
|
|
1576
|
+
has_mutations = any(c.action in ("remove", "modify") for c in planned)
|
|
1577
|
+
if confirm_fn is not None and has_mutations:
|
|
1578
|
+
if not confirm_fn(preview):
|
|
1579
|
+
return InstallerReport(
|
|
1580
|
+
status="aborted",
|
|
1581
|
+
operation="uninstall",
|
|
1582
|
+
location=scope,
|
|
1583
|
+
workspace=str(effective_ws),
|
|
1584
|
+
detected_agents=tuple(detected),
|
|
1585
|
+
selected_agents=tuple(selected),
|
|
1586
|
+
changes=tuple(planned),
|
|
1587
|
+
warnings=tuple(warnings),
|
|
1588
|
+
files_changed=False,
|
|
1589
|
+
)
|
|
1590
|
+
|
|
1591
|
+
from codegraph.process_lifecycle import stop_codegraph_processes
|
|
1592
|
+
|
|
1593
|
+
stop_codegraph_processes(
|
|
1594
|
+
repository=None if scope == "global" else effective_ws,
|
|
1595
|
+
stop_all=(scope == "global"),
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
applied = _apply_planned_changes_atomically(planned)
|
|
1599
|
+
return InstallerReport(
|
|
1600
|
+
status="ok",
|
|
1601
|
+
operation="uninstall",
|
|
1602
|
+
location=scope,
|
|
1603
|
+
workspace=str(effective_ws),
|
|
1604
|
+
detected_agents=tuple(detected),
|
|
1605
|
+
selected_agents=tuple(selected),
|
|
1606
|
+
changes=tuple(applied),
|
|
1607
|
+
warnings=tuple(warnings),
|
|
1608
|
+
files_changed=has_mutations,
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def run_uninit(
|
|
1613
|
+
workspace: Path,
|
|
1614
|
+
dry_run: bool = False,
|
|
1615
|
+
confirm_fn: Callable[[InstallerReport], bool] | None = None,
|
|
1616
|
+
) -> InstallerReport:
|
|
1617
|
+
"""Remove only the current project's CodeGraph index artifacts (`.codegraph.sqlite3*`, `.codegraph/`)."""
|
|
1618
|
+
effective_ws = workspace.resolve()
|
|
1619
|
+
index_candidates = (
|
|
1620
|
+
".codegraph.sqlite3",
|
|
1621
|
+
".codegraph.sqlite3-wal",
|
|
1622
|
+
".codegraph.sqlite3-shm",
|
|
1623
|
+
".codegraph.sqlite3-journal",
|
|
1624
|
+
)
|
|
1625
|
+
|
|
1626
|
+
planned: list[PlannedFileChange] = []
|
|
1627
|
+
for rel_name in index_candidates:
|
|
1628
|
+
p = _ensure_safe_target_path(effective_ws, rel_name, allow_index_artifact=True)
|
|
1629
|
+
if p.exists():
|
|
1630
|
+
planned.append(
|
|
1631
|
+
PlannedFileChange(
|
|
1632
|
+
path=p,
|
|
1633
|
+
display_path=rel_name,
|
|
1634
|
+
action="remove",
|
|
1635
|
+
category="index",
|
|
1636
|
+
agent_id="project",
|
|
1637
|
+
detail=f"Remove CodeGraph SQLite index file {rel_name}",
|
|
1638
|
+
)
|
|
1639
|
+
)
|
|
1640
|
+
|
|
1641
|
+
cg_dir = effective_ws / ".codegraph"
|
|
1642
|
+
if cg_dir.is_symlink():
|
|
1643
|
+
raise SecurityError(
|
|
1644
|
+
"Refusing to remove symlinked .codegraph directory.",
|
|
1645
|
+
code=ErrorCode.PATH_OUTSIDE_REPOSITORY,
|
|
1646
|
+
)
|
|
1647
|
+
if cg_dir.exists() and cg_dir.is_dir():
|
|
1648
|
+
planned.append(
|
|
1649
|
+
PlannedFileChange(
|
|
1650
|
+
path=cg_dir,
|
|
1651
|
+
display_path=".codegraph/",
|
|
1652
|
+
action="remove",
|
|
1653
|
+
category="index",
|
|
1654
|
+
agent_id="project",
|
|
1655
|
+
detail="Remove .codegraph/ cache directory",
|
|
1656
|
+
)
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
preview = InstallerReport(
|
|
1660
|
+
status="dry_run" if dry_run else "ok",
|
|
1661
|
+
operation="uninit",
|
|
1662
|
+
location="local",
|
|
1663
|
+
workspace=str(effective_ws),
|
|
1664
|
+
detected_agents=(),
|
|
1665
|
+
selected_agents=(),
|
|
1666
|
+
changes=tuple(planned),
|
|
1667
|
+
files_changed=False,
|
|
1668
|
+
)
|
|
1669
|
+
|
|
1670
|
+
if dry_run:
|
|
1671
|
+
return preview
|
|
1672
|
+
|
|
1673
|
+
has_mutations = len(planned) > 0
|
|
1674
|
+
if confirm_fn is not None and has_mutations:
|
|
1675
|
+
if not confirm_fn(preview):
|
|
1676
|
+
return InstallerReport(
|
|
1677
|
+
status="aborted",
|
|
1678
|
+
operation="uninit",
|
|
1679
|
+
location="local",
|
|
1680
|
+
workspace=str(effective_ws),
|
|
1681
|
+
detected_agents=(),
|
|
1682
|
+
selected_agents=(),
|
|
1683
|
+
changes=tuple(planned),
|
|
1684
|
+
files_changed=False,
|
|
1685
|
+
)
|
|
1686
|
+
|
|
1687
|
+
for ch in planned:
|
|
1688
|
+
if ch.display_path == ".codegraph/" and ch.path.exists() and ch.path.is_dir():
|
|
1689
|
+
shutil.rmtree(ch.path)
|
|
1690
|
+
elif ch.path.exists():
|
|
1691
|
+
ch.path.unlink()
|
|
1692
|
+
|
|
1693
|
+
return InstallerReport(
|
|
1694
|
+
status="ok",
|
|
1695
|
+
operation="uninit",
|
|
1696
|
+
location="local",
|
|
1697
|
+
workspace=str(effective_ws),
|
|
1698
|
+
detected_agents=(),
|
|
1699
|
+
selected_agents=(),
|
|
1700
|
+
changes=tuple(planned),
|
|
1701
|
+
files_changed=has_mutations,
|
|
1702
|
+
)
|