@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.
- package/.claude-plugin/marketplace.json +36 -0
- package/.claude-plugin/plugin.json +23 -0
- package/.claude-plugin/scripts/install.sh +49 -0
- package/.claude-plugin/scripts/uninstall.sh +80 -0
- package/.claude-plugin/scripts/update.sh +84 -0
- package/.mcp.json +20 -0
- package/LICENSE +21 -0
- package/OMG-setup.sh +1093 -0
- package/README.md +335 -0
- package/THIRD_PARTY_NOTICES.md +24 -0
- package/UPSTREAM_DIFF.md +20 -0
- package/agents/__init__.py +1 -0
- package/agents/_model_roles.yaml +26 -0
- package/agents/designer.md +67 -0
- package/agents/explore.md +60 -0
- package/agents/model_roles.py +196 -0
- package/agents/omg-api-builder.md +23 -0
- package/agents/omg-architect-mode.md +43 -0
- package/agents/omg-architect.md +13 -0
- package/agents/omg-backend-engineer.md +43 -0
- package/agents/omg-critic.md +16 -0
- package/agents/omg-database-engineer.md +43 -0
- package/agents/omg-escalation-router.md +17 -0
- package/agents/omg-executor.md +12 -0
- package/agents/omg-frontend-designer.md +42 -0
- package/agents/omg-implement-mode.md +50 -0
- package/agents/omg-infra-engineer.md +43 -0
- package/agents/omg-qa-tester.md +16 -0
- package/agents/omg-research-mode.md +43 -0
- package/agents/omg-security-auditor.md +43 -0
- package/agents/omg-testing-engineer.md +43 -0
- package/agents/plan.md +80 -0
- package/agents/quick_task.md +64 -0
- package/agents/reviewer.md +83 -0
- package/agents/task.md +71 -0
- package/commands/OMG:ccg.md +22 -0
- package/commands/OMG:compat.md +57 -0
- package/commands/OMG:crazy.md +125 -0
- package/commands/OMG:domain-init.md +11 -0
- package/commands/OMG:escalate.md +52 -0
- package/commands/OMG:health-check.md +45 -0
- package/commands/OMG:init.md +134 -0
- package/commands/OMG:mode.md +44 -0
- package/commands/OMG:project-init.md +11 -0
- package/commands/OMG:ralph-start.md +43 -0
- package/commands/OMG:ralph-stop.md +23 -0
- package/commands/OMG:teams.md +39 -0
- package/commands/ai-commit.md +113 -0
- package/commands/ccg.md +9 -0
- package/commands/create-agent.md +183 -0
- package/commands/omc-teams.md +9 -0
- package/commands/session-branch.md +85 -0
- package/commands/session-fork.md +53 -0
- package/commands/session-merge.md +134 -0
- package/commands/theme.md +44 -0
- package/config/lsp_languages.yaml +324 -0
- package/config/themes/catppuccin-frappe.yaml +14 -0
- package/config/themes/catppuccin-latte.yaml +14 -0
- package/config/themes/catppuccin-macchiato.yaml +14 -0
- package/config/themes/catppuccin-mocha.yaml +14 -0
- package/config/themes/dracula.yaml +14 -0
- package/config/themes/gruvbox-dark.yaml +14 -0
- package/config/themes/nord.yaml +14 -0
- package/config/themes/one-dark.yaml +14 -0
- package/config/themes/solarized-dark.yaml +14 -0
- package/config/themes/tokyo-night.yaml +14 -0
- package/control_plane/__init__.py +2 -0
- package/control_plane/openapi.yaml +109 -0
- package/control_plane/server.py +107 -0
- package/control_plane/service.py +148 -0
- package/crates/omg-natives/Cargo.toml +17 -0
- package/crates/omg-natives/src/clipboard.rs +5 -0
- package/crates/omg-natives/src/glob.rs +15 -0
- package/crates/omg-natives/src/grep.rs +15 -0
- package/crates/omg-natives/src/highlight.rs +15 -0
- package/crates/omg-natives/src/html.rs +14 -0
- package/crates/omg-natives/src/image.rs +5 -0
- package/crates/omg-natives/src/keys.rs +5 -0
- package/crates/omg-natives/src/lib.rs +36 -0
- package/crates/omg-natives/src/prof.rs +5 -0
- package/crates/omg-natives/src/ps.rs +5 -0
- package/crates/omg-natives/src/shell.rs +5 -0
- package/crates/omg-natives/src/task.rs +5 -0
- package/crates/omg-natives/src/text.rs +14 -0
- package/hooks/_agent_registry.py +421 -0
- package/hooks/_budget.py +31 -0
- package/hooks/_common.py +476 -0
- package/hooks/_learnings.py +126 -0
- package/hooks/_memory.py +103 -0
- package/hooks/circuit-breaker.py +270 -0
- package/hooks/config-guard.py +163 -0
- package/hooks/context_pressure.py +53 -0
- package/hooks/credential_store.py +801 -0
- package/hooks/fetch-rate-limits.py +212 -0
- package/hooks/firewall.py +48 -0
- package/hooks/hashline-formatter-bridge.py +224 -0
- package/hooks/hashline-injector.py +273 -0
- package/hooks/hashline-validator.py +216 -0
- package/hooks/idle-detector.py +95 -0
- package/hooks/intentgate-keyword-detector.py +188 -0
- package/hooks/magic-keyword-router.py +195 -0
- package/hooks/policy_engine.py +310 -0
- package/hooks/post-tool-failure.py +19 -0
- package/hooks/post-write.py +199 -0
- package/hooks/pre-compact.py +204 -0
- package/hooks/pre-tool-inject.py +98 -0
- package/hooks/prompt-enhancer.py +672 -0
- package/hooks/quality-runner.py +191 -0
- package/hooks/secret-guard.py +47 -0
- package/hooks/session-end-capture.py +137 -0
- package/hooks/session-start.py +275 -0
- package/hooks/shadow_manager.py +297 -0
- package/hooks/state_migration.py +209 -0
- package/hooks/stop-gate.py +7 -0
- package/hooks/stop_dispatcher.py +929 -0
- package/hooks/test-validator.py +138 -0
- package/hooks/todo-state-tracker.py +114 -0
- package/hooks/tool-ledger.py +126 -0
- package/hooks/trust_review.py +524 -0
- package/install.sh +9 -0
- package/omg_natives/__init__.py +186 -0
- package/omg_natives/_bindings.py +165 -0
- package/omg_natives/clipboard.py +36 -0
- package/omg_natives/glob.py +42 -0
- package/omg_natives/grep.py +61 -0
- package/omg_natives/highlight.py +54 -0
- package/omg_natives/html.py +157 -0
- package/omg_natives/image.py +51 -0
- package/omg_natives/keys.py +46 -0
- package/omg_natives/prof.py +39 -0
- package/omg_natives/ps.py +93 -0
- package/omg_natives/shell.py +58 -0
- package/omg_natives/task.py +41 -0
- package/omg_natives/text.py +50 -0
- package/package.json +26 -0
- package/plugins/README.md +82 -0
- package/plugins/advanced/commands/OMG:code-review.md +114 -0
- package/plugins/advanced/commands/OMG:deep-plan.md +221 -0
- package/plugins/advanced/commands/OMG:handoff.md +115 -0
- package/plugins/advanced/commands/OMG:learn.md +110 -0
- package/plugins/advanced/commands/OMG:maintainer.md +31 -0
- package/plugins/advanced/commands/OMG:ralph-start.md +43 -0
- package/plugins/advanced/commands/OMG:ralph-stop.md +23 -0
- package/plugins/advanced/commands/OMG:security-review.md +119 -0
- package/plugins/advanced/commands/OMG:sequential-thinking.md +20 -0
- package/plugins/advanced/commands/OMG:ship.md +46 -0
- package/plugins/advanced/plugin.json +96 -0
- package/plugins/core/plugin.json +82 -0
- package/pytest.ini +5 -0
- package/registry/__init__.py +1 -0
- package/registry/verify_artifact.py +90 -0
- package/rules/contextual/architect-mode.md +9 -0
- package/rules/contextual/big-picture.md +20 -0
- package/rules/contextual/code-hygiene.md +26 -0
- package/rules/contextual/context-management.md +19 -0
- package/rules/contextual/context-minimization.md +32 -0
- package/rules/contextual/ddd-sdd.md +28 -0
- package/rules/contextual/dependency-safety.md +16 -0
- package/rules/contextual/doc-check.md +13 -0
- package/rules/contextual/implement-mode.md +9 -0
- package/rules/contextual/infra-safety.md +14 -0
- package/rules/contextual/outside-in.md +13 -0
- package/rules/contextual/persistent-mode.md +24 -0
- package/rules/contextual/research-mode.md +9 -0
- package/rules/contextual/security-domains.md +25 -0
- package/rules/contextual/vision-detection.md +27 -0
- package/rules/contextual/web-search.md +25 -0
- package/rules/contextual/write-verify.md +23 -0
- package/rules/core/00-truth.md +20 -0
- package/rules/core/01-surgical.md +19 -0
- package/rules/core/02-circuit-breaker.md +22 -0
- package/rules/core/03-ensemble.md +28 -0
- package/rules/core/04-testing.md +30 -0
- package/runtime/__init__.py +32 -0
- package/runtime/adapters/__init__.py +13 -0
- package/runtime/adapters/claude.py +60 -0
- package/runtime/adapters/gpt.py +53 -0
- package/runtime/adapters/local.py +53 -0
- package/runtime/business_workflow.py +220 -0
- package/runtime/compat.py +1299 -0
- package/runtime/custom_agent_loader.py +366 -0
- package/runtime/dispatcher.py +47 -0
- package/runtime/ecosystem.py +371 -0
- package/runtime/legacy_compat.py +7 -0
- package/runtime/omc_compat.py +7 -0
- package/runtime/omc_contract_snapshot.json +916 -0
- package/runtime/omg_compat_contract_snapshot.json +916 -0
- package/runtime/subagent_dispatcher.py +362 -0
- package/runtime/team_router.py +838 -0
- package/scripts/check-omc-contract-snapshot.py +12 -0
- package/scripts/check-omg-compat-contract-snapshot.py +137 -0
- package/scripts/check-omg-standalone-clean.py +102 -0
- package/scripts/legacy_to_omg_migrate.py +29 -0
- package/scripts/migrate-omc.py +464 -0
- package/scripts/omc_to_omg_migrate.py +12 -0
- package/scripts/omg.py +493 -0
- package/scripts/settings-merge.py +224 -0
- package/scripts/verify-no-omc.sh +5 -0
- package/scripts/verify-standalone.sh +21 -0
- package/templates/idea.yml +30 -0
- package/templates/policy.yaml +15 -0
- package/templates/profile.yaml +25 -0
- package/templates/runtime.yaml +12 -0
- package/templates/working-memory.md +17 -0
- package/tools/__init__.py +2 -0
- package/tools/browser_consent.py +289 -0
- package/tools/browser_stealth.py +481 -0
- package/tools/browser_tool.py +448 -0
- package/tools/changelog_generator.py +268 -0
- package/tools/commit_splitter.py +361 -0
- package/tools/config_discovery.py +151 -0
- package/tools/config_merger.py +449 -0
- package/tools/git_inspector.py +298 -0
- package/tools/lsp_client.py +275 -0
- package/tools/lsp_discovery.py +231 -0
- package/tools/lsp_operations.py +392 -0
- package/tools/python_repl.py +656 -0
- package/tools/python_sandbox.py +609 -0
- package/tools/search_providers/__init__.py +77 -0
- package/tools/search_providers/brave.py +115 -0
- package/tools/search_providers/exa.py +116 -0
- package/tools/search_providers/jina.py +104 -0
- package/tools/search_providers/perplexity.py +139 -0
- package/tools/search_providers/synthetic.py +74 -0
- package/tools/session_snapshot.py +736 -0
- package/tools/ssh_manager.py +912 -0
- package/tools/theme_engine.py +294 -0
- package/tools/theme_selector.py +137 -0
- 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()}>"
|