@trac3er/oh-my-god 2.0.0 → 2.0.2

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 (243) hide show
  1. package/.claude-plugin/marketplace.json +8 -8
  2. package/.claude-plugin/plugin.json +5 -4
  3. package/.claude-plugin/scripts/uninstall.sh +74 -3
  4. package/.claude-plugin/scripts/update.sh +78 -3
  5. package/.coveragerc +26 -0
  6. package/.mcp.json +4 -4
  7. package/CHANGELOG.md +14 -0
  8. package/CODE_OF_CONDUCT.md +27 -0
  9. package/CONTRIBUTING.md +62 -0
  10. package/OMG-setup.sh +1201 -355
  11. package/README.md +77 -56
  12. package/SECURITY.md +25 -0
  13. package/agents/__init__.py +1 -0
  14. package/agents/model_roles.py +196 -0
  15. package/agents/omg-architect-mode.md +3 -5
  16. package/agents/omg-backend-engineer.md +3 -5
  17. package/agents/omg-database-engineer.md +3 -5
  18. package/agents/omg-frontend-designer.md +4 -5
  19. package/agents/omg-implement-mode.md +4 -5
  20. package/agents/omg-infra-engineer.md +3 -5
  21. package/agents/omg-research-mode.md +4 -6
  22. package/agents/omg-security-auditor.md +3 -5
  23. package/agents/omg-testing-engineer.md +3 -5
  24. package/build/lib/yaml.py +321 -0
  25. package/commands/OMG:ai-commit.md +101 -14
  26. package/commands/OMG:arch.md +302 -19
  27. package/commands/OMG:ccg.md +12 -7
  28. package/commands/OMG:compat.md +25 -17
  29. package/commands/OMG:cost.md +173 -13
  30. package/commands/OMG:crazy.md +1 -1
  31. package/commands/OMG:create-agent.md +170 -20
  32. package/commands/OMG:deps.md +235 -17
  33. package/commands/OMG:domain-init.md +1 -1
  34. package/commands/OMG:escalate.md +41 -12
  35. package/commands/OMG:health-check.md +37 -13
  36. package/commands/OMG:init.md +122 -14
  37. package/commands/OMG:project-init.md +1 -1
  38. package/commands/OMG:session-branch.md +76 -9
  39. package/commands/OMG:session-fork.md +42 -5
  40. package/commands/OMG:session-merge.md +124 -8
  41. package/commands/OMG:setup.md +69 -12
  42. package/commands/OMG:stats.md +215 -14
  43. package/commands/OMG:teams.md +19 -10
  44. package/config/lsp_languages.yaml +8 -0
  45. package/hooks/__init__.py +0 -0
  46. package/hooks/_agent_registry.py +423 -0
  47. package/hooks/_analytics.py +291 -0
  48. package/hooks/_budget.py +31 -0
  49. package/hooks/_common.py +569 -0
  50. package/hooks/_compression_optimizer.py +119 -0
  51. package/hooks/_cost_ledger.py +176 -0
  52. package/hooks/_learnings.py +126 -0
  53. package/hooks/_memory.py +103 -0
  54. package/hooks/_protected_context.py +150 -0
  55. package/hooks/_token_counter.py +221 -0
  56. package/hooks/branch_manager.py +236 -0
  57. package/hooks/budget_governor.py +232 -0
  58. package/hooks/circuit-breaker.py +270 -0
  59. package/hooks/compression_feedback.py +254 -0
  60. package/hooks/config-guard.py +216 -0
  61. package/hooks/context_pressure.py +53 -0
  62. package/hooks/credential_store.py +1020 -0
  63. package/hooks/fetch-rate-limits.py +212 -0
  64. package/hooks/firewall.py +48 -0
  65. package/hooks/hashline-formatter-bridge.py +224 -0
  66. package/hooks/hashline-injector.py +273 -0
  67. package/hooks/hashline-validator.py +216 -0
  68. package/hooks/idle-detector.py +95 -0
  69. package/hooks/intentgate-keyword-detector.py +188 -0
  70. package/hooks/magic-keyword-router.py +195 -0
  71. package/hooks/policy_engine.py +505 -0
  72. package/hooks/post-tool-failure.py +19 -0
  73. package/hooks/post-write.py +219 -0
  74. package/hooks/post_write.py +46 -0
  75. package/hooks/pre-compact.py +398 -0
  76. package/hooks/pre-tool-inject.py +98 -0
  77. package/hooks/prompt-enhancer.py +672 -0
  78. package/hooks/quality-runner.py +191 -0
  79. package/hooks/query.py +512 -0
  80. package/hooks/secret-guard.py +61 -0
  81. package/hooks/secret_audit.py +144 -0
  82. package/hooks/session-end-capture.py +137 -0
  83. package/hooks/session-start.py +277 -0
  84. package/hooks/setup_wizard.py +582 -0
  85. package/hooks/shadow_manager.py +297 -0
  86. package/hooks/state_migration.py +225 -0
  87. package/hooks/stop-gate.py +7 -0
  88. package/hooks/stop_dispatcher.py +945 -0
  89. package/hooks/test-validator.py +361 -0
  90. package/hooks/test_generator_hook.py +123 -0
  91. package/hooks/todo-state-tracker.py +114 -0
  92. package/hooks/tool-ledger.py +149 -0
  93. package/hooks/trust_review.py +585 -0
  94. package/hud/omg-hud.mjs +31 -1
  95. package/lab/__init__.py +1 -0
  96. package/lab/pipeline.py +75 -0
  97. package/lab/policies.py +52 -0
  98. package/package.json +7 -18
  99. package/plugins/README.md +33 -61
  100. package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
  101. package/plugins/advanced/commands/OMG:learn.md +1 -1
  102. package/plugins/advanced/commands/OMG:security-review.md +3 -3
  103. package/plugins/advanced/commands/OMG:ship.md +1 -1
  104. package/plugins/advanced/plugin.json +1 -1
  105. package/plugins/core/plugin.json +8 -3
  106. package/plugins/dephealth/__init__.py +0 -0
  107. package/plugins/dephealth/cve_scanner.py +188 -0
  108. package/plugins/dephealth/license_checker.py +135 -0
  109. package/plugins/dephealth/manifest_detector.py +423 -0
  110. package/plugins/dephealth/vuln_analyzer.py +169 -0
  111. package/plugins/testgen/__init__.py +0 -0
  112. package/plugins/testgen/codamosa_engine.py +402 -0
  113. package/plugins/testgen/edge_case_synthesizer.py +184 -0
  114. package/plugins/testgen/framework_detector.py +271 -0
  115. package/plugins/testgen/skeleton_generator.py +219 -0
  116. package/plugins/viz/__init__.py +0 -0
  117. package/plugins/viz/ast_parser.py +139 -0
  118. package/plugins/viz/diagram_generator.py +192 -0
  119. package/plugins/viz/graph_builder.py +444 -0
  120. package/plugins/viz/native_parsers.py +259 -0
  121. package/plugins/viz/regex_parser.py +112 -0
  122. package/pyproject.toml +81 -0
  123. package/rules/contextual/write-verify.md +2 -2
  124. package/rules/core/00-truth.md +1 -1
  125. package/rules/core/01-surgical.md +1 -1
  126. package/rules/core/02-circuit-breaker.md +2 -2
  127. package/rules/core/03-ensemble.md +3 -3
  128. package/rules/core/04-testing.md +3 -3
  129. package/runtime/__init__.py +32 -0
  130. package/runtime/adapters/__init__.py +13 -0
  131. package/runtime/adapters/claude.py +60 -0
  132. package/runtime/adapters/gpt.py +53 -0
  133. package/runtime/adapters/local.py +53 -0
  134. package/runtime/adoption.py +212 -0
  135. package/runtime/business_workflow.py +220 -0
  136. package/runtime/cli_provider.py +85 -0
  137. package/runtime/compat.py +1299 -0
  138. package/runtime/custom_agent_loader.py +366 -0
  139. package/runtime/dispatcher.py +47 -0
  140. package/runtime/ecosystem.py +371 -0
  141. package/runtime/legacy_compat.py +7 -0
  142. package/runtime/mcp_config_writers.py +115 -0
  143. package/runtime/mcp_lifecycle.py +153 -0
  144. package/runtime/mcp_memory_server.py +135 -0
  145. package/runtime/memory_parsers/__init__.py +0 -0
  146. package/runtime/memory_parsers/chatgpt_parser.py +257 -0
  147. package/runtime/memory_parsers/claude_import.py +107 -0
  148. package/runtime/memory_parsers/export.py +97 -0
  149. package/runtime/memory_parsers/gemini_import.py +91 -0
  150. package/runtime/memory_parsers/kimi_import.py +91 -0
  151. package/runtime/memory_store.py +215 -0
  152. package/runtime/omc_compat.py +7 -0
  153. package/runtime/providers/__init__.py +0 -0
  154. package/runtime/providers/codex_provider.py +112 -0
  155. package/runtime/providers/gemini_provider.py +128 -0
  156. package/runtime/providers/kimi_provider.py +151 -0
  157. package/runtime/providers/opencode_provider.py +144 -0
  158. package/runtime/subagent_dispatcher.py +362 -0
  159. package/runtime/team_router.py +1167 -0
  160. package/runtime/tmux_session_manager.py +169 -0
  161. package/scripts/check-omg-compat-contract-snapshot.py +137 -0
  162. package/scripts/check-omg-contract-snapshot.py +12 -0
  163. package/scripts/check-omg-public-ready.py +193 -0
  164. package/scripts/check-omg-standalone-clean.py +103 -0
  165. package/scripts/legacy_to_omg_migrate.py +29 -0
  166. package/scripts/migrate-legacy.py +464 -0
  167. package/scripts/omc_to_omg_migrate.py +12 -0
  168. package/scripts/omg.py +492 -0
  169. package/scripts/settings-merge.py +283 -0
  170. package/scripts/verify-standalone.sh +8 -4
  171. package/settings.json +126 -29
  172. package/templates/profile.yaml +1 -1
  173. package/tools/__init__.py +2 -0
  174. package/tools/browser_consent.py +289 -0
  175. package/tools/browser_stealth.py +481 -0
  176. package/tools/browser_tool.py +448 -0
  177. package/tools/changelog_generator.py +347 -0
  178. package/tools/commit_splitter.py +746 -0
  179. package/tools/config_discovery.py +151 -0
  180. package/tools/config_merger.py +449 -0
  181. package/tools/dashboard_generator.py +300 -0
  182. package/tools/git_inspector.py +298 -0
  183. package/tools/lsp_client.py +275 -0
  184. package/tools/lsp_discovery.py +231 -0
  185. package/tools/lsp_operations.py +392 -0
  186. package/tools/pr_generator.py +404 -0
  187. package/tools/python_repl.py +656 -0
  188. package/tools/python_sandbox.py +609 -0
  189. package/tools/search_providers/__init__.py +77 -0
  190. package/tools/search_providers/brave.py +115 -0
  191. package/tools/search_providers/exa.py +116 -0
  192. package/tools/search_providers/jina.py +104 -0
  193. package/tools/search_providers/perplexity.py +139 -0
  194. package/tools/search_providers/synthetic.py +74 -0
  195. package/tools/session_snapshot.py +736 -0
  196. package/tools/ssh_manager.py +912 -0
  197. package/tools/theme_engine.py +294 -0
  198. package/tools/theme_selector.py +137 -0
  199. package/tools/web_search.py +622 -0
  200. package/yaml.py +321 -0
  201. package/.claude-plugin/scripts/install.sh +0 -9
  202. package/bun.lock +0 -23
  203. package/bunfig.toml +0 -3
  204. package/hooks/_budget.ts +0 -1
  205. package/hooks/_common.ts +0 -63
  206. package/hooks/circuit-breaker.ts +0 -101
  207. package/hooks/config-guard.ts +0 -4
  208. package/hooks/firewall.ts +0 -20
  209. package/hooks/policy_engine.ts +0 -156
  210. package/hooks/post-tool-failure.ts +0 -22
  211. package/hooks/post-write.ts +0 -4
  212. package/hooks/pre-tool-inject.ts +0 -4
  213. package/hooks/prompt-enhancer.ts +0 -46
  214. package/hooks/quality-runner.ts +0 -24
  215. package/hooks/secret-guard.ts +0 -4
  216. package/hooks/session-end-capture.ts +0 -19
  217. package/hooks/session-start.ts +0 -19
  218. package/hooks/shadow_manager.ts +0 -81
  219. package/hooks/stop-gate.ts +0 -22
  220. package/hooks/stop_dispatcher.ts +0 -147
  221. package/hooks/test-generator-hook.ts +0 -4
  222. package/hooks/tool-ledger.ts +0 -27
  223. package/hooks/trust_review.ts +0 -175
  224. package/lab/pipeline.ts +0 -75
  225. package/lab/policies.ts +0 -68
  226. package/runtime/common.ts +0 -111
  227. package/runtime/compat.ts +0 -174
  228. package/runtime/dispatcher.ts +0 -25
  229. package/runtime/ecosystem.ts +0 -186
  230. package/runtime/provider_bootstrap.ts +0 -99
  231. package/runtime/provider_smoke.ts +0 -34
  232. package/runtime/release_readiness.ts +0 -186
  233. package/runtime/team_router.ts +0 -144
  234. package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
  235. package/scripts/check-omg-standalone-clean.ts +0 -12
  236. package/scripts/check-runtime-clean.ts +0 -94
  237. package/scripts/omg.ts +0 -352
  238. package/scripts/settings-merge.ts +0 -93
  239. package/tools/commit_splitter.ts +0 -23
  240. package/tools/git_inspector.ts +0 -18
  241. package/tools/session_snapshot.ts +0 -47
  242. package/trac3er-oh-my-god-2.0.0.tgz +0 -0
  243. package/tsconfig.json +0 -15
@@ -0,0 +1,275 @@
1
+ """LSP client wrapper supporting stdio transport.
2
+
3
+ Implements JSON-RPC 2.0 over stdio with Content-Length framing
4
+ per the Language Server Protocol specification.
5
+
6
+ Pure stdlib: subprocess, json, threading — no external dependencies.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import subprocess
14
+ import threading
15
+ from typing import Any
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _CONTENT_LENGTH_HEADER = "Content-Length: "
20
+ _HEADER_SEPARATOR = "\r\n\r\n"
21
+ _DEFAULT_TIMEOUT = 10.0
22
+
23
+
24
+ class LSPClient:
25
+ """LSP client that communicates with a language server over stdio.
26
+
27
+ When ``server_cmd`` is None the client operates in **stub mode**:
28
+ ``start()`` returns False, ``is_connected()`` returns False, and
29
+ ``send_request()`` returns None. This allows OMG to instantiate
30
+ the client unconditionally — LSP is always optional.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ server_cmd: list[str] | None = None,
36
+ transport: str = "stdio",
37
+ timeout: float = _DEFAULT_TIMEOUT,
38
+ ) -> None:
39
+ if transport != "stdio":
40
+ raise ValueError(f"Unsupported transport: {transport!r} (only 'stdio' is supported)")
41
+
42
+ self._server_cmd = server_cmd
43
+ self._transport = transport
44
+ self._timeout = timeout
45
+
46
+ self._process: subprocess.Popen[bytes] | None = None
47
+ self._request_id = 0
48
+ self._lock = threading.Lock()
49
+ self._connected = False
50
+ self._initialized = False
51
+
52
+ def start(self) -> bool:
53
+ """Start the language-server process.
54
+
55
+ Returns True on success, False if no server command was
56
+ configured (stub mode) or if the process failed to launch.
57
+ """
58
+ if self._server_cmd is None:
59
+ logger.debug("LSPClient in stub mode — no server_cmd provided")
60
+ return False
61
+
62
+ try:
63
+ self._process = subprocess.Popen(
64
+ self._server_cmd,
65
+ stdin=subprocess.PIPE,
66
+ stdout=subprocess.PIPE,
67
+ stderr=subprocess.PIPE,
68
+ )
69
+ self._connected = True
70
+ logger.info("LSP server started: %s (pid=%d)", self._server_cmd, self._process.pid)
71
+ return True
72
+ except (OSError, FileNotFoundError) as exc:
73
+ logger.error("Failed to start LSP server %s: %s", self._server_cmd, exc)
74
+ self._connected = False
75
+ return False
76
+
77
+ def initialize(
78
+ self,
79
+ root_uri: str,
80
+ capabilities: dict[str, Any] | None = None,
81
+ ) -> dict[str, Any]:
82
+ """Perform the LSP ``initialize`` handshake.
83
+
84
+ Returns the server capabilities dict, or an empty dict on
85
+ failure / stub mode.
86
+ """
87
+ if not self._connected:
88
+ return {}
89
+
90
+ params: dict[str, Any] = {
91
+ "processId": None,
92
+ "rootUri": root_uri,
93
+ "capabilities": capabilities or {},
94
+ }
95
+
96
+ result = self.send_request("initialize", params)
97
+ if result is None:
98
+ return {}
99
+
100
+ self.send_notification("initialized", {})
101
+ self.send_notification("initialized", {})
102
+ self._initialized = True
103
+ return result
104
+
105
+ def shutdown(self) -> None:
106
+ """Send ``shutdown`` then ``exit`` and tear down the process."""
107
+ if not self._connected or self._process is None:
108
+ return
109
+
110
+ try:
111
+ self.send_request("shutdown", {})
112
+ self.send_notification("exit", {})
113
+ except Exception: # noqa: BLE001
114
+ logger.debug("Error during LSP shutdown sequence", exc_info=True)
115
+
116
+ self._cleanup_process()
117
+
118
+ def send_request(
119
+ self,
120
+ method: str,
121
+ params: dict[str, Any],
122
+ ) -> dict[str, Any] | None:
123
+ """Send a JSON-RPC **request** (expects a response).
124
+
125
+ Returns the ``result`` field of the response, or None on
126
+ timeout / error / not connected.
127
+ """
128
+ if not self._connected or self._process is None:
129
+ return None
130
+
131
+ with self._lock:
132
+ self._request_id += 1
133
+ req_id = self._request_id
134
+
135
+ message: dict[str, Any] = {
136
+ "jsonrpc": "2.0",
137
+ "id": req_id,
138
+ "method": method,
139
+ "params": params,
140
+ }
141
+
142
+ try:
143
+ self._write_message(message)
144
+ response = self._read_message()
145
+ except Exception: # noqa: BLE001
146
+ logger.debug("send_request(%s) failed", method, exc_info=True)
147
+ return None
148
+
149
+ if response is None:
150
+ return None
151
+
152
+ if "error" in response:
153
+ logger.warning(
154
+ "LSP error for %s: %s",
155
+ method,
156
+ response["error"],
157
+ )
158
+ return None
159
+
160
+ return response.get("result")
161
+
162
+ def send_notification(
163
+ self,
164
+ method: str,
165
+ params: dict[str, Any],
166
+ ) -> None:
167
+ """Send a JSON-RPC **notification** (no ``id``, no response)."""
168
+ if not self._connected or self._process is None:
169
+ return
170
+
171
+ message: dict[str, Any] = {
172
+ "jsonrpc": "2.0",
173
+ "method": method,
174
+ "params": params,
175
+ }
176
+
177
+ try:
178
+ self._write_message(message)
179
+ except Exception: # noqa: BLE001
180
+ logger.debug("send_notification(%s) failed", method, exc_info=True)
181
+
182
+ def is_connected(self) -> bool:
183
+ """Return True if the server process is alive and connected."""
184
+ if not self._connected or self._process is None:
185
+ return False
186
+ # Poll returns None while process is still running
187
+ return self._process.poll() is None
188
+
189
+ @staticmethod
190
+ def encode_message(body: dict[str, Any]) -> bytes:
191
+ """Encode a JSON-RPC message with Content-Length header.
192
+
193
+ Public so tests can verify framing without a live process.
194
+ """
195
+ payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
196
+ header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")
197
+ return header + payload
198
+
199
+ def _write_message(self, body: dict[str, Any]) -> None:
200
+ """Write a framed JSON-RPC message to the server's stdin."""
201
+ assert self._process is not None and self._process.stdin is not None
202
+ raw = self.encode_message(body)
203
+ self._process.stdin.write(raw)
204
+ self._process.stdin.flush()
205
+
206
+ def _read_message(self) -> dict[str, Any] | None:
207
+ """Read one framed JSON-RPC message from the server's stdout.
208
+
209
+ Uses a background thread + join(timeout) so we never block
210
+ indefinitely.
211
+ """
212
+ assert self._process is not None and self._process.stdout is not None
213
+
214
+ result_holder: list[dict[str, Any] | None] = [None]
215
+
216
+ def _reader() -> None:
217
+ try:
218
+ stdout = self._process.stdout # type: ignore[union-attr]
219
+ content_length = 0
220
+ content_length = 0
221
+ while True:
222
+ line = stdout.readline()
223
+ if not line:
224
+ return # EOF
225
+ line_str = line.decode("ascii").strip()
226
+ if not line_str:
227
+ break
228
+ if line_str.startswith(_CONTENT_LENGTH_HEADER.strip()):
229
+ content_length = int(line_str[len(_CONTENT_LENGTH_HEADER.strip()):])
230
+
231
+ if content_length == 0:
232
+ return
233
+
234
+ data = stdout.read(content_length)
235
+ if data:
236
+ result_holder[0] = json.loads(data.decode("utf-8"))
237
+ except Exception: # noqa: BLE001
238
+ logger.debug("_reader failed", exc_info=True)
239
+
240
+ thread = threading.Thread(target=_reader, daemon=True)
241
+ thread.start()
242
+ thread.join(timeout=self._timeout)
243
+
244
+ if thread.is_alive():
245
+ logger.warning("LSP read timed out after %.1fs", self._timeout)
246
+ return None
247
+
248
+ return result_holder[0]
249
+
250
+ def _cleanup_process(self) -> None:
251
+ """Terminate / kill the server process and reset state."""
252
+ self._connected = False
253
+ self._initialized = False
254
+ if self._process is None:
255
+ return
256
+ try:
257
+ self._process.terminate()
258
+ self._process.wait(timeout=3)
259
+ except subprocess.TimeoutExpired:
260
+ self._process.kill()
261
+ self._process.wait(timeout=1)
262
+ except Exception: # noqa: BLE001
263
+ pass
264
+ finally:
265
+ self._process = None
266
+
267
+ def __enter__(self) -> LSPClient:
268
+ return self
269
+
270
+ def __exit__(self, *_: object) -> None:
271
+ self.shutdown()
272
+
273
+ def __repr__(self) -> str:
274
+ cmd = self._server_cmd or "(stub)"
275
+ return f"<LSPClient cmd={cmd!r} connected={self.is_connected()}>"
@@ -0,0 +1,231 @@
1
+ """LSP Server Auto-Discovery Tool
2
+
3
+ Scans for available LSP servers based on language configurations.
4
+ Searches in: node_modules/.bin/, .venv/bin/, system PATH
5
+
6
+ Feature flag: OMG_LSP_TOOLS_ENABLED (default: False)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import shutil
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Try to load YAML, fall back to JSON
22
+ try:
23
+ import yaml
24
+ HAS_YAML = True
25
+ except ImportError:
26
+ HAS_YAML = False
27
+
28
+
29
+ def _load_language_config(project_dir: str = ".") -> dict[str, Any]:
30
+ """Load language configuration from YAML or JSON.
31
+
32
+ Returns dict with 'languages' key containing list of language configs.
33
+ Falls back to empty dict if file not found.
34
+ """
35
+ config_path = Path(project_dir) / "config" / "lsp_languages.yaml"
36
+
37
+ if not config_path.exists():
38
+ return {"languages": []}
39
+
40
+ try:
41
+ if HAS_YAML:
42
+ with open(config_path, "r", encoding="utf-8") as f:
43
+ return yaml.safe_load(f) or {"languages": []}
44
+ else:
45
+ # Fallback: try to parse as JSON
46
+ with open(config_path, "r", encoding="utf-8") as f:
47
+ content = f.read()
48
+ # Simple YAML to JSON conversion for basic cases
49
+ # This is a minimal fallback - just try JSON first
50
+ try:
51
+ return json.loads(content)
52
+ except json.JSONDecodeError:
53
+ # If JSON fails, return empty config
54
+ return {"languages": []}
55
+ except Exception as e:
56
+ logger.error(f"Failed to load language config: {e}")
57
+ return {"languages": []}
58
+
59
+
60
+ def _expand_path(path_str: str) -> str:
61
+ """Expand ~ and environment variables in path."""
62
+ return os.path.expanduser(os.path.expandvars(path_str))
63
+
64
+
65
+ def _find_in_paths(binary_name: str, search_paths: list[str]) -> str | None:
66
+ """Find binary in list of paths.
67
+
68
+ Args:
69
+ binary_name: Name of binary to find (e.g., 'pylsp')
70
+ search_paths: List of paths to search (may contain ~ and env vars)
71
+
72
+ Returns:
73
+ Full path to binary if found, None otherwise
74
+ """
75
+ for path_str in search_paths:
76
+ expanded = _expand_path(path_str)
77
+
78
+ # If path is a directory, look for binary inside
79
+ if os.path.isdir(expanded):
80
+ binary_path = os.path.join(expanded, binary_name)
81
+ if os.path.isfile(binary_path) and os.access(binary_path, os.X_OK):
82
+ return binary_path
83
+ # If path is a file, check if it matches
84
+ elif os.path.isfile(expanded) and os.access(expanded, os.X_OK):
85
+ if os.path.basename(expanded) == binary_name:
86
+ return expanded
87
+
88
+ return None
89
+
90
+
91
+ def _find_in_system_path(binary_name: str) -> str | None:
92
+ """Find binary in system PATH.
93
+
94
+ Args:
95
+ binary_name: Name of binary to find
96
+
97
+ Returns:
98
+ Full path to binary if found, None otherwise
99
+ """
100
+ return shutil.which(binary_name)
101
+
102
+
103
+ def discover_lsp_servers(project_dir: str = ".") -> list[dict[str, Any]]:
104
+ """Discover available LSP servers in project.
105
+
106
+ Scans for LSP servers defined in config/lsp_languages.yaml.
107
+ For each language, checks discovery_paths and system PATH.
108
+
109
+ Args:
110
+ project_dir: Project directory to scan (default: current directory)
111
+
112
+ Returns:
113
+ List of dicts with keys:
114
+ - language: Language name
115
+ - server_command: Command to start server
116
+ - server_name: Human-readable server name
117
+ - found_at: Full path to server binary (or None if not found)
118
+ - available: Boolean indicating if server was found
119
+ """
120
+ config = _load_language_config(project_dir)
121
+ languages = config.get("languages", [])
122
+
123
+ discovered = []
124
+
125
+ for lang_config in languages:
126
+ language = lang_config.get("name", "unknown")
127
+ server_command = lang_config.get("server_command", [])
128
+ server_name = lang_config.get("server_name", "unknown")
129
+ discovery_paths = lang_config.get("discovery_paths", [])
130
+
131
+ # Get the binary name (first element of server_command)
132
+ if not server_command:
133
+ discovered.append({
134
+ "language": language,
135
+ "server_command": server_command,
136
+ "server_name": server_name,
137
+ "found_at": None,
138
+ "available": False,
139
+ })
140
+ continue
141
+
142
+ binary_name = server_command[0]
143
+
144
+ # Search in discovery_paths first
145
+ found_at = _find_in_paths(binary_name, discovery_paths)
146
+
147
+ # Fall back to system PATH
148
+ if not found_at:
149
+ found_at = _find_in_system_path(binary_name)
150
+
151
+ discovered.append({
152
+ "language": language,
153
+ "server_command": server_command,
154
+ "server_name": server_name,
155
+ "found_at": found_at,
156
+ "available": found_at is not None,
157
+ })
158
+
159
+ return discovered
160
+
161
+
162
+ def _is_enabled() -> bool:
163
+ """Check if LSP discovery is enabled via feature flag.
164
+
165
+ Checks env var first (OMG_LSP_TOOLS_ENABLED), then settings.json.
166
+ """
167
+ # Check environment variable
168
+ env_val = os.environ.get("OMG_LSP_TOOLS_ENABLED", "").lower()
169
+ if env_val in ("0", "false", "no"):
170
+ return False
171
+ if env_val in ("1", "true", "yes"):
172
+ return True
173
+
174
+ # Check settings.json
175
+ try:
176
+ project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
177
+ settings_path = os.path.join(project_dir, "settings.json")
178
+ if os.path.exists(settings_path):
179
+ with open(settings_path, "r", encoding="utf-8") as f:
180
+ settings = json.load(f)
181
+ features = settings.get("_oal", {}).get("features", {})
182
+ if "LSP_TOOLS" in features:
183
+ return features["LSP_TOOLS"]
184
+ except Exception:
185
+ pass
186
+
187
+ # Default: disabled (opt-in)
188
+ return False
189
+
190
+
191
+ def main():
192
+ """CLI entry point for LSP discovery.
193
+
194
+ Usage:
195
+ python3 tools/lsp_discovery.py --project <dir>
196
+
197
+ Outputs JSON to stdout with list of discovered servers.
198
+ """
199
+ import argparse
200
+
201
+ parser = argparse.ArgumentParser(
202
+ description="Discover available LSP servers in project"
203
+ )
204
+ parser.add_argument(
205
+ "--project",
206
+ default=".",
207
+ help="Project directory to scan (default: current directory)"
208
+ )
209
+
210
+ args = parser.parse_args()
211
+
212
+ # Discover servers (works regardless of feature flag)
213
+ servers = discover_lsp_servers(args.project)
214
+
215
+ # Output as JSON
216
+ output = {
217
+ "project_dir": os.path.abspath(args.project),
218
+ "enabled": _is_enabled(),
219
+ "servers": servers,
220
+ "summary": {
221
+ "total": len(servers),
222
+ "available": sum(1 for s in servers if s["available"]),
223
+ "unavailable": sum(1 for s in servers if not s["available"]),
224
+ }
225
+ }
226
+
227
+ json.dump(output, sys.stdout, indent=2)
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()