codegraph-engine 3.0.0__2-py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +868 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. 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
+ )