@trac3er/oh-my-god 1.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 (229) hide show
  1. package/.claude-plugin/marketplace.json +36 -0
  2. package/.claude-plugin/plugin.json +23 -0
  3. package/.claude-plugin/scripts/install.sh +49 -0
  4. package/.claude-plugin/scripts/uninstall.sh +80 -0
  5. package/.claude-plugin/scripts/update.sh +84 -0
  6. package/.mcp.json +20 -0
  7. package/LICENSE +21 -0
  8. package/OMG-setup.sh +1093 -0
  9. package/README.md +335 -0
  10. package/THIRD_PARTY_NOTICES.md +24 -0
  11. package/UPSTREAM_DIFF.md +20 -0
  12. package/agents/__init__.py +1 -0
  13. package/agents/_model_roles.yaml +26 -0
  14. package/agents/designer.md +67 -0
  15. package/agents/explore.md +60 -0
  16. package/agents/model_roles.py +196 -0
  17. package/agents/omg-api-builder.md +23 -0
  18. package/agents/omg-architect-mode.md +43 -0
  19. package/agents/omg-architect.md +13 -0
  20. package/agents/omg-backend-engineer.md +43 -0
  21. package/agents/omg-critic.md +16 -0
  22. package/agents/omg-database-engineer.md +43 -0
  23. package/agents/omg-escalation-router.md +17 -0
  24. package/agents/omg-executor.md +12 -0
  25. package/agents/omg-frontend-designer.md +42 -0
  26. package/agents/omg-implement-mode.md +50 -0
  27. package/agents/omg-infra-engineer.md +43 -0
  28. package/agents/omg-qa-tester.md +16 -0
  29. package/agents/omg-research-mode.md +43 -0
  30. package/agents/omg-security-auditor.md +43 -0
  31. package/agents/omg-testing-engineer.md +43 -0
  32. package/agents/plan.md +80 -0
  33. package/agents/quick_task.md +64 -0
  34. package/agents/reviewer.md +83 -0
  35. package/agents/task.md +71 -0
  36. package/commands/OMG:ccg.md +22 -0
  37. package/commands/OMG:compat.md +57 -0
  38. package/commands/OMG:crazy.md +125 -0
  39. package/commands/OMG:domain-init.md +11 -0
  40. package/commands/OMG:escalate.md +52 -0
  41. package/commands/OMG:health-check.md +45 -0
  42. package/commands/OMG:init.md +134 -0
  43. package/commands/OMG:mode.md +44 -0
  44. package/commands/OMG:project-init.md +11 -0
  45. package/commands/OMG:ralph-start.md +43 -0
  46. package/commands/OMG:ralph-stop.md +23 -0
  47. package/commands/OMG:teams.md +39 -0
  48. package/commands/ai-commit.md +113 -0
  49. package/commands/ccg.md +9 -0
  50. package/commands/create-agent.md +183 -0
  51. package/commands/omc-teams.md +9 -0
  52. package/commands/session-branch.md +85 -0
  53. package/commands/session-fork.md +53 -0
  54. package/commands/session-merge.md +134 -0
  55. package/commands/theme.md +44 -0
  56. package/config/lsp_languages.yaml +324 -0
  57. package/config/themes/catppuccin-frappe.yaml +14 -0
  58. package/config/themes/catppuccin-latte.yaml +14 -0
  59. package/config/themes/catppuccin-macchiato.yaml +14 -0
  60. package/config/themes/catppuccin-mocha.yaml +14 -0
  61. package/config/themes/dracula.yaml +14 -0
  62. package/config/themes/gruvbox-dark.yaml +14 -0
  63. package/config/themes/nord.yaml +14 -0
  64. package/config/themes/one-dark.yaml +14 -0
  65. package/config/themes/solarized-dark.yaml +14 -0
  66. package/config/themes/tokyo-night.yaml +14 -0
  67. package/control_plane/__init__.py +2 -0
  68. package/control_plane/openapi.yaml +109 -0
  69. package/control_plane/server.py +107 -0
  70. package/control_plane/service.py +148 -0
  71. package/crates/omg-natives/Cargo.toml +17 -0
  72. package/crates/omg-natives/src/clipboard.rs +5 -0
  73. package/crates/omg-natives/src/glob.rs +15 -0
  74. package/crates/omg-natives/src/grep.rs +15 -0
  75. package/crates/omg-natives/src/highlight.rs +15 -0
  76. package/crates/omg-natives/src/html.rs +14 -0
  77. package/crates/omg-natives/src/image.rs +5 -0
  78. package/crates/omg-natives/src/keys.rs +5 -0
  79. package/crates/omg-natives/src/lib.rs +36 -0
  80. package/crates/omg-natives/src/prof.rs +5 -0
  81. package/crates/omg-natives/src/ps.rs +5 -0
  82. package/crates/omg-natives/src/shell.rs +5 -0
  83. package/crates/omg-natives/src/task.rs +5 -0
  84. package/crates/omg-natives/src/text.rs +14 -0
  85. package/hooks/_agent_registry.py +421 -0
  86. package/hooks/_budget.py +31 -0
  87. package/hooks/_common.py +476 -0
  88. package/hooks/_learnings.py +126 -0
  89. package/hooks/_memory.py +103 -0
  90. package/hooks/circuit-breaker.py +270 -0
  91. package/hooks/config-guard.py +163 -0
  92. package/hooks/context_pressure.py +53 -0
  93. package/hooks/credential_store.py +801 -0
  94. package/hooks/fetch-rate-limits.py +212 -0
  95. package/hooks/firewall.py +48 -0
  96. package/hooks/hashline-formatter-bridge.py +224 -0
  97. package/hooks/hashline-injector.py +273 -0
  98. package/hooks/hashline-validator.py +216 -0
  99. package/hooks/idle-detector.py +95 -0
  100. package/hooks/intentgate-keyword-detector.py +188 -0
  101. package/hooks/magic-keyword-router.py +195 -0
  102. package/hooks/policy_engine.py +310 -0
  103. package/hooks/post-tool-failure.py +19 -0
  104. package/hooks/post-write.py +199 -0
  105. package/hooks/pre-compact.py +204 -0
  106. package/hooks/pre-tool-inject.py +98 -0
  107. package/hooks/prompt-enhancer.py +672 -0
  108. package/hooks/quality-runner.py +191 -0
  109. package/hooks/secret-guard.py +47 -0
  110. package/hooks/session-end-capture.py +137 -0
  111. package/hooks/session-start.py +275 -0
  112. package/hooks/shadow_manager.py +297 -0
  113. package/hooks/state_migration.py +209 -0
  114. package/hooks/stop-gate.py +7 -0
  115. package/hooks/stop_dispatcher.py +929 -0
  116. package/hooks/test-validator.py +138 -0
  117. package/hooks/todo-state-tracker.py +114 -0
  118. package/hooks/tool-ledger.py +126 -0
  119. package/hooks/trust_review.py +524 -0
  120. package/install.sh +9 -0
  121. package/omg_natives/__init__.py +186 -0
  122. package/omg_natives/_bindings.py +165 -0
  123. package/omg_natives/clipboard.py +36 -0
  124. package/omg_natives/glob.py +42 -0
  125. package/omg_natives/grep.py +61 -0
  126. package/omg_natives/highlight.py +54 -0
  127. package/omg_natives/html.py +157 -0
  128. package/omg_natives/image.py +51 -0
  129. package/omg_natives/keys.py +46 -0
  130. package/omg_natives/prof.py +39 -0
  131. package/omg_natives/ps.py +93 -0
  132. package/omg_natives/shell.py +58 -0
  133. package/omg_natives/task.py +41 -0
  134. package/omg_natives/text.py +50 -0
  135. package/package.json +26 -0
  136. package/plugins/README.md +82 -0
  137. package/plugins/advanced/commands/OMG:code-review.md +114 -0
  138. package/plugins/advanced/commands/OMG:deep-plan.md +221 -0
  139. package/plugins/advanced/commands/OMG:handoff.md +115 -0
  140. package/plugins/advanced/commands/OMG:learn.md +110 -0
  141. package/plugins/advanced/commands/OMG:maintainer.md +31 -0
  142. package/plugins/advanced/commands/OMG:ralph-start.md +43 -0
  143. package/plugins/advanced/commands/OMG:ralph-stop.md +23 -0
  144. package/plugins/advanced/commands/OMG:security-review.md +119 -0
  145. package/plugins/advanced/commands/OMG:sequential-thinking.md +20 -0
  146. package/plugins/advanced/commands/OMG:ship.md +46 -0
  147. package/plugins/advanced/plugin.json +96 -0
  148. package/plugins/core/plugin.json +82 -0
  149. package/pytest.ini +5 -0
  150. package/registry/__init__.py +1 -0
  151. package/registry/verify_artifact.py +90 -0
  152. package/rules/contextual/architect-mode.md +9 -0
  153. package/rules/contextual/big-picture.md +20 -0
  154. package/rules/contextual/code-hygiene.md +26 -0
  155. package/rules/contextual/context-management.md +19 -0
  156. package/rules/contextual/context-minimization.md +32 -0
  157. package/rules/contextual/ddd-sdd.md +28 -0
  158. package/rules/contextual/dependency-safety.md +16 -0
  159. package/rules/contextual/doc-check.md +13 -0
  160. package/rules/contextual/implement-mode.md +9 -0
  161. package/rules/contextual/infra-safety.md +14 -0
  162. package/rules/contextual/outside-in.md +13 -0
  163. package/rules/contextual/persistent-mode.md +24 -0
  164. package/rules/contextual/research-mode.md +9 -0
  165. package/rules/contextual/security-domains.md +25 -0
  166. package/rules/contextual/vision-detection.md +27 -0
  167. package/rules/contextual/web-search.md +25 -0
  168. package/rules/contextual/write-verify.md +23 -0
  169. package/rules/core/00-truth.md +20 -0
  170. package/rules/core/01-surgical.md +19 -0
  171. package/rules/core/02-circuit-breaker.md +22 -0
  172. package/rules/core/03-ensemble.md +28 -0
  173. package/rules/core/04-testing.md +30 -0
  174. package/runtime/__init__.py +32 -0
  175. package/runtime/adapters/__init__.py +13 -0
  176. package/runtime/adapters/claude.py +60 -0
  177. package/runtime/adapters/gpt.py +53 -0
  178. package/runtime/adapters/local.py +53 -0
  179. package/runtime/business_workflow.py +220 -0
  180. package/runtime/compat.py +1299 -0
  181. package/runtime/custom_agent_loader.py +366 -0
  182. package/runtime/dispatcher.py +47 -0
  183. package/runtime/ecosystem.py +371 -0
  184. package/runtime/legacy_compat.py +7 -0
  185. package/runtime/omc_compat.py +7 -0
  186. package/runtime/omc_contract_snapshot.json +916 -0
  187. package/runtime/omg_compat_contract_snapshot.json +916 -0
  188. package/runtime/subagent_dispatcher.py +362 -0
  189. package/runtime/team_router.py +838 -0
  190. package/scripts/check-omc-contract-snapshot.py +12 -0
  191. package/scripts/check-omg-compat-contract-snapshot.py +137 -0
  192. package/scripts/check-omg-standalone-clean.py +102 -0
  193. package/scripts/legacy_to_omg_migrate.py +29 -0
  194. package/scripts/migrate-omc.py +464 -0
  195. package/scripts/omc_to_omg_migrate.py +12 -0
  196. package/scripts/omg.py +493 -0
  197. package/scripts/settings-merge.py +224 -0
  198. package/scripts/verify-no-omc.sh +5 -0
  199. package/scripts/verify-standalone.sh +21 -0
  200. package/templates/idea.yml +30 -0
  201. package/templates/policy.yaml +15 -0
  202. package/templates/profile.yaml +25 -0
  203. package/templates/runtime.yaml +12 -0
  204. package/templates/working-memory.md +17 -0
  205. package/tools/__init__.py +2 -0
  206. package/tools/browser_consent.py +289 -0
  207. package/tools/browser_stealth.py +481 -0
  208. package/tools/browser_tool.py +448 -0
  209. package/tools/changelog_generator.py +268 -0
  210. package/tools/commit_splitter.py +361 -0
  211. package/tools/config_discovery.py +151 -0
  212. package/tools/config_merger.py +449 -0
  213. package/tools/git_inspector.py +298 -0
  214. package/tools/lsp_client.py +275 -0
  215. package/tools/lsp_discovery.py +231 -0
  216. package/tools/lsp_operations.py +392 -0
  217. package/tools/python_repl.py +656 -0
  218. package/tools/python_sandbox.py +609 -0
  219. package/tools/search_providers/__init__.py +77 -0
  220. package/tools/search_providers/brave.py +115 -0
  221. package/tools/search_providers/exa.py +116 -0
  222. package/tools/search_providers/jina.py +104 -0
  223. package/tools/search_providers/perplexity.py +139 -0
  224. package/tools/search_providers/synthetic.py +74 -0
  225. package/tools/session_snapshot.py +736 -0
  226. package/tools/ssh_manager.py +912 -0
  227. package/tools/theme_engine.py +294 -0
  228. package/tools/theme_selector.py +137 -0
  229. package/tools/web_search.py +622 -0
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Git Inspection Tools for OMG
4
+
5
+ Read-only git inspection: status, log, and hunk-level diffs.
6
+ Feature flag: OMG_GIT_TOOLS_ENABLED (default: False)
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ # Import feature flag helper
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+ from hooks._common import get_feature_flag
19
+
20
+
21
+ def git_status(cwd: str = ".") -> Dict[str, Any]:
22
+ """
23
+ Get git status: staged, unstaged, untracked files and current branch.
24
+
25
+ Args:
26
+ cwd: Working directory (default: current directory)
27
+
28
+ Returns:
29
+ {
30
+ "skipped": True # if feature flag disabled
31
+ }
32
+ or
33
+ {
34
+ "staged": ["file1.py", "file2.py"],
35
+ "unstaged": ["file3.py"],
36
+ "untracked": ["file4.py"],
37
+ "branch": "main",
38
+ "error": None
39
+ }
40
+ or
41
+ {
42
+ "error": "git not found"
43
+ }
44
+ """
45
+ # Check feature flag
46
+ if not get_feature_flag("git_tools", default=False):
47
+ return {"skipped": True}
48
+
49
+ try:
50
+ # Get status with porcelain format
51
+ result = subprocess.run(
52
+ ["git", "status", "--porcelain"],
53
+ cwd=cwd,
54
+ capture_output=True,
55
+ text=True,
56
+ timeout=10
57
+ )
58
+
59
+ if result.returncode != 0:
60
+ return {"error": "git command failed"}
61
+
62
+ staged = []
63
+ unstaged = []
64
+ untracked = []
65
+
66
+ for line in result.stdout.split("\n"):
67
+ if not line:
68
+ continue
69
+
70
+ status_code = line[:2]
71
+ file_path = line[3:]
72
+
73
+ # First char: index (staged)
74
+ # Second char: working tree (unstaged)
75
+ if status_code[0] != " ":
76
+ staged.append(file_path)
77
+ if status_code[1] != " ":
78
+ unstaged.append(file_path)
79
+ if status_code == "??":
80
+ untracked.append(file_path)
81
+
82
+ # Get current branch
83
+ branch_result = subprocess.run(
84
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
85
+ cwd=cwd,
86
+ capture_output=True,
87
+ text=True,
88
+ timeout=10
89
+ )
90
+ branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
91
+
92
+ return {
93
+ "staged": staged,
94
+ "unstaged": unstaged,
95
+ "untracked": untracked,
96
+ "branch": branch,
97
+ "error": None
98
+ }
99
+
100
+ except FileNotFoundError:
101
+ return {"error": "git not found"}
102
+ except subprocess.TimeoutExpired:
103
+ return {"error": "git command timeout"}
104
+ except Exception as e:
105
+ return {"error": str(e)}
106
+
107
+
108
+ def git_log(cwd: str = ".", n: int = 10) -> List[Dict[str, Any]]:
109
+ """
110
+ Get recent N commits with hash, subject, author, and date.
111
+
112
+ Args:
113
+ cwd: Working directory (default: current directory)
114
+ n: Number of commits to retrieve (default: 10)
115
+
116
+ Returns:
117
+ List of {hash, subject, author, date} dicts
118
+ Empty list if git not available or feature flag disabled
119
+ """
120
+ # Check feature flag
121
+ if not get_feature_flag("git_tools", default=False):
122
+ return []
123
+
124
+ try:
125
+ result = subprocess.run(
126
+ ["git", "log", f"--oneline", f"-n", str(n),
127
+ "--format=%H|%s|%an|%ai"],
128
+ cwd=cwd,
129
+ capture_output=True,
130
+ text=True,
131
+ timeout=10
132
+ )
133
+
134
+ if result.returncode != 0:
135
+ return []
136
+
137
+ commits = []
138
+ for line in result.stdout.strip().split("\n"):
139
+ if not line:
140
+ continue
141
+
142
+ parts = line.split("|", 3)
143
+ if len(parts) == 4:
144
+ commits.append({
145
+ "hash": parts[0],
146
+ "subject": parts[1],
147
+ "author": parts[2],
148
+ "date": parts[3]
149
+ })
150
+
151
+ return commits
152
+
153
+ except FileNotFoundError:
154
+ return []
155
+ except subprocess.TimeoutExpired:
156
+ return []
157
+ except Exception:
158
+ return []
159
+
160
+
161
+ def git_hunk(cwd: str = ".", file_path: Optional[str] = None) -> List[Dict[str, Any]]:
162
+ """
163
+ Get hunk-level diff for a file or all files.
164
+
165
+ Args:
166
+ cwd: Working directory (default: current directory)
167
+ file_path: Specific file to diff (None for all files)
168
+
169
+ Returns:
170
+ List of {file, old_start, old_count, new_start, new_count, context, lines} dicts
171
+ Empty list if no diff or git not available or feature flag disabled
172
+ """
173
+ # Check feature flag
174
+ if not get_feature_flag("git_tools", default=False):
175
+ return []
176
+
177
+ try:
178
+ cmd = ["git", "diff", "--unified=3"]
179
+ if file_path:
180
+ cmd.append(file_path)
181
+
182
+ result = subprocess.run(
183
+ cmd,
184
+ cwd=cwd,
185
+ capture_output=True,
186
+ text=True,
187
+ timeout=10
188
+ )
189
+
190
+ if result.returncode != 0:
191
+ return []
192
+
193
+ hunks = []
194
+ current_file = None
195
+ current_hunk = None
196
+ hunk_lines = []
197
+
198
+ # Regex to match hunk header: @@ -a,b +c,d @@ context
199
+ hunk_header_re = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@\s*(.*)")
200
+
201
+ for line in result.stdout.split("\n"):
202
+ # File header
203
+ if line.startswith("diff --git"):
204
+ # Save previous hunk if exists
205
+ if current_hunk and hunk_lines:
206
+ current_hunk["lines"] = hunk_lines
207
+ hunks.append(current_hunk)
208
+ hunk_lines = []
209
+ current_hunk = None
210
+
211
+ # Extract filename from "diff --git a/file b/file"
212
+ parts = line.split()
213
+ if len(parts) >= 4:
214
+ current_file = parts[3][2:] # Remove "b/" prefix
215
+
216
+ # Hunk header
217
+ elif line.startswith("@@"):
218
+ # Save previous hunk if exists
219
+ if current_hunk and hunk_lines:
220
+ current_hunk["lines"] = hunk_lines
221
+ hunks.append(current_hunk)
222
+ hunk_lines = []
223
+
224
+ match = hunk_header_re.match(line)
225
+ if match:
226
+ old_start = int(match.group(1))
227
+ old_count = int(match.group(2)) if match.group(2) else 1
228
+ new_start = int(match.group(3))
229
+ new_count = int(match.group(4)) if match.group(4) else 1
230
+ context = match.group(5).strip()
231
+
232
+ current_hunk = {
233
+ "file": current_file,
234
+ "old_start": old_start,
235
+ "old_count": old_count,
236
+ "new_start": new_start,
237
+ "new_count": new_count,
238
+ "context": context,
239
+ "lines": []
240
+ }
241
+
242
+ # Hunk content
243
+ elif current_hunk is not None:
244
+ if line.startswith("+") or line.startswith("-") or line.startswith(" "):
245
+ hunk_lines.append(line)
246
+
247
+ # Save last hunk
248
+ if current_hunk and hunk_lines:
249
+ current_hunk["lines"] = hunk_lines
250
+ hunks.append(current_hunk)
251
+
252
+ return hunks
253
+
254
+ except FileNotFoundError:
255
+ return []
256
+ except subprocess.TimeoutExpired:
257
+ return []
258
+ except Exception:
259
+ return []
260
+
261
+
262
+ def main():
263
+ """CLI entry point."""
264
+ if len(sys.argv) < 2:
265
+ print("Usage:", file=sys.stderr)
266
+ print(" python3 git_inspector.py --overview", file=sys.stderr)
267
+ print(" python3 git_inspector.py --hunk [--file <path>]", file=sys.stderr)
268
+ sys.exit(1)
269
+
270
+ cwd = os.getcwd()
271
+
272
+ if sys.argv[1] == "--overview":
273
+ # Return status + log
274
+ status = git_status(cwd)
275
+ log = git_log(cwd)
276
+ result = {
277
+ "status": status,
278
+ "log": log
279
+ }
280
+ print(json.dumps(result, indent=2))
281
+
282
+ elif sys.argv[1] == "--hunk":
283
+ # Return hunk diff
284
+ file_path = None
285
+ if len(sys.argv) >= 4 and sys.argv[2] == "--file":
286
+ file_path = sys.argv[3]
287
+
288
+ hunks = git_hunk(cwd, file_path)
289
+ result = {"hunks": hunks}
290
+ print(json.dumps(result, indent=2))
291
+
292
+ else:
293
+ print(f"Unknown command: {sys.argv[1]}", file=sys.stderr)
294
+ sys.exit(1)
295
+
296
+
297
+ if __name__ == "__main__":
298
+ main()
@@ -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()}>"