@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,448 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Puppeteer Browser Integration for OMG
4
+
5
+ Wrapper around Puppeteer MCP tools that generates tool call specifications
6
+ for browser automation. Functions produce spec dicts — actual execution is
7
+ done by Claude Code when it dispatches the MCP call.
8
+
9
+ Feature flag: OMG_BROWSER_ENABLED (default: False)
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+ import uuid
16
+ from dataclasses import asdict, dataclass, field
17
+ from typing import Any, Dict, List, Optional
18
+
19
+
20
+ # --- Lazy imports for hooks/_common.py ---
21
+
22
+ _get_feature_flag = None
23
+
24
+
25
+ def _ensure_imports():
26
+ """Lazy import feature flag from hooks/_common.py."""
27
+ global _get_feature_flag
28
+ if _get_feature_flag is not None:
29
+ return
30
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
31
+ if repo_root not in sys.path:
32
+ sys.path.insert(0, repo_root)
33
+ try:
34
+ from hooks._common import get_feature_flag as _gff
35
+ _get_feature_flag = _gff
36
+ except ImportError:
37
+ pass
38
+
39
+
40
+ # --- Feature flag ---
41
+
42
+ def _is_enabled() -> bool:
43
+ """Check if Browser feature is enabled."""
44
+ # Fast path: check env var directly
45
+ env_val = os.environ.get("OMG_BROWSER_ENABLED", "").lower()
46
+ if env_val in ("0", "false", "no"):
47
+ return False
48
+ if env_val in ("1", "true", "yes"):
49
+ return True
50
+ # Fallback to hooks/_common.get_feature_flag
51
+ _ensure_imports()
52
+ if _get_feature_flag is not None:
53
+ return _get_feature_flag("BROWSER", default=False)
54
+ return False
55
+
56
+
57
+ # --- Response helpers ---
58
+
59
+ def _success_response(result: Any) -> Dict[str, Any]:
60
+ """Create a success response dict."""
61
+ return {"success": True, "result": result, "error": None}
62
+
63
+
64
+ def _error_response(error: str) -> Dict[str, Any]:
65
+ """Create an error response dict."""
66
+ return {"success": False, "result": None, "error": error}
67
+
68
+
69
+ def _disabled_response() -> Dict[str, Any]:
70
+ """Create a response for when the feature flag is disabled."""
71
+ return _error_response("Browser feature is disabled (OMG_BROWSER_ENABLED=false)")
72
+
73
+
74
+ # =============================================================================
75
+ # BrowserSession — session state tracking
76
+ # =============================================================================
77
+
78
+
79
+ @dataclass
80
+ class BrowserSession:
81
+ """Manages browser session state.
82
+
83
+ Tracks the current URL, navigation history, and screenshot names
84
+ for the duration of a browser automation session.
85
+
86
+ Attributes:
87
+ session_id: Unique identifier for this session.
88
+ current_url: The URL the browser is currently on (empty if none).
89
+ history: List of URLs visited during this session.
90
+ screenshots: List of screenshot names taken during this session.
91
+ """
92
+
93
+ session_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
94
+ current_url: str = ""
95
+ history: List[str] = field(default_factory=list)
96
+ screenshots: List[str] = field(default_factory=list)
97
+
98
+ def navigate_to(self, url: str) -> None:
99
+ """Record a navigation to a URL."""
100
+ self.current_url = url
101
+ self.history.append(url)
102
+
103
+ def record_screenshot(self, name: str) -> None:
104
+ """Record a screenshot taken."""
105
+ self.screenshots.append(name)
106
+
107
+ def to_dict(self) -> Dict[str, Any]:
108
+ """Convert session state to a plain dictionary."""
109
+ return asdict(self)
110
+
111
+ def reset(self) -> None:
112
+ """Reset session state while keeping the same session_id."""
113
+ self.current_url = ""
114
+ self.history.clear()
115
+ self.screenshots.clear()
116
+
117
+
118
+ # Module-level session instance
119
+ session = BrowserSession()
120
+
121
+
122
+ # =============================================================================
123
+ # Browser Operations — tool call spec generators
124
+ # =============================================================================
125
+
126
+
127
+ def browser_navigate(url: str, timeout: int = 30) -> Dict[str, Any]:
128
+ """Navigate the browser to a URL.
129
+
130
+ Generates a Puppeteer MCP tool call spec for navigation.
131
+
132
+ Args:
133
+ url: The URL to navigate to.
134
+ timeout: Navigation timeout in seconds (default: 30).
135
+
136
+ Returns:
137
+ A dict with ``success``, ``result`` (the tool call spec), and ``error``.
138
+ If the feature flag is disabled, returns an error response.
139
+ """
140
+ if not _is_enabled():
141
+ return _disabled_response()
142
+
143
+ try:
144
+ if not url or not isinstance(url, str):
145
+ return _error_response("URL must be a non-empty string")
146
+
147
+ # Validate URL has a scheme
148
+ if not url.startswith(("http://", "https://")):
149
+ url = f"https://{url}"
150
+
151
+ spec = {
152
+ "tool": "mcp_puppeteer_puppeteer_navigate",
153
+ "parameters": {
154
+ "url": url,
155
+ },
156
+ }
157
+
158
+ # Track session state
159
+ session.navigate_to(url)
160
+
161
+ return _success_response(spec)
162
+
163
+ except Exception as e:
164
+ return _error_response(str(e))
165
+
166
+
167
+ def browser_click(selector: str) -> Dict[str, Any]:
168
+ """Click an element on the page.
169
+
170
+ Generates a Puppeteer MCP tool call spec for clicking.
171
+
172
+ Args:
173
+ selector: CSS selector for the element to click.
174
+
175
+ Returns:
176
+ A dict with ``success``, ``result`` (the tool call spec), and ``error``.
177
+ """
178
+ if not _is_enabled():
179
+ return _disabled_response()
180
+
181
+ try:
182
+ if not selector or not isinstance(selector, str):
183
+ return _error_response("Selector must be a non-empty string")
184
+
185
+ spec = {
186
+ "tool": "mcp_puppeteer_puppeteer_click",
187
+ "parameters": {
188
+ "selector": selector,
189
+ },
190
+ }
191
+
192
+ return _success_response(spec)
193
+
194
+ except Exception as e:
195
+ return _error_response(str(e))
196
+
197
+
198
+ def browser_type(selector: str, text: str) -> Dict[str, Any]:
199
+ """Type text into an input element.
200
+
201
+ Generates a Puppeteer MCP tool call spec for filling an input field.
202
+
203
+ Args:
204
+ selector: CSS selector for the input field.
205
+ text: The text to type into the field.
206
+
207
+ Returns:
208
+ A dict with ``success``, ``result`` (the tool call spec), and ``error``.
209
+ """
210
+ if not _is_enabled():
211
+ return _disabled_response()
212
+
213
+ try:
214
+ if not selector or not isinstance(selector, str):
215
+ return _error_response("Selector must be a non-empty string")
216
+ if not isinstance(text, str):
217
+ return _error_response("Text must be a string")
218
+
219
+ spec = {
220
+ "tool": "mcp_puppeteer_puppeteer_fill",
221
+ "parameters": {
222
+ "selector": selector,
223
+ "value": text,
224
+ },
225
+ }
226
+
227
+ return _success_response(spec)
228
+
229
+ except Exception as e:
230
+ return _error_response(str(e))
231
+
232
+
233
+ def browser_screenshot(name: str, selector: Optional[str] = None) -> Dict[str, Any]:
234
+ """Take a screenshot of the page or a specific element.
235
+
236
+ Generates a Puppeteer MCP tool call spec for taking a screenshot.
237
+
238
+ Args:
239
+ name: Name for the screenshot.
240
+ selector: Optional CSS selector for a specific element to capture.
241
+
242
+ Returns:
243
+ A dict with ``success``, ``result`` (the tool call spec), and ``error``.
244
+ """
245
+ if not _is_enabled():
246
+ return _disabled_response()
247
+
248
+ try:
249
+ if not name or not isinstance(name, str):
250
+ return _error_response("Name must be a non-empty string")
251
+
252
+ params: Dict[str, Any] = {"name": name}
253
+ if selector:
254
+ params["selector"] = selector
255
+
256
+ spec = {
257
+ "tool": "mcp_puppeteer_puppeteer_screenshot",
258
+ "parameters": params,
259
+ }
260
+
261
+ # Track screenshot
262
+ session.record_screenshot(name)
263
+
264
+ return _success_response(spec)
265
+
266
+ except Exception as e:
267
+ return _error_response(str(e))
268
+
269
+
270
+ def browser_evaluate(script: str) -> Dict[str, Any]:
271
+ """Execute JavaScript in the browser console.
272
+
273
+ Generates a Puppeteer MCP tool call spec for evaluating JavaScript.
274
+
275
+ Args:
276
+ script: JavaScript code to execute.
277
+
278
+ Returns:
279
+ A dict with ``success``, ``result`` (the tool call spec), and ``error``.
280
+ """
281
+ if not _is_enabled():
282
+ return _disabled_response()
283
+
284
+ try:
285
+ if not script or not isinstance(script, str):
286
+ return _error_response("Script must be a non-empty string")
287
+
288
+ spec = {
289
+ "tool": "mcp_puppeteer_puppeteer_evaluate",
290
+ "parameters": {
291
+ "script": script,
292
+ },
293
+ }
294
+
295
+ return _success_response(spec)
296
+
297
+ except Exception as e:
298
+ return _error_response(str(e))
299
+
300
+
301
+ # =============================================================================
302
+ # CLI Interface
303
+ # =============================================================================
304
+
305
+
306
+ def _cli_main():
307
+ """CLI entry point for browser_tool.py."""
308
+ import argparse
309
+
310
+ parser = argparse.ArgumentParser(
311
+ description="OMG Browser Tool — Puppeteer MCP wrapper for browser automation",
312
+ formatter_class=argparse.RawDescriptionHelpFormatter,
313
+ )
314
+ parser.add_argument("--navigate", dest="navigate_url", help="Navigate to URL")
315
+ parser.add_argument("--click", dest="click_selector", help="Click CSS selector")
316
+ parser.add_argument(
317
+ "--type", dest="type_args", nargs=2, metavar=("SELECTOR", "TEXT"),
318
+ help="Type text into selector",
319
+ )
320
+ parser.add_argument(
321
+ "--screenshot", dest="screenshot_name", help="Take screenshot with name",
322
+ )
323
+ parser.add_argument(
324
+ "--screenshot-selector", dest="screenshot_selector", default=None,
325
+ help="Optional CSS selector for screenshot (use with --screenshot)",
326
+ )
327
+ parser.add_argument("--evaluate", dest="eval_script", help="Evaluate JavaScript")
328
+ parser.add_argument(
329
+ "--dry-run", action="store_true",
330
+ help="Print the tool call spec without executing",
331
+ )
332
+ parser.add_argument(
333
+ "--session-info", action="store_true",
334
+ help="Show current session state",
335
+ )
336
+
337
+ args = parser.parse_args()
338
+
339
+ enabled = _is_enabled()
340
+
341
+ # Session info
342
+ if args.session_info:
343
+ print(json.dumps({
344
+ "session": session.to_dict(),
345
+ "enabled": enabled,
346
+ }, indent=2))
347
+ return
348
+
349
+ # Dry-run navigate
350
+ if args.dry_run and args.navigate_url:
351
+ result = browser_navigate(args.navigate_url)
352
+ print(json.dumps({
353
+ "dry_run": True,
354
+ "operation": "navigate",
355
+ "url": args.navigate_url,
356
+ "enabled": enabled,
357
+ "spec": result,
358
+ }, indent=2))
359
+ return
360
+
361
+ # Dry-run click
362
+ if args.dry_run and args.click_selector:
363
+ result = browser_click(args.click_selector)
364
+ print(json.dumps({
365
+ "dry_run": True,
366
+ "operation": "click",
367
+ "selector": args.click_selector,
368
+ "enabled": enabled,
369
+ "spec": result,
370
+ }, indent=2))
371
+ return
372
+
373
+ # Dry-run type
374
+ if args.dry_run and args.type_args:
375
+ result = browser_type(args.type_args[0], args.type_args[1])
376
+ print(json.dumps({
377
+ "dry_run": True,
378
+ "operation": "type",
379
+ "selector": args.type_args[0],
380
+ "text": args.type_args[1],
381
+ "enabled": enabled,
382
+ "spec": result,
383
+ }, indent=2))
384
+ return
385
+
386
+ # Dry-run screenshot
387
+ if args.dry_run and args.screenshot_name:
388
+ result = browser_screenshot(args.screenshot_name, args.screenshot_selector)
389
+ print(json.dumps({
390
+ "dry_run": True,
391
+ "operation": "screenshot",
392
+ "name": args.screenshot_name,
393
+ "selector": args.screenshot_selector,
394
+ "enabled": enabled,
395
+ "spec": result,
396
+ }, indent=2))
397
+ return
398
+
399
+ # Dry-run evaluate
400
+ if args.dry_run and args.eval_script:
401
+ result = browser_evaluate(args.eval_script)
402
+ print(json.dumps({
403
+ "dry_run": True,
404
+ "operation": "evaluate",
405
+ "script": args.eval_script,
406
+ "enabled": enabled,
407
+ "spec": result,
408
+ }, indent=2))
409
+ return
410
+
411
+ # Non-dry-run: check feature flag
412
+ if not enabled:
413
+ print(json.dumps({
414
+ "error": "Browser feature is disabled (OMG_BROWSER_ENABLED=false)",
415
+ }))
416
+ sys.exit(1)
417
+
418
+ # Execute operations
419
+ if args.navigate_url:
420
+ result = browser_navigate(args.navigate_url)
421
+ print(json.dumps(result, indent=2))
422
+ return
423
+
424
+ if args.click_selector:
425
+ result = browser_click(args.click_selector)
426
+ print(json.dumps(result, indent=2))
427
+ return
428
+
429
+ if args.type_args:
430
+ result = browser_type(args.type_args[0], args.type_args[1])
431
+ print(json.dumps(result, indent=2))
432
+ return
433
+
434
+ if args.screenshot_name:
435
+ result = browser_screenshot(args.screenshot_name, args.screenshot_selector)
436
+ print(json.dumps(result, indent=2))
437
+ return
438
+
439
+ if args.eval_script:
440
+ result = browser_evaluate(args.eval_script)
441
+ print(json.dumps(result, indent=2))
442
+ return
443
+
444
+ parser.print_help()
445
+
446
+
447
+ if __name__ == "__main__":
448
+ _cli_main()
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Changelog Generator for OMG
4
+
5
+ Parses conventional commits from git log and generates/updates CHANGELOG.md
6
+ in Keep-a-Changelog format.
7
+
8
+ Feature flag: OMG_CHANGELOG_ENABLED (default: False)
9
+ """
10
+
11
+ import os
12
+ import re
13
+ import sys
14
+ from datetime import date
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ # Lazy imports
18
+ _git_inspector = None
19
+ _get_feature_flag = None
20
+
21
+
22
+ def _ensure_imports():
23
+ """Lazy import git_inspector and feature flag helper."""
24
+ global _git_inspector, _get_feature_flag
25
+ if _git_inspector is None:
26
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
27
+ from tools import git_inspector as _gi
28
+ from hooks._common import get_feature_flag as _gff
29
+ _git_inspector = _gi
30
+ _get_feature_flag = _gff
31
+
32
+
33
+ def _is_enabled() -> bool:
34
+ """Check if changelog feature is enabled."""
35
+ _ensure_imports()
36
+ return _get_feature_flag("changelog", default=False)
37
+
38
+
39
+ # Supported conventional commit types
40
+ CONVENTIONAL_TYPES = frozenset({
41
+ "feat", "fix", "docs", "style", "refactor",
42
+ "test", "chore", "perf", "ci", "build", "sec",
43
+ })
44
+
45
+ # Regex for conventional commit: type(scope): description OR type: description
46
+ _CONVENTIONAL_RE = re.compile(
47
+ r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?!?:\s*(?P<description>.+)$"
48
+ )
49
+
50
+ # Changelog section groupings
51
+ _TYPE_TO_SECTION = {
52
+ "feat": "Added",
53
+ "fix": "Fixed",
54
+ "refactor": "Changed",
55
+ "perf": "Changed",
56
+ "docs": "Changed",
57
+ "style": "Changed",
58
+ "build": "Changed",
59
+ "ci": "Changed",
60
+ "chore": "Other",
61
+ "test": "Other",
62
+ "sec": "Security",
63
+ }
64
+
65
+ _SECTION_ORDER = ["Added", "Fixed", "Changed", "Deprecated", "Removed", "Security", "Other"]
66
+
67
+
68
+ def parse_commit_log(cwd: str = ".") -> List[Dict[str, Any]]:
69
+ """Parse git log for conventional commits.
70
+
71
+ Args:
72
+ cwd: Working directory (default: current directory)
73
+
74
+ Returns:
75
+ List of dicts with keys: type, scope, description, hash, author, date, breaking
76
+ Returns empty list if OMG_CHANGELOG_ENABLED is False or no conventional commits found.
77
+ """
78
+ if not _is_enabled():
79
+ return []
80
+
81
+ _ensure_imports()
82
+ raw_commits = _git_inspector.git_log(cwd, n=100)
83
+
84
+ if not raw_commits:
85
+ return []
86
+
87
+ parsed = []
88
+ for commit in raw_commits:
89
+ subject = commit.get("subject", "").strip()
90
+ if not subject:
91
+ continue
92
+
93
+ match = _CONVENTIONAL_RE.match(subject)
94
+ if not match:
95
+ continue
96
+
97
+ commit_type = match.group("type").lower()
98
+ if commit_type not in CONVENTIONAL_TYPES:
99
+ continue
100
+
101
+ # Detect breaking changes
102
+ breaking = "BREAKING CHANGE" in subject or "!" in subject.split(":")[0]
103
+
104
+ parsed.append({
105
+ "type": commit_type,
106
+ "scope": match.group("scope") or "",
107
+ "description": match.group("description").strip(),
108
+ "hash": commit.get("hash", "")[:7],
109
+ "author": commit.get("author", ""),
110
+ "date": commit.get("date", ""),
111
+ "breaking": breaking,
112
+ })
113
+
114
+ return parsed
115
+
116
+
117
+ def generate_changelog_entry(
118
+ commits: List[Dict[str, Any]],
119
+ version: str = "Unreleased",
120
+ ) -> str:
121
+ """Format a Keep-a-Changelog section from parsed commits.
122
+
123
+ Args:
124
+ commits: List of parsed commit dicts from parse_commit_log()
125
+ version: Version label (default: "Unreleased")
126
+
127
+ Returns:
128
+ Formatted changelog section string.
129
+ Returns empty string if commits list is empty.
130
+ """
131
+ if not commits:
132
+ return ""
133
+
134
+ today = date.today().isoformat()
135
+ header = f"## [{version}] - {today}"
136
+
137
+ # Group commits by section
138
+ sections: Dict[str, List[str]] = {s: [] for s in _SECTION_ORDER}
139
+
140
+ for commit in commits:
141
+ section = _TYPE_TO_SECTION.get(commit["type"], "Other")
142
+ scope = commit.get("scope", "")
143
+ description = commit["description"]
144
+ short_hash = commit.get("hash", "")
145
+
146
+ if scope:
147
+ entry = f"- **{scope}**: {description}"
148
+ else:
149
+ entry = f"- {description}"
150
+
151
+ if short_hash:
152
+ entry += f" (#{short_hash})"
153
+
154
+ if commit.get("breaking"):
155
+ entry += " **[BREAKING]**"
156
+
157
+ sections[section].append(entry)
158
+
159
+ lines = [header, ""]
160
+
161
+ for section_name in _SECTION_ORDER:
162
+ entries = sections[section_name]
163
+ if not entries:
164
+ continue
165
+ lines.append(f"### {section_name}")
166
+ lines.extend(entries)
167
+ lines.append("")
168
+
169
+ # Strip trailing blank line
170
+ while lines and lines[-1] == "":
171
+ lines.pop()
172
+
173
+ return "\n".join(lines)
174
+
175
+
176
+ def update_changelog(cwd: str = ".", version: str = None) -> bool:
177
+ """Parse commits and prepend a new entry to CHANGELOG.md.
178
+
179
+ Reads existing CHANGELOG.md (or creates a new one). Inserts the new
180
+ entry after the top-level `# Changelog` header without overwriting
181
+ existing sections.
182
+
183
+ Args:
184
+ cwd: Working directory (default: current directory)
185
+ version: Version label (default: "Unreleased")
186
+
187
+ Returns:
188
+ True on success, False on failure or if no commits to add.
189
+ """
190
+ commits = parse_commit_log(cwd)
191
+ if not commits:
192
+ return False
193
+
194
+ entry = generate_changelog_entry(commits, version=version or "Unreleased")
195
+ if not entry:
196
+ return False
197
+
198
+ changelog_path = os.path.join(cwd, "CHANGELOG.md")
199
+
200
+ try:
201
+ if os.path.exists(changelog_path):
202
+ with open(changelog_path, "r", encoding="utf-8") as f:
203
+ existing = f.read()
204
+ else:
205
+ existing = "# Changelog\n\nAll notable changes to this project will be documented here.\n"
206
+
207
+ # Find insertion point: after the first `# Changelog` header line
208
+ lines = existing.splitlines(keepends=True)
209
+ insert_idx = 0
210
+ for i, line in enumerate(lines):
211
+ if line.startswith("# "):
212
+ insert_idx = i + 1
213
+ # Skip blank lines immediately after the header
214
+ while insert_idx < len(lines) and lines[insert_idx].strip() == "":
215
+ insert_idx += 1
216
+ break
217
+
218
+ new_block = entry + "\n\n"
219
+ lines.insert(insert_idx, new_block)
220
+ new_content = "".join(lines)
221
+
222
+ with open(changelog_path, "w", encoding="utf-8") as f:
223
+ f.write(new_content)
224
+
225
+ return True
226
+
227
+ except OSError:
228
+ return False
229
+
230
+
231
+ def _dry_run(cwd: str = ".", version: str = None) -> str:
232
+ """Return the changelog entry that would be written, without modifying any file."""
233
+ commits = parse_commit_log(cwd)
234
+ if not commits:
235
+ return "[OMG Changelog] No conventional commits found (or feature flag disabled)."
236
+ return generate_changelog_entry(commits, version=version or "Unreleased")
237
+
238
+
239
+ def main():
240
+ """CLI entry point."""
241
+ import argparse
242
+
243
+ parser = argparse.ArgumentParser(
244
+ description="OMG Changelog Generator — parses conventional commits and updates CHANGELOG.md"
245
+ )
246
+ parser.add_argument("--cwd", default=".", help="Working directory (default: .)")
247
+ parser.add_argument("--version", default=None, help="Version label (default: Unreleased)")
248
+ parser.add_argument(
249
+ "--dry-run",
250
+ action="store_true",
251
+ help="Print the changelog entry without writing to file",
252
+ )
253
+ args = parser.parse_args()
254
+
255
+ if args.dry_run:
256
+ print(_dry_run(cwd=args.cwd, version=args.version))
257
+ return
258
+
259
+ success = update_changelog(cwd=args.cwd, version=args.version)
260
+ if success:
261
+ print("[OMG Changelog] CHANGELOG.md updated successfully.")
262
+ else:
263
+ print("[OMG Changelog] No changes written (no commits or feature flag disabled).")
264
+ sys.exit(1)
265
+
266
+
267
+ if __name__ == "__main__":
268
+ main()