codemesh 0.1.4__tar.gz → 0.1.6__tar.gz

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 (72) hide show
  1. {codemesh-0.1.4 → codemesh-0.1.6}/PKG-INFO +1 -1
  2. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/__init__.py +1 -1
  3. codemesh-0.1.6/codemesh/cli/install_cmd.py +634 -0
  4. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/cli/main.py +74 -42
  5. {codemesh-0.1.4 → codemesh-0.1.6}/pyproject.toml +1 -1
  6. codemesh-0.1.4/codemesh/cli/install_cmd.py +0 -346
  7. {codemesh-0.1.4 → codemesh-0.1.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  8. {codemesh-0.1.4 → codemesh-0.1.6}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  9. {codemesh-0.1.4 → codemesh-0.1.6}/.github/workflows/ci.yml +0 -0
  10. {codemesh-0.1.4 → codemesh-0.1.6}/.github/workflows/publish.yml +0 -0
  11. {codemesh-0.1.4 → codemesh-0.1.6}/.gitignore +0 -0
  12. {codemesh-0.1.4 → codemesh-0.1.6}/CHANGELOG.md +0 -0
  13. {codemesh-0.1.4 → codemesh-0.1.6}/CONTRIBUTING.md +0 -0
  14. {codemesh-0.1.4 → codemesh-0.1.6}/LICENSE +0 -0
  15. {codemesh-0.1.4 → codemesh-0.1.6}/Makefile +0 -0
  16. {codemesh-0.1.4 → codemesh-0.1.6}/README.md +0 -0
  17. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/__main__.py +0 -0
  18. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/cli/__init__.py +0 -0
  19. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/cli/init.py +0 -0
  20. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/context/__init__.py +0 -0
  21. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/context/builder.py +0 -0
  22. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/db/__init__.py +0 -0
  23. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/db/connection.py +0 -0
  24. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/db/queries.py +0 -0
  25. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/db/schema.py +0 -0
  26. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/embedding/__init__.py +0 -0
  27. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/extraction/__init__.py +0 -0
  28. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/extraction/languages/__init__.py +0 -0
  29. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/extraction/languages/c_family.py +0 -0
  30. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/extraction/languages/go.py +0 -0
  31. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/extraction/languages/java.py +0 -0
  32. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/extraction/languages/python.py +0 -0
  33. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/extraction/languages/rust.py +0 -0
  34. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/extraction/languages/swift.py +0 -0
  35. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/extraction/languages/typescript.py +0 -0
  36. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/extraction/orchestrator.py +0 -0
  37. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/graph/__init__.py +0 -0
  38. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/graph/query_manager.py +0 -0
  39. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/graph/traverser.py +0 -0
  40. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/indexer.py +0 -0
  41. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/mcp/__init__.py +0 -0
  42. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/mcp/server.py +0 -0
  43. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/mcp/tools.py +0 -0
  44. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/querier.py +0 -0
  45. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/resolution/__init__.py +0 -0
  46. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/resolution/frameworks/__init__.py +0 -0
  47. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/resolution/frameworks/django.py +0 -0
  48. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/resolution/frameworks/fastapi.py +0 -0
  49. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/resolution/import_resolver.py +0 -0
  50. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/resolution/name_matcher.py +0 -0
  51. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/resolution/resolver.py +0 -0
  52. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/retrieval/__init__.py +0 -0
  53. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/search/__init__.py +0 -0
  54. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/sync/__init__.py +0 -0
  55. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/sync/watcher.py +0 -0
  56. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/types.py +0 -0
  57. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/viz/__init__.py +0 -0
  58. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/viz/graph_builder.py +0 -0
  59. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/viz/server.py +0 -0
  60. {codemesh-0.1.4 → codemesh-0.1.6}/codemesh/viz/templates/index.html +0 -0
  61. {codemesh-0.1.4 → codemesh-0.1.6}/tests/__init__.py +0 -0
  62. {codemesh-0.1.4 → codemesh-0.1.6}/tests/conftest.py +0 -0
  63. {codemesh-0.1.4 → codemesh-0.1.6}/tests/fixtures/__init__.py +0 -0
  64. {codemesh-0.1.4 → codemesh-0.1.6}/tests/test_adversarial.py +0 -0
  65. {codemesh-0.1.4 → codemesh-0.1.6}/tests/test_benchmark_repoqa.py +0 -0
  66. {codemesh-0.1.4 → codemesh-0.1.6}/tests/test_embedding_e2e.py +0 -0
  67. {codemesh-0.1.4 → codemesh-0.1.6}/tests/test_extraction.py +0 -0
  68. {codemesh-0.1.4 → codemesh-0.1.6}/tests/test_integration.py +0 -0
  69. {codemesh-0.1.4 → codemesh-0.1.6}/tests/test_llm_judge.py +0 -0
  70. {codemesh-0.1.4 → codemesh-0.1.6}/tests/test_performance.py +0 -0
  71. {codemesh-0.1.4 → codemesh-0.1.6}/tests/test_viz.py +0 -0
  72. {codemesh-0.1.4 → codemesh-0.1.6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codemesh
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: BM25 keyword search and graph walk for code intelligence
5
5
  Project-URL: Homepage, https://github.com/gkatte/codemesh
6
6
  Project-URL: Repository, https://github.com/gkatte/codemesh
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.4"
5
+ __version__ = "0.1.6"
@@ -0,0 +1,634 @@
1
+ # mypy: ignore-errors
2
+ """Install/uninstall commands — configure CodeMesh MCP server for AI coding agents."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import shutil
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ import typer
13
+
14
+ from codemesh.cli.init import _CLAUDE_MD_TEMPLATE, _CODEX_TEMPLATE, _CURSOR_RULES_TEMPLATE
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # ── MCP server config templates ──────────────────────────────────────────────
20
+
21
+ _CLAUDE_MCP_CONFIG = {
22
+ "mcpServers": {
23
+ "codemesh": {
24
+ "type": "stdio",
25
+ "command": "codemesh",
26
+ "args": ["serve", "--transport", "stdio"],
27
+ }
28
+ }
29
+ }
30
+
31
+ _CLAUDE_PERMISSIONS = {
32
+ "permissions": {
33
+ "allow": [
34
+ "mcp__codemesh__codemesh_search",
35
+ "mcp__codemesh__codemesh_context",
36
+ "mcp__codemesh__codemesh_callers",
37
+ "mcp__codemesh__codemesh_callees",
38
+ "mcp__codemesh__codemesh_impact",
39
+ "mcp__codemesh__codemesh_node",
40
+ "mcp__codemesh__codemesh_status",
41
+ "mcp__codemesh__codemesh_files",
42
+ "mcp__codemesh__codemesh_explore",
43
+ ]
44
+ }
45
+ }
46
+
47
+
48
+ # ── Agent metadata ───────────────────────────────────────────────────────────
49
+
50
+ @dataclass
51
+ class AgentInfo:
52
+ name: str # canonical key: "claude", "cursor", etc.
53
+ display: str # human name: "Claude Code"
54
+ detected: bool = False # is the agent installed on this machine?
55
+ configured: bool = False # is codemesh already configured for this agent?
56
+ scope: str = "project" # "project" or "global"
57
+ detail: str = "" # extra info for the UI, e.g. path
58
+
59
+
60
+ def detect_agents(root: Path | None = None) -> list[AgentInfo]:
61
+ """Detect all supported agents and their codemesh configuration status.
62
+
63
+ Returns a list of AgentInfo for every known agent, with detected/configured
64
+ flags set accordingly.
65
+ """
66
+ if root is None:
67
+ root = Path.cwd()
68
+
69
+ agents: list[AgentInfo] = []
70
+
71
+ # ── Claude Code ─────────────────────────────────────────────────────────
72
+ claude_dir = _find_claude_json_dir()
73
+ claude_json = claude_dir / "claude.json" if claude_dir else None
74
+ claude_configured = (
75
+ claude_json is not None
76
+ and claude_json.exists()
77
+ and "codemesh" in json.loads(claude_json.read_text()).get("mcpServers", {})
78
+ )
79
+ agents.append(AgentInfo(
80
+ name="claude",
81
+ display="Claude Code",
82
+ detected=(claude_dir is not None and claude_dir.exists()),
83
+ configured=claude_configured,
84
+ scope="global",
85
+ detail=str(claude_json) if claude_json else "",
86
+ ))
87
+
88
+ # ── Cursor ──────────────────────────────────────────────────────────────
89
+ cursor_mcp = root / ".cursor" / "mcp.json"
90
+ cursor_configured = (
91
+ cursor_mcp.exists()
92
+ and "codemesh" in json.loads(cursor_mcp.read_text()).get("mcpServers", {})
93
+ )
94
+ agents.append(AgentInfo(
95
+ name="cursor",
96
+ display="Cursor",
97
+ detected=(root / ".cursor").exists(),
98
+ configured=cursor_configured,
99
+ scope="project",
100
+ detail=str(cursor_mcp),
101
+ ))
102
+
103
+ # ── Codex CLI ───────────────────────────────────────────────────────────
104
+ codex_dir = Path.home() / ".codex"
105
+ codex_config = codex_dir / "config.json"
106
+ codex_configured = (
107
+ codex_config.exists()
108
+ and "codemesh" in json.loads(codex_config.read_text()).get("mcpServers", {})
109
+ )
110
+ agents.append(AgentInfo(
111
+ name="codex",
112
+ display="Codex CLI",
113
+ detected=shutil.which("codex") is not None,
114
+ configured=codex_configured,
115
+ scope="global",
116
+ detail=str(codex_config),
117
+ ))
118
+
119
+ # ── Hermes Agent ────────────────────────────────────────────────────────
120
+ hermes_config = Path.home() / ".hermes" / "config.yaml"
121
+ hermes_configured = False
122
+ if hermes_config.exists():
123
+ try:
124
+ import yaml
125
+ hermes_data = yaml.safe_load(hermes_config.read_text()) or {}
126
+ mcp_servers = hermes_data.get("mcp_servers", {})
127
+ hermes_configured = "codemesh" in mcp_servers
128
+ except Exception:
129
+ pass
130
+ agents.append(AgentInfo(
131
+ name="hermes",
132
+ display="Hermes Agent",
133
+ detected=hermes_config.exists() or shutil.which("hermes") is not None,
134
+ configured=hermes_configured,
135
+ scope="global",
136
+ detail=str(hermes_config),
137
+ ))
138
+
139
+ return agents
140
+
141
+
142
+ # ── Interactive agent selection ──────────────────────────────────────────────
143
+
144
+ def select_agents_interactive(
145
+ agents: list[AgentInfo],
146
+ mode: str = "install", # "install" or "uninstall"
147
+ ) -> list[str]:
148
+ """Present an interactive checklist and return the selected agent names.
149
+
150
+ Uses a simple numbered input — works in any terminal without requiring
151
+ an interactive TUI library.
152
+
153
+ For install: pre-selects detected agents that are NOT yet configured.
154
+ For uninstall: pre-selects agents that ARE configured.
155
+ """
156
+ typer.echo("")
157
+ if mode == "install":
158
+ typer.echo(" Which agents should codemesh configure?")
159
+ else:
160
+ typer.echo(" Which agents should codemesh uninstall from?")
161
+ typer.echo("")
162
+
163
+ pre_selected: set[str] = set()
164
+ for i, a in enumerate(agents, 1):
165
+ if mode == "install" and a.detected and not a.configured:
166
+ pre_selected.add(a.name)
167
+
168
+ # Build annotation
169
+ annotations: list[str] = []
170
+ if a.configured:
171
+ annotations.append("already configured")
172
+ elif not a.detected:
173
+ annotations.append("not found")
174
+ if a.scope == "global" and a.detected:
175
+ annotations.append("global only")
176
+
177
+ ann_str = f" — {', '.join(annotations)}" if annotations else ""
178
+ marker = "◼" if a.name in pre_selected else "◻"
179
+
180
+ typer.echo(f" {i}. {marker} {a.display}{ann_str}")
181
+
182
+ typer.echo("")
183
+ typer.echo(" Enter numbers to toggle (e.g. 1,3,4), 'all', or 'none'.")
184
+ typer.echo(" Press Enter with no input to accept the pre-selection.")
185
+ typer.echo("")
186
+
187
+ selected: set[str] = set(pre_selected)
188
+
189
+ while True:
190
+ raw = typer.prompt(" Select", default="", show_default=False).strip()
191
+
192
+ if raw == "":
193
+ break
194
+ elif raw.lower() == "all":
195
+ selected = {a.name for a in agents if a.detected}
196
+ break
197
+ elif raw.lower() == "none":
198
+ selected = set()
199
+ break
200
+ else:
201
+ # Parse comma/space-separated numbers
202
+ parts = raw.replace(",", " ").split()
203
+ for p in parts:
204
+ try:
205
+ idx = int(p) - 1
206
+ if 0 <= idx < len(agents):
207
+ name = agents[idx].name
208
+ if name in selected:
209
+ selected.discard(name)
210
+ else:
211
+ selected.add(name)
212
+ else:
213
+ typer.echo(f" Ignoring out-of-range number: {p}")
214
+ except ValueError:
215
+ # Match by name
216
+ matched = False
217
+ for a in agents:
218
+ if a.name == p.lower() or a.display.lower() == p.lower():
219
+ if a.name in selected:
220
+ selected.discard(a.name)
221
+ else:
222
+ selected.add(a.name)
223
+ matched = True
224
+ break
225
+ if not matched:
226
+ typer.echo(f" Unknown agent: {p}")
227
+ break
228
+
229
+ if not selected:
230
+ typer.echo(" No agents selected.")
231
+ raise typer.Exit(1)
232
+
233
+ return list(selected)
234
+
235
+
236
+ # ── Install helpers ──────────────────────────────────────────────────────────
237
+
238
+ def _find_claude_json_dir() -> Path | None:
239
+ """Find the Claude Code configuration directory."""
240
+ candidates = [
241
+ Path.home() / ".claude",
242
+ Path.home() / ".config" / "claude",
243
+ ]
244
+ for c in candidates:
245
+ if c.exists():
246
+ return c
247
+ return None
248
+
249
+
250
+ def _merge_json_file(path: Path, new_data: dict) -> dict:
251
+ """Merge new data into an existing JSON file."""
252
+ existing: dict = {}
253
+ if path.exists():
254
+ try:
255
+ existing = json.loads(path.read_text())
256
+ except (json.JSONDecodeError, OSError):
257
+ existing = {}
258
+
259
+ if "mcpServers" in new_data:
260
+ existing.setdefault("mcpServers", {})
261
+ existing["mcpServers"].update(new_data["mcpServers"])
262
+
263
+ if "permissions" in new_data and "allow" in new_data["permissions"]:
264
+ existing.setdefault("permissions", {"allow": []})
265
+ perms_allow = existing["permissions"].get("allow", [])
266
+ for item in new_data["permissions"]["allow"]:
267
+ if item not in perms_allow:
268
+ perms_allow.append(item)
269
+ existing["permissions"]["allow"] = perms_allow
270
+
271
+ return existing
272
+
273
+
274
+ def install_claude(root: Path, global_config: bool = True) -> dict:
275
+ """Configure Claude Code to use CodeMesh MCP server."""
276
+ result = {"claude_json": None, "claude_settings": None}
277
+
278
+ if global_config:
279
+ claude_dir = _find_claude_json_dir()
280
+ if claude_dir is None:
281
+ claude_dir = Path.home() / ".claude"
282
+ claude_dir.mkdir(parents=True, exist_ok=True)
283
+ claude_json = claude_dir / "claude.json"
284
+ claude_settings = claude_dir / "settings.json"
285
+ else:
286
+ claude_json = root / ".claude.json"
287
+ claude_settings = root / ".claude_settings.json"
288
+
289
+ # Check if already configured
290
+ if claude_json.exists():
291
+ existing = json.loads(claude_json.read_text()) if claude_json.exists() else {}
292
+ if "codemesh" in existing.get("mcpServers", {}):
293
+ result["claude_json"] = str(claude_json) + " (already configured)"
294
+ return result
295
+
296
+ merged = _merge_json_file(claude_json, _CLAUDE_MCP_CONFIG)
297
+ claude_json.write_text(json.dumps(merged, indent=2))
298
+ result["claude_json"] = str(claude_json)
299
+
300
+ merged_settings = _merge_json_file(claude_settings, _CLAUDE_PERMISSIONS)
301
+ claude_settings.write_text(json.dumps(merged_settings, indent=2))
302
+ result["claude_settings"] = str(claude_settings)
303
+
304
+ return result
305
+
306
+
307
+ def install_cursor(root: Path) -> dict:
308
+ """Configure Cursor to use CodeMesh MCP server."""
309
+ result = {"cursor_mcp": None}
310
+
311
+ cursor_dir = root / ".cursor"
312
+ cursor_dir.mkdir(parents=True, exist_ok=True)
313
+ mcp_json = cursor_dir / "mcp.json"
314
+
315
+ config = {}
316
+ if mcp_json.exists():
317
+ try:
318
+ config = json.loads(mcp_json.read_text())
319
+ except (json.JSONDecodeError, OSError):
320
+ config = {}
321
+
322
+ config.setdefault("mcpServers", {})
323
+ if "codemesh" in config.get("mcpServers", {}):
324
+ result["cursor_mcp"] = str(mcp_json) + " (already configured)"
325
+ return result
326
+
327
+ config["mcpServers"]["codemesh"] = {
328
+ "type": "stdio",
329
+ "command": "codemesh",
330
+ "args": ["serve", "--transport", "stdio"],
331
+ }
332
+ mcp_json.write_text(json.dumps(config, indent=2))
333
+ result["cursor_mcp"] = str(mcp_json)
334
+
335
+ return result
336
+
337
+
338
+ def install_codex(root: Path) -> dict:
339
+ """Configure Codex CLI to use CodeMesh MCP server."""
340
+ result = {"codex_config": None}
341
+
342
+ codex_dir = Path.home() / ".codex"
343
+ codex_dir.mkdir(parents=True, exist_ok=True)
344
+ config_file = codex_dir / "config.json"
345
+
346
+ config: dict = {}
347
+ if config_file.exists():
348
+ try:
349
+ config = json.loads(config_file.read_text())
350
+ except (json.JSONDecodeError, OSError):
351
+ config = {}
352
+
353
+ config.setdefault("mcpServers", {})
354
+ if "codemesh" in config.get("mcpServers", {}):
355
+ result["codex_config"] = str(config_file) + " (already configured)"
356
+ return result
357
+
358
+ config["mcpServers"]["codemesh"] = {
359
+ "type": "stdio",
360
+ "command": "codemesh",
361
+ "args": ["serve", "--transport", "stdio"],
362
+ }
363
+ config_file.write_text(json.dumps(config, indent=2))
364
+ result["codex_config"] = str(config_file)
365
+
366
+ return result
367
+
368
+
369
+ def install_hermes(_root: Path) -> dict:
370
+ """Configure Hermes Agent to use CodeMesh MCP server.
371
+
372
+ Hermes uses ~/.hermes/config.yaml with an mcp_servers section.
373
+ Silently returns an empty result if PyYAML is not installed.
374
+ """
375
+ result: dict = {}
376
+
377
+ try:
378
+ import yaml # noqa: F401
379
+ except ImportError:
380
+ return result # PyYAML not installed — skip silently
381
+
382
+ hermes_config_path = Path.home() / ".hermes" / "config.yaml"
383
+ if not hermes_config_path.parent.exists():
384
+ hermes_config_path.parent.mkdir(parents=True, exist_ok=True)
385
+
386
+ config: dict = {}
387
+ if hermes_config_path.exists():
388
+ try:
389
+ config = yaml.safe_load(hermes_config_path.read_text()) or {}
390
+ except Exception:
391
+ config = {}
392
+
393
+ config.setdefault("mcp_servers", {})
394
+ if "codemesh" in config.get("mcp_servers", {}):
395
+ return result # already configured
396
+
397
+ config["mcp_servers"]["codemesh"] = {
398
+ "command": "codemesh",
399
+ "args": ["serve", "--transport", "stdio"],
400
+ "enabled": True,
401
+ }
402
+ hermes_config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
403
+ result["hermes_config"] = str(hermes_config_path)
404
+
405
+ return result
406
+
407
+
408
+ # ── Uninstall helpers ────────────────────────────────────────────────────────
409
+
410
+ def uninstall_claude(root: Path, global_config: bool = True) -> dict:
411
+ """Remove CodeMesh MCP server configuration from Claude Code."""
412
+ result = {"claude_json": None, "claude_settings": None}
413
+
414
+ if global_config:
415
+ claude_dir = _find_claude_json_dir()
416
+ if claude_dir is None:
417
+ return result
418
+ claude_json = claude_dir / "claude.json"
419
+ claude_settings = claude_dir / "settings.json"
420
+ else:
421
+ claude_json = root / ".claude.json"
422
+ claude_settings = root / ".claude_settings.json"
423
+
424
+ if claude_json.exists():
425
+ try:
426
+ data = json.loads(claude_json.read_text())
427
+ except (json.JSONDecodeError, OSError):
428
+ data = {}
429
+ if "codemesh" in data.get("mcpServers", {}):
430
+ del data["mcpServers"]["codemesh"]
431
+ if not data["mcpServers"]:
432
+ del data["mcpServers"]
433
+ claude_json.write_text(json.dumps(data, indent=2))
434
+ result["claude_json"] = str(claude_json)
435
+ else:
436
+ result["claude_json"] = "not configured"
437
+
438
+ if claude_settings.exists():
439
+ try:
440
+ settings = json.loads(claude_settings.read_text())
441
+ except (json.JSONDecodeError, OSError):
442
+ settings = {}
443
+ perms = settings.get("permissions", {}).get("allow", [])
444
+ codemesh_perms = [p for p in perms if p.startswith("mcp__codemesh__")]
445
+ if codemesh_perms:
446
+ settings.setdefault("permissions", {})["allow"] = [
447
+ p for p in perms if not p.startswith("mcp__codemesh__")
448
+ ]
449
+ claude_settings.write_text(json.dumps(settings, indent=2))
450
+ result["claude_settings"] = str(claude_settings)
451
+ else:
452
+ result["claude_settings"] = "not configured"
453
+
454
+ return result
455
+
456
+
457
+ def uninstall_cursor(root: Path) -> dict:
458
+ """Remove CodeMesh MCP server configuration from Cursor."""
459
+ result = {"cursor_mcp": None}
460
+
461
+ mcp_json = root / ".cursor" / "mcp.json"
462
+ if not mcp_json.exists():
463
+ result["cursor_mcp"] = "not configured"
464
+ return result
465
+
466
+ try:
467
+ config = json.loads(mcp_json.read_text())
468
+ except (json.JSONDecodeError, OSError):
469
+ return result
470
+
471
+ if "codemesh" in config.get("mcpServers", {}):
472
+ del config["mcpServers"]["codemesh"]
473
+ if not config["mcpServers"]:
474
+ del config["mcpServers"]
475
+ mcp_json.write_text(json.dumps(config, indent=2))
476
+ result["cursor_mcp"] = str(mcp_json)
477
+ else:
478
+ result["cursor_mcp"] = "not configured"
479
+
480
+ return result
481
+
482
+
483
+ def uninstall_codex(_root: Path) -> dict:
484
+ """Remove CodeMesh MCP server configuration from Codex CLI."""
485
+ result = {"codex_config": None}
486
+
487
+ codex_dir = Path.home() / ".codex"
488
+ config_file = codex_dir / "config.json"
489
+ if not config_file.exists():
490
+ result["codex_config"] = "not configured"
491
+ return result
492
+
493
+ try:
494
+ config = json.loads(config_file.read_text())
495
+ except (json.JSONDecodeError, OSError):
496
+ return result
497
+
498
+ if "codemesh" in config.get("mcpServers", {}):
499
+ del config["mcpServers"]["codemesh"]
500
+ if not config["mcpServers"]:
501
+ del config["mcpServers"]
502
+ config_file.write_text(json.dumps(config, indent=2))
503
+ result["codex_config"] = str(config_file)
504
+ else:
505
+ result["codex_config"] = "not configured"
506
+
507
+ return result
508
+
509
+
510
+ def uninstall_hermes(_root: Path) -> dict:
511
+ """Remove CodeMesh MCP server configuration from Hermes Agent.
512
+
513
+ Silently returns an empty result if PyYAML is not installed.
514
+ """
515
+ result: dict = {}
516
+
517
+ try:
518
+ import yaml # noqa: F401
519
+ except ImportError:
520
+ return result
521
+
522
+ hermes_config_path = Path.home() / ".hermes" / "config.yaml"
523
+ if not hermes_config_path.exists():
524
+ return result
525
+
526
+ try:
527
+ config = yaml.safe_load(hermes_config_path.read_text()) or {}
528
+ except Exception:
529
+ return result
530
+
531
+ if "codemesh" in config.get("mcp_servers", {}):
532
+ del config["mcp_servers"]["codemesh"]
533
+ if not config["mcp_servers"]:
534
+ del config["mcp_servers"]
535
+ hermes_config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
536
+ result["hermes_config"] = str(hermes_config_path)
537
+
538
+ return result
539
+
540
+
541
+ # ── Project artifact cleanup (surgical) ──────────────────────────────────────
542
+
543
+ def _remove_codemesh_section(content: str, heading: str = "## CodeMesh") -> tuple[str, bool]:
544
+ """Remove the CodeMesh section from a markdown file.
545
+
546
+ Returns (new_content, was_modified).
547
+ """
548
+ if heading not in content:
549
+ return content, False
550
+
551
+ parts = content.split(heading, 1)
552
+ before = parts[0]
553
+ after = parts[1] if len(parts) > 1 else ""
554
+
555
+ next_section_idx = -1
556
+ if "\n## " in after:
557
+ next_section_idx = after.index("\n## ")
558
+
559
+ if next_section_idx >= 0:
560
+ after = after[next_section_idx:]
561
+ new_content = (before.rstrip("\n") + "\n\n" + after.lstrip("\n")).strip("\n") + "\n"
562
+ if not new_content.strip():
563
+ return "", True
564
+ return new_content, True
565
+ else:
566
+ if before.strip():
567
+ return before.rstrip("\n") + "\n", True
568
+ else:
569
+ return "", True
570
+
571
+
572
+ def clean_project(root: Path, force: bool = False) -> dict:
573
+ """Remove CodeMesh project artifacts (.codemesh/, CLAUDE.md, AGENTS.md, .cursor/rules/).
574
+
575
+ Uses surgical removal for shared files (CLAUDE.md, AGENTS.md):
576
+ - If the file is EXACTLY our template, it's deleted
577
+ - If the file contains CodeMesh section mixed with user content, only the
578
+ CodeMesh section is extracted and the rest is preserved
579
+ - If the file doesn't contain CodeMesh content, it's left untouched
580
+
581
+ Returns a dict with paths removed or modified.
582
+ """
583
+ import shutil as _shutil
584
+
585
+ removed: list[str] = []
586
+ modified: list[str] = []
587
+
588
+ # --- .codemesh/ directory: always safe to remove entirely ---
589
+ codemesh_dir = root / ".codemesh"
590
+ if codemesh_dir.exists():
591
+ _shutil.rmtree(codemesh_dir)
592
+ removed.append(str(codemesh_dir))
593
+
594
+ # --- CLAUDE.md: surgical removal ---
595
+ claude_md = root / "CLAUDE.md"
596
+ if claude_md.exists():
597
+ content = claude_md.read_text()
598
+ if _CLAUDE_MD_TEMPLATE.strip() == content.strip():
599
+ claude_md.unlink()
600
+ removed.append(str(claude_md))
601
+ elif "## CodeMesh" in content:
602
+ new_content, changed = _remove_codemesh_section(content)
603
+ if changed:
604
+ if new_content.strip():
605
+ claude_md.write_text(new_content)
606
+ modified.append(str(claude_md))
607
+ else:
608
+ claude_md.unlink()
609
+ removed.append(str(claude_md))
610
+
611
+ # --- AGENTS.md: surgical removal ---
612
+ agents_md = root / "AGENTS.md"
613
+ if agents_md.exists():
614
+ content = agents_md.read_text()
615
+ if _CODEX_TEMPLATE.strip() == content.strip():
616
+ agents_md.unlink()
617
+ removed.append(str(agents_md))
618
+ elif "## CodeMesh" in content:
619
+ new_content, changed = _remove_codemesh_section(content)
620
+ if changed:
621
+ if new_content.strip():
622
+ agents_md.write_text(new_content)
623
+ modified.append(str(agents_md))
624
+ else:
625
+ agents_md.unlink()
626
+ removed.append(str(agents_md))
627
+
628
+ # --- .cursor/rules/codemesh.mdc: dedicated file, safe to delete ---
629
+ cursor_rules = root / ".cursor" / "rules" / "codemesh.mdc"
630
+ if cursor_rules.exists():
631
+ cursor_rules.unlink()
632
+ removed.append(str(cursor_rules))
633
+
634
+ return {"removed": removed, "modified": modified}