codevira 1.6.0__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 (58) hide show
  1. codevira-1.6.0.dist-info/LICENSE +21 -0
  2. codevira-1.6.0.dist-info/METADATA +477 -0
  3. codevira-1.6.0.dist-info/RECORD +58 -0
  4. codevira-1.6.0.dist-info/WHEEL +5 -0
  5. codevira-1.6.0.dist-info/entry_points.txt +2 -0
  6. codevira-1.6.0.dist-info/top_level.txt +2 -0
  7. indexer/__init__.py +1 -0
  8. indexer/chunker.py +428 -0
  9. indexer/global_db.py +197 -0
  10. indexer/graph_generator.py +380 -0
  11. indexer/index_codebase.py +588 -0
  12. indexer/outcome_tracker.py +172 -0
  13. indexer/rule_learner.py +186 -0
  14. indexer/sqlite_graph.py +640 -0
  15. indexer/treesitter_parser.py +423 -0
  16. mcp_server/__init__.py +1 -0
  17. mcp_server/__main__.py +20 -0
  18. mcp_server/auto_init.py +257 -0
  19. mcp_server/cli.py +622 -0
  20. mcp_server/crash_logger.py +236 -0
  21. mcp_server/data/__init__.py +1 -0
  22. mcp_server/data/agents/builder.md +84 -0
  23. mcp_server/data/agents/developer.md +111 -0
  24. mcp_server/data/agents/documenter.md +138 -0
  25. mcp_server/data/agents/orchestrator.md +96 -0
  26. mcp_server/data/agents/planner.md +106 -0
  27. mcp_server/data/agents/reviewer.md +82 -0
  28. mcp_server/data/agents/tester.md +83 -0
  29. mcp_server/data/config.example.yaml +33 -0
  30. mcp_server/data/rules/coding-standards.md +48 -0
  31. mcp_server/data/rules/engineering-excellence.md +28 -0
  32. mcp_server/data/rules/git-cicd-governance.md +32 -0
  33. mcp_server/data/rules/git_commits.md +130 -0
  34. mcp_server/data/rules/incremental-updates.md +5 -0
  35. mcp_server/data/rules/master_rule.md +187 -0
  36. mcp_server/data/rules/multi-language.md +19 -0
  37. mcp_server/data/rules/persistence.md +21 -0
  38. mcp_server/data/rules/resilience-observability.md +17 -0
  39. mcp_server/data/rules/smoke-testing.md +48 -0
  40. mcp_server/data/rules/testing-standards.md +23 -0
  41. mcp_server/detect.py +284 -0
  42. mcp_server/gitignore.py +284 -0
  43. mcp_server/global_sync.py +187 -0
  44. mcp_server/http_server.py +341 -0
  45. mcp_server/ide_inject.py +444 -0
  46. mcp_server/launchd.py +156 -0
  47. mcp_server/migrate.py +215 -0
  48. mcp_server/paths.py +256 -0
  49. mcp_server/prompts.py +136 -0
  50. mcp_server/server.py +1049 -0
  51. mcp_server/tools/__init__.py +0 -0
  52. mcp_server/tools/changesets.py +223 -0
  53. mcp_server/tools/code_reader.py +335 -0
  54. mcp_server/tools/graph.py +637 -0
  55. mcp_server/tools/learning.py +238 -0
  56. mcp_server/tools/playbook.py +89 -0
  57. mcp_server/tools/roadmap.py +599 -0
  58. mcp_server/tools/search.py +145 -0
@@ -0,0 +1,444 @@
1
+ """
2
+ ide_inject.py — Auto-detect installed AI tools and inject MCP configuration.
3
+
4
+ Detects Claude Code, Claude Desktop, Cursor, Windsurf, and Google Antigravity,
5
+ then writes the correct MCP server config to each tool's settings file.
6
+ Non-destructive merge: only touches the 'codevira' entry, preserves everything else.
7
+
8
+ v1.6 additions:
9
+ - Claude Desktop support (stdio-only, requires full binary path + --project-dir)
10
+ - Global mode: inject once with no project path, works for every project
11
+ - HTTP URL injection for Claude Code CLI
12
+ - Windows cross-platform fix for sysconfig path resolution
13
+ - Antigravity server name sanitization (handles special chars)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import re
20
+ import shutil
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # IDE detection
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def detect_installed_ides(project_root: Path) -> list[str]:
32
+ """Detect which AI coding tools are installed."""
33
+ found: list[str] = []
34
+
35
+ # Claude Code: per-project .claude/ or claude binary in PATH
36
+ if (project_root / ".claude").is_dir() or shutil.which("claude"):
37
+ found.append("claude")
38
+
39
+ # Claude Desktop: check for its config directory
40
+ desktop_cfg_dir = _claude_desktop_config_path().parent
41
+ if desktop_cfg_dir.exists():
42
+ found.append("claude_desktop")
43
+
44
+ # Cursor: global ~/.cursor/ or cursor binary
45
+ if (Path.home() / ".cursor").is_dir() or shutil.which("cursor"):
46
+ found.append("cursor")
47
+
48
+ # Windsurf: global ~/.windsurf/ or ~/.codeium/windsurf/
49
+ if (Path.home() / ".windsurf").is_dir() or (Path.home() / ".codeium" / "windsurf").is_dir():
50
+ found.append("windsurf")
51
+
52
+ # Google Antigravity: global ~/.gemini/
53
+ if (Path.home() / ".gemini").is_dir():
54
+ found.append("antigravity")
55
+
56
+ return found
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Config file paths
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def _claude_config_path(project_root: Path) -> Path:
64
+ return project_root / ".claude" / "settings.json"
65
+
66
+ def _claude_global_config_path() -> Path:
67
+ return Path.home() / ".claude" / "settings.json"
68
+
69
+ def _claude_desktop_config_path() -> Path:
70
+ """Return the Claude Desktop config file path (platform-aware)."""
71
+ if sys.platform == "darwin":
72
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
73
+ elif sys.platform == "win32":
74
+ appdata = Path(sys.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
75
+ return appdata / "Claude" / "claude_desktop_config.json"
76
+ else:
77
+ # Linux / other
78
+ return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
79
+
80
+ def _cursor_config_path(project_root: Path) -> Path:
81
+ return project_root / ".cursor" / "mcp.json"
82
+
83
+ def _cursor_global_config_path() -> Path:
84
+ return Path.home() / ".cursor" / "mcp.json"
85
+
86
+ def _windsurf_config_path(project_root: Path) -> Path:
87
+ return project_root / ".windsurf" / "mcp.json"
88
+
89
+ def _windsurf_global_config_path() -> Path:
90
+ """Return global Windsurf MCP config path."""
91
+ if (Path.home() / ".codeium" / "windsurf").is_dir():
92
+ return Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
93
+ return Path.home() / ".windsurf" / "mcp_config.json"
94
+
95
+ def _antigravity_config_path() -> Path:
96
+ return Path.home() / ".gemini" / "settings" / "mcp_config.json"
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # JSON helpers
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def _read_json_safe(path: Path) -> dict:
104
+ """Read a JSON file, returning {} if missing or corrupt."""
105
+ if not path.exists():
106
+ return {}
107
+ try:
108
+ return json.loads(path.read_text(encoding="utf-8"))
109
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
110
+ logger.warning("Could not parse %s: %s (will create fresh)", path, e)
111
+ return {}
112
+
113
+
114
+ def _write_json_safe(path: Path, data: dict) -> None:
115
+ """Atomic write: write to .tmp then rename."""
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ tmp = path.with_suffix(".tmp")
118
+ tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
119
+ tmp.replace(path) # Atomic on POSIX, best-effort on Windows
120
+
121
+
122
+ def _merge_mcp_config(existing: dict, server_name: str, server_config: dict) -> dict:
123
+ """Non-destructive merge: only touch the server_name entry."""
124
+ result = json.loads(json.dumps(existing)) # deep copy
125
+ if "mcpServers" not in result:
126
+ result["mcpServers"] = {}
127
+ result["mcpServers"][server_name] = server_config
128
+ return result
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Resolve the best command to run codevira
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def _resolve_command() -> tuple[str, str]:
136
+ """
137
+ Returns (cmd_path, python_exe).
138
+ cmd_path is the absolute path to codevira binary.
139
+ python_exe is the Python interpreter that runs this process.
140
+
141
+ Search order for the binary:
142
+ 1. shutil.which (works when ~/.local/bin is in PATH)
143
+ 2. pipx default venv location ~/.local/pipx/venvs/codevira/bin/
144
+ 3. pip --user install location ~/Library/Python/X.Y/bin/ (macOS) or %APPDATA% (Windows)
145
+ 4. Same bin dir as current Python interpreter
146
+ 5. Fallback: run as `python -m mcp_server` using current interpreter
147
+ """
148
+ python_exe = sys.executable
149
+
150
+ # 1. Standard PATH lookup
151
+ exe = shutil.which("codevira")
152
+ if exe:
153
+ return exe, python_exe
154
+
155
+ # 2. pipx default venv
156
+ pipx_bin = Path.home() / ".local" / "pipx" / "venvs" / "codevira" / "bin" / "codevira"
157
+ if sys.platform == "win32":
158
+ pipx_bin = Path.home() / ".local" / "pipx" / "venvs" / "codevira" / "Scripts" / "codevira.exe"
159
+ if pipx_bin.exists():
160
+ return str(pipx_bin), python_exe
161
+
162
+ # 3. pip --user install location (cross-platform)
163
+ try:
164
+ import sysconfig
165
+ if sys.platform == "win32":
166
+ scripts_scheme = "nt_user"
167
+ else:
168
+ scripts_scheme = "posix_user"
169
+ user_scripts = sysconfig.get_path("scripts", scripts_scheme)
170
+ if user_scripts:
171
+ suffix = ".exe" if sys.platform == "win32" else ""
172
+ user_bin = Path(user_scripts) / f"codevira{suffix}"
173
+ if user_bin.exists():
174
+ return str(user_bin), python_exe
175
+ except Exception:
176
+ pass
177
+
178
+ # 4. Same bin dir as current Python
179
+ suffix = ".exe" if sys.platform == "win32" else ""
180
+ sibling_bin = Path(python_exe).parent / f"codevira{suffix}"
181
+ if sibling_bin.exists():
182
+ return str(sibling_bin), python_exe
183
+
184
+ # 5. Fallback: use current interpreter with -m flag (always works)
185
+ return python_exe, python_exe
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Per-IDE injection (per-project mode)
190
+ # ---------------------------------------------------------------------------
191
+
192
+ def _build_server_config(cmd_path: str, python_exe: str, project_root: Path, use_cwd: bool = True) -> dict:
193
+ """
194
+ Build the MCP server config dict for per-project mode.
195
+
196
+ If cmd_path is the Python interpreter (fallback), use `-m mcp_server --project-dir`.
197
+ If cmd_path is the codevira binary:
198
+ - use_cwd=True: {"command": ..., "args": [], "cwd": ...} (Claude / Cursor / Windsurf)
199
+ - use_cwd=False: {"command": ..., "args": ["--project-dir", ...]} (tools that ignore cwd)
200
+ """
201
+ is_python_fallback = (cmd_path == python_exe)
202
+
203
+ if is_python_fallback:
204
+ return {
205
+ "command": cmd_path,
206
+ "args": ["-m", "mcp_server", "--project-dir", str(project_root)],
207
+ }
208
+
209
+ if use_cwd:
210
+ return {
211
+ "command": cmd_path,
212
+ "args": [],
213
+ "cwd": str(project_root),
214
+ }
215
+ else:
216
+ return {
217
+ "command": cmd_path,
218
+ "args": ["--project-dir", str(project_root)],
219
+ }
220
+
221
+
222
+ def _build_global_server_config(cmd_path: str, python_exe: str) -> dict:
223
+ """
224
+ Build the MCP server config dict for global mode (v1.6).
225
+
226
+ Global mode: no project path — the server detects the project from cwd
227
+ when each AI tool opens a project. Works for every project automatically.
228
+ """
229
+ is_python_fallback = (cmd_path == python_exe)
230
+ if is_python_fallback:
231
+ return {"command": cmd_path, "args": ["-m", "mcp_server"]}
232
+ return {"command": cmd_path, "args": []}
233
+
234
+
235
+ def _inject_claude(project_root: Path, cmd_path: str, python_exe: str) -> str | None:
236
+ """Inject MCP config into Claude Code per-project settings."""
237
+ config_path = _claude_config_path(project_root)
238
+ existing = _read_json_safe(config_path)
239
+ server_config = _build_server_config(cmd_path, python_exe, project_root, use_cwd=True)
240
+ merged = _merge_mcp_config(existing, "codevira", server_config)
241
+ _write_json_safe(config_path, merged)
242
+ return str(config_path)
243
+
244
+
245
+ def _inject_claude_desktop(project_root: Path, cmd_path: str, python_exe: str) -> str | None:
246
+ """Inject MCP config into Claude Desktop (stdio-only, requires full binary path).
247
+
248
+ Claude Desktop:
249
+ - Does NOT support the 'url' format — only 'command' + 'args'
250
+ - Does NOT support 'cwd' — must use '--project-dir' arg
251
+ - Requires the FULL absolute binary path (not just 'codevira')
252
+ """
253
+ config_path = _claude_desktop_config_path()
254
+ existing = _read_json_safe(config_path)
255
+
256
+ # Always use --project-dir for Claude Desktop (no cwd support)
257
+ server_config = _build_server_config(cmd_path, python_exe, project_root, use_cwd=False)
258
+
259
+ merged = _merge_mcp_config(existing, "codevira", server_config)
260
+ _write_json_safe(config_path, merged)
261
+ return str(config_path)
262
+
263
+
264
+ def _inject_cursor(project_root: Path, cmd_path: str, python_exe: str) -> str | None:
265
+ """Inject MCP config into Cursor per-project settings."""
266
+ config_path = _cursor_config_path(project_root)
267
+ existing = _read_json_safe(config_path)
268
+ server_config = _build_server_config(cmd_path, python_exe, project_root, use_cwd=True)
269
+ merged = _merge_mcp_config(existing, "codevira", server_config)
270
+ _write_json_safe(config_path, merged)
271
+ return str(config_path)
272
+
273
+
274
+ def _inject_windsurf(project_root: Path, cmd_path: str, python_exe: str) -> str | None:
275
+ """Inject MCP config into Windsurf per-project settings."""
276
+ config_path = _windsurf_config_path(project_root)
277
+ existing = _read_json_safe(config_path)
278
+ server_config = _build_server_config(cmd_path, python_exe, project_root, use_cwd=True)
279
+ merged = _merge_mcp_config(existing, "codevira", server_config)
280
+ _write_json_safe(config_path, merged)
281
+ return str(config_path)
282
+
283
+
284
+ def _inject_antigravity(project_root: Path, cmd_path: str, python_exe: str, project_name: str) -> str | None:
285
+ """Inject MCP config into Google Antigravity settings (global file, unique server name per project).
286
+
287
+ Antigravity does not support 'cwd', so always use --project-dir args.
288
+ """
289
+ config_path = _antigravity_config_path()
290
+ existing = _read_json_safe(config_path)
291
+
292
+ # Sanitize project name: lowercase, replace anything non-alphanumeric with hyphens
293
+ safe_name = re.sub(r"[^a-z0-9-]", "-", project_name.lower())
294
+ safe_name = re.sub(r"-{2,}", "-", safe_name).strip("-")
295
+ server_name = f"codevira-{safe_name}"
296
+
297
+ base_config = _build_server_config(cmd_path, python_exe, project_root, use_cwd=False)
298
+ server_config = {"$typeName": "exa.cascade_plugins_pb.CascadePluginCommandTemplate", **base_config}
299
+
300
+ merged = _merge_mcp_config(existing, server_name, server_config)
301
+ _write_json_safe(config_path, merged)
302
+ return str(config_path)
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Global mode injection (v1.6) — one-time, no project path
307
+ # ---------------------------------------------------------------------------
308
+
309
+ def inject_global_claude_code(cmd_path: str, python_exe: str) -> str | None:
310
+ """Inject global codevira config into Claude Code (~/.claude/settings.json).
311
+
312
+ Global mode: no cwd, no --project-dir. The server auto-detects the project
313
+ from cwd when Claude Code opens each project directory.
314
+ """
315
+ config_path = _claude_global_config_path()
316
+ existing = _read_json_safe(config_path)
317
+ server_config = _build_global_server_config(cmd_path, python_exe)
318
+ merged = _merge_mcp_config(existing, "codevira", server_config)
319
+ _write_json_safe(config_path, merged)
320
+ return str(config_path)
321
+
322
+
323
+ def inject_global_cursor(cmd_path: str, python_exe: str) -> str | None:
324
+ """Inject global codevira config into Cursor (~/.cursor/mcp.json)."""
325
+ config_path = _cursor_global_config_path()
326
+ existing = _read_json_safe(config_path)
327
+ server_config = _build_global_server_config(cmd_path, python_exe)
328
+ merged = _merge_mcp_config(existing, "codevira", server_config)
329
+ _write_json_safe(config_path, merged)
330
+ return str(config_path)
331
+
332
+
333
+ def inject_global_windsurf(cmd_path: str, python_exe: str) -> str | None:
334
+ """Inject global codevira config into Windsurf."""
335
+ config_path = _windsurf_global_config_path()
336
+ existing = _read_json_safe(config_path)
337
+ server_config = _build_global_server_config(cmd_path, python_exe)
338
+ merged = _merge_mcp_config(existing, "codevira", server_config)
339
+ _write_json_safe(config_path, merged)
340
+ return str(config_path)
341
+
342
+
343
+ def inject_claude_http_url(url: str) -> str | None:
344
+ """Inject HTTP URL config into Claude Code global settings.
345
+
346
+ Only for Claude Code CLI — Cursor/Windsurf do not support URL format.
347
+ Claude Desktop does not support URL format either (stdio only).
348
+
349
+ Args:
350
+ url: Full MCP URL e.g. 'https://localhost:7443/mcp'
351
+ """
352
+ config_path = _claude_global_config_path()
353
+ existing = _read_json_safe(config_path)
354
+ server_config = {"url": url}
355
+ merged = _merge_mcp_config(existing, "codevira", server_config)
356
+ _write_json_safe(config_path, merged)
357
+ return str(config_path)
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Main orchestrators
362
+ # ---------------------------------------------------------------------------
363
+
364
+ def inject_ide_config(
365
+ project_root: Path,
366
+ project_name: str = "",
367
+ global_mode: bool = False,
368
+ ) -> dict[str, str]:
369
+ """
370
+ Detect installed AI tools and auto-inject MCP configuration.
371
+
372
+ Args:
373
+ project_root: Project directory (used in per-project mode).
374
+ project_name: Display name for the project.
375
+ global_mode: If True, inject global config (no project path) instead of
376
+ per-project config. Use for 'codevira register'.
377
+
378
+ Returns:
379
+ Dict of {ide_name: config_path_written} for each configured tool.
380
+ """
381
+ project_root = project_root.resolve()
382
+ if not project_name:
383
+ project_name = project_root.name
384
+
385
+ cmd_path, python_exe = _resolve_command()
386
+ ides = detect_installed_ides(project_root)
387
+ results: dict[str, str] = {}
388
+
389
+ for ide in ides:
390
+ try:
391
+ if global_mode:
392
+ # Global mode: register once, works for every project
393
+ if ide == "claude":
394
+ path = inject_global_claude_code(cmd_path, python_exe)
395
+ if path:
396
+ results["Claude Code (global)"] = path
397
+ elif ide == "cursor":
398
+ path = inject_global_cursor(cmd_path, python_exe)
399
+ if path:
400
+ results["Cursor (global)"] = path
401
+ elif ide == "windsurf":
402
+ path = inject_global_windsurf(cmd_path, python_exe)
403
+ if path:
404
+ results["Windsurf (global)"] = path
405
+ elif ide == "claude_desktop":
406
+ # Claude Desktop always needs --project-dir (no cwd + no url support)
407
+ # In global mode, skip Claude Desktop (it can't do project-agnostic config)
408
+ pass
409
+ elif ide == "antigravity":
410
+ # Antigravity always uses per-project keys; skip in global mode
411
+ pass
412
+ else:
413
+ # Per-project mode (existing behavior)
414
+ if ide == "claude":
415
+ path = _inject_claude(project_root, cmd_path, python_exe)
416
+ if path:
417
+ results["Claude Code"] = path
418
+ elif ide == "claude_desktop":
419
+ path = _inject_claude_desktop(project_root, cmd_path, python_exe)
420
+ if path:
421
+ results["Claude Desktop"] = path
422
+ elif ide == "cursor":
423
+ path = _inject_cursor(project_root, cmd_path, python_exe)
424
+ if path:
425
+ results["Cursor"] = path
426
+ elif ide == "windsurf":
427
+ path = _inject_windsurf(project_root, cmd_path, python_exe)
428
+ if path:
429
+ results["Windsurf"] = path
430
+ elif ide == "antigravity":
431
+ path = _inject_antigravity(project_root, cmd_path, python_exe, project_name)
432
+ if path:
433
+ results["Antigravity"] = path
434
+
435
+ except Exception as e:
436
+ logger.warning("Failed to inject %s config: %s", ide, e)
437
+ try:
438
+ from mcp_server.crash_logger import log_crash
439
+ log_crash(e, context=f"IDE config inject: {ide}",
440
+ project_path=str(project_root))
441
+ except Exception:
442
+ pass
443
+
444
+ return results
mcp_server/launchd.py ADDED
@@ -0,0 +1,156 @@
1
+ """
2
+ launchd.py — macOS launchd service management for Codevira MCP HTTP server.
3
+
4
+ Generates a launchd plist so that `codevira serve` starts automatically
5
+ on login and stays running as a background service.
6
+
7
+ Usage (via CLI):
8
+ codevira serve --install-service # install + load
9
+ codevira serve --uninstall-service # unload + remove
10
+
11
+ This is macOS-only. Windows and Linux service support is planned for v2.0.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import plistlib
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _PLIST_LABEL = "com.codevira.mcp-serve"
24
+ _PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{_PLIST_LABEL}.plist"
25
+
26
+
27
+ def install_launchd(
28
+ port: int = 7007,
29
+ use_https: bool = False,
30
+ host: str = "127.0.0.1",
31
+ project_dir: Path | None = None,
32
+ ) -> Path:
33
+ """Generate and load a launchd plist for the Codevira MCP HTTP server.
34
+
35
+ The plist starts `codevira serve` on login with the given options.
36
+ Logs go to ~/Library/Logs/codevira.log.
37
+
38
+ Args:
39
+ port: TCP port for the server (default: 7007).
40
+ use_https: If True, adds --https flag (requires mkcert CA to be trusted).
41
+ host: Bind address (default: 127.0.0.1).
42
+ project_dir: If provided, adds --project-dir to ProgramArguments and
43
+ sets WorkingDirectory in the plist so the server resolves
44
+ the correct project root.
45
+
46
+ Returns:
47
+ Path to the installed plist file.
48
+
49
+ Raises:
50
+ RuntimeError on non-macOS platforms or if launchctl fails.
51
+ """
52
+ if sys.platform != "darwin":
53
+ raise RuntimeError("launchd auto-start is only supported on macOS.")
54
+
55
+ from mcp_server.ide_inject import _resolve_command
56
+ cmd_path, _ = _resolve_command()
57
+
58
+ args = [cmd_path, "serve", "--host", host, "--port", str(port)]
59
+ if project_dir is not None:
60
+ args.extend(["--project-dir", str(project_dir)])
61
+ if use_https:
62
+ args.append("--https")
63
+
64
+ log_dir = Path.home() / "Library" / "Logs"
65
+ log_dir.mkdir(parents=True, exist_ok=True)
66
+ log_path = str(log_dir / "codevira.log")
67
+
68
+ # Build plist via plistlib (safe from XML injection — values are properly
69
+ # escaped regardless of content in host, port, or cmd_path).
70
+ plist_data = {
71
+ "Label": _PLIST_LABEL,
72
+ "ProgramArguments": args,
73
+ "RunAtLoad": True,
74
+ "KeepAlive": True,
75
+ "StandardOutPath": log_path,
76
+ "StandardErrorPath": log_path,
77
+ }
78
+ if project_dir is not None:
79
+ plist_data["WorkingDirectory"] = str(project_dir)
80
+
81
+ _PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
82
+
83
+ # Unload existing service if present (ignore errors)
84
+ subprocess.run(
85
+ ["launchctl", "unload", str(_PLIST_PATH)],
86
+ capture_output=True,
87
+ timeout=10,
88
+ )
89
+
90
+ with open(_PLIST_PATH, "wb") as f:
91
+ plistlib.dump(plist_data, f)
92
+ logger.info("Wrote launchd plist: %s", _PLIST_PATH)
93
+
94
+ # Load the new service
95
+ result = subprocess.run(
96
+ ["launchctl", "load", str(_PLIST_PATH)],
97
+ capture_output=True,
98
+ text=True,
99
+ timeout=10,
100
+ )
101
+ if result.returncode != 0:
102
+ raise RuntimeError(
103
+ f"launchctl load failed:\n{result.stderr or result.stdout}"
104
+ )
105
+
106
+ logger.info("Launchd service loaded: %s", _PLIST_LABEL)
107
+ return _PLIST_PATH
108
+
109
+
110
+ def uninstall_launchd() -> bool:
111
+ """Unload and remove the Codevira launchd plist.
112
+
113
+ Returns:
114
+ True if the service was removed, False if it wasn't installed.
115
+
116
+ Raises:
117
+ RuntimeError on non-macOS platforms.
118
+ """
119
+ if sys.platform != "darwin":
120
+ raise RuntimeError("launchd management is only supported on macOS.")
121
+
122
+ if not _PLIST_PATH.exists():
123
+ return False
124
+
125
+ subprocess.run(
126
+ ["launchctl", "unload", str(_PLIST_PATH)],
127
+ capture_output=True,
128
+ timeout=10,
129
+ )
130
+ _PLIST_PATH.unlink(missing_ok=True)
131
+ logger.info("Launchd service removed: %s", _PLIST_LABEL)
132
+ return True
133
+
134
+
135
+ def launchd_status() -> dict:
136
+ """Return the current status of the launchd service."""
137
+ if sys.platform != "darwin":
138
+ return {"platform": "not_macos", "installed": False}
139
+
140
+ installed = _PLIST_PATH.exists()
141
+ running = False
142
+ if installed:
143
+ result = subprocess.run(
144
+ ["launchctl", "list", _PLIST_LABEL],
145
+ capture_output=True,
146
+ text=True,
147
+ timeout=5,
148
+ )
149
+ running = result.returncode == 0
150
+
151
+ return {
152
+ "installed": installed,
153
+ "running": running,
154
+ "plist_path": str(_PLIST_PATH) if installed else None,
155
+ "label": _PLIST_LABEL,
156
+ }