@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.
- package/.claude-plugin/marketplace.json +8 -8
- package/.claude-plugin/plugin.json +5 -4
- package/.claude-plugin/scripts/uninstall.sh +74 -3
- package/.claude-plugin/scripts/update.sh +78 -3
- package/.coveragerc +26 -0
- package/.mcp.json +4 -4
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +62 -0
- package/OMG-setup.sh +1201 -355
- package/README.md +77 -56
- package/SECURITY.md +25 -0
- package/agents/__init__.py +1 -0
- package/agents/model_roles.py +196 -0
- package/agents/omg-architect-mode.md +3 -5
- package/agents/omg-backend-engineer.md +3 -5
- package/agents/omg-database-engineer.md +3 -5
- package/agents/omg-frontend-designer.md +4 -5
- package/agents/omg-implement-mode.md +4 -5
- package/agents/omg-infra-engineer.md +3 -5
- package/agents/omg-research-mode.md +4 -6
- package/agents/omg-security-auditor.md +3 -5
- package/agents/omg-testing-engineer.md +3 -5
- package/build/lib/yaml.py +321 -0
- package/commands/OMG:ai-commit.md +101 -14
- package/commands/OMG:arch.md +302 -19
- package/commands/OMG:ccg.md +12 -7
- package/commands/OMG:compat.md +25 -17
- package/commands/OMG:cost.md +173 -13
- package/commands/OMG:crazy.md +1 -1
- package/commands/OMG:create-agent.md +170 -20
- package/commands/OMG:deps.md +235 -17
- package/commands/OMG:domain-init.md +1 -1
- package/commands/OMG:escalate.md +41 -12
- package/commands/OMG:health-check.md +37 -13
- package/commands/OMG:init.md +122 -14
- package/commands/OMG:project-init.md +1 -1
- package/commands/OMG:session-branch.md +76 -9
- package/commands/OMG:session-fork.md +42 -5
- package/commands/OMG:session-merge.md +124 -8
- package/commands/OMG:setup.md +69 -12
- package/commands/OMG:stats.md +215 -14
- package/commands/OMG:teams.md +19 -10
- package/config/lsp_languages.yaml +8 -0
- package/hooks/__init__.py +0 -0
- package/hooks/_agent_registry.py +423 -0
- package/hooks/_analytics.py +291 -0
- package/hooks/_budget.py +31 -0
- package/hooks/_common.py +569 -0
- package/hooks/_compression_optimizer.py +119 -0
- package/hooks/_cost_ledger.py +176 -0
- package/hooks/_learnings.py +126 -0
- package/hooks/_memory.py +103 -0
- package/hooks/_protected_context.py +150 -0
- package/hooks/_token_counter.py +221 -0
- package/hooks/branch_manager.py +236 -0
- package/hooks/budget_governor.py +232 -0
- package/hooks/circuit-breaker.py +270 -0
- package/hooks/compression_feedback.py +254 -0
- package/hooks/config-guard.py +216 -0
- package/hooks/context_pressure.py +53 -0
- package/hooks/credential_store.py +1020 -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 +505 -0
- package/hooks/post-tool-failure.py +19 -0
- package/hooks/post-write.py +219 -0
- package/hooks/post_write.py +46 -0
- package/hooks/pre-compact.py +398 -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/query.py +512 -0
- package/hooks/secret-guard.py +61 -0
- package/hooks/secret_audit.py +144 -0
- package/hooks/session-end-capture.py +137 -0
- package/hooks/session-start.py +277 -0
- package/hooks/setup_wizard.py +582 -0
- package/hooks/shadow_manager.py +297 -0
- package/hooks/state_migration.py +225 -0
- package/hooks/stop-gate.py +7 -0
- package/hooks/stop_dispatcher.py +945 -0
- package/hooks/test-validator.py +361 -0
- package/hooks/test_generator_hook.py +123 -0
- package/hooks/todo-state-tracker.py +114 -0
- package/hooks/tool-ledger.py +149 -0
- package/hooks/trust_review.py +585 -0
- package/hud/omg-hud.mjs +31 -1
- package/lab/__init__.py +1 -0
- package/lab/pipeline.py +75 -0
- package/lab/policies.py +52 -0
- package/package.json +7 -18
- package/plugins/README.md +33 -61
- package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
- package/plugins/advanced/commands/OMG:learn.md +1 -1
- package/plugins/advanced/commands/OMG:security-review.md +3 -3
- package/plugins/advanced/commands/OMG:ship.md +1 -1
- package/plugins/advanced/plugin.json +1 -1
- package/plugins/core/plugin.json +8 -3
- package/plugins/dephealth/__init__.py +0 -0
- package/plugins/dephealth/cve_scanner.py +188 -0
- package/plugins/dephealth/license_checker.py +135 -0
- package/plugins/dephealth/manifest_detector.py +423 -0
- package/plugins/dephealth/vuln_analyzer.py +169 -0
- package/plugins/testgen/__init__.py +0 -0
- package/plugins/testgen/codamosa_engine.py +402 -0
- package/plugins/testgen/edge_case_synthesizer.py +184 -0
- package/plugins/testgen/framework_detector.py +271 -0
- package/plugins/testgen/skeleton_generator.py +219 -0
- package/plugins/viz/__init__.py +0 -0
- package/plugins/viz/ast_parser.py +139 -0
- package/plugins/viz/diagram_generator.py +192 -0
- package/plugins/viz/graph_builder.py +444 -0
- package/plugins/viz/native_parsers.py +259 -0
- package/plugins/viz/regex_parser.py +112 -0
- package/pyproject.toml +81 -0
- package/rules/contextual/write-verify.md +2 -2
- package/rules/core/00-truth.md +1 -1
- package/rules/core/01-surgical.md +1 -1
- package/rules/core/02-circuit-breaker.md +2 -2
- package/rules/core/03-ensemble.md +3 -3
- package/rules/core/04-testing.md +3 -3
- 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/adoption.py +212 -0
- package/runtime/business_workflow.py +220 -0
- package/runtime/cli_provider.py +85 -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/mcp_config_writers.py +115 -0
- package/runtime/mcp_lifecycle.py +153 -0
- package/runtime/mcp_memory_server.py +135 -0
- package/runtime/memory_parsers/__init__.py +0 -0
- package/runtime/memory_parsers/chatgpt_parser.py +257 -0
- package/runtime/memory_parsers/claude_import.py +107 -0
- package/runtime/memory_parsers/export.py +97 -0
- package/runtime/memory_parsers/gemini_import.py +91 -0
- package/runtime/memory_parsers/kimi_import.py +91 -0
- package/runtime/memory_store.py +215 -0
- package/runtime/omc_compat.py +7 -0
- package/runtime/providers/__init__.py +0 -0
- package/runtime/providers/codex_provider.py +112 -0
- package/runtime/providers/gemini_provider.py +128 -0
- package/runtime/providers/kimi_provider.py +151 -0
- package/runtime/providers/opencode_provider.py +144 -0
- package/runtime/subagent_dispatcher.py +362 -0
- package/runtime/team_router.py +1167 -0
- package/runtime/tmux_session_manager.py +169 -0
- package/scripts/check-omg-compat-contract-snapshot.py +137 -0
- package/scripts/check-omg-contract-snapshot.py +12 -0
- package/scripts/check-omg-public-ready.py +193 -0
- package/scripts/check-omg-standalone-clean.py +103 -0
- package/scripts/legacy_to_omg_migrate.py +29 -0
- package/scripts/migrate-legacy.py +464 -0
- package/scripts/omc_to_omg_migrate.py +12 -0
- package/scripts/omg.py +492 -0
- package/scripts/settings-merge.py +283 -0
- package/scripts/verify-standalone.sh +8 -4
- package/settings.json +126 -29
- package/templates/profile.yaml +1 -1
- 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 +347 -0
- package/tools/commit_splitter.py +746 -0
- package/tools/config_discovery.py +151 -0
- package/tools/config_merger.py +449 -0
- package/tools/dashboard_generator.py +300 -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/pr_generator.py +404 -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
- package/yaml.py +321 -0
- package/.claude-plugin/scripts/install.sh +0 -9
- package/bun.lock +0 -23
- package/bunfig.toml +0 -3
- package/hooks/_budget.ts +0 -1
- package/hooks/_common.ts +0 -63
- package/hooks/circuit-breaker.ts +0 -101
- package/hooks/config-guard.ts +0 -4
- package/hooks/firewall.ts +0 -20
- package/hooks/policy_engine.ts +0 -156
- package/hooks/post-tool-failure.ts +0 -22
- package/hooks/post-write.ts +0 -4
- package/hooks/pre-tool-inject.ts +0 -4
- package/hooks/prompt-enhancer.ts +0 -46
- package/hooks/quality-runner.ts +0 -24
- package/hooks/secret-guard.ts +0 -4
- package/hooks/session-end-capture.ts +0 -19
- package/hooks/session-start.ts +0 -19
- package/hooks/shadow_manager.ts +0 -81
- package/hooks/stop-gate.ts +0 -22
- package/hooks/stop_dispatcher.ts +0 -147
- package/hooks/test-generator-hook.ts +0 -4
- package/hooks/tool-ledger.ts +0 -27
- package/hooks/trust_review.ts +0 -175
- package/lab/pipeline.ts +0 -75
- package/lab/policies.ts +0 -68
- package/runtime/common.ts +0 -111
- package/runtime/compat.ts +0 -174
- package/runtime/dispatcher.ts +0 -25
- package/runtime/ecosystem.ts +0 -186
- package/runtime/provider_bootstrap.ts +0 -99
- package/runtime/provider_smoke.ts +0 -34
- package/runtime/release_readiness.ts +0 -186
- package/runtime/team_router.ts +0 -144
- package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
- package/scripts/check-omg-standalone-clean.ts +0 -12
- package/scripts/check-runtime-clean.ts +0 -94
- package/scripts/omg.ts +0 -352
- package/scripts/settings-merge.ts +0 -93
- package/tools/commit_splitter.ts +0 -23
- package/tools/git_inspector.ts +0 -18
- package/tools/session_snapshot.ts +0 -47
- package/trac3er-oh-my-god-2.0.0.tgz +0 -0
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
IPython Kernel Integration for OMG
|
|
4
|
+
|
|
5
|
+
Provides persistent REPL sessions with IPython kernel support (optional)
|
|
6
|
+
and stdlib fallback via code.InteractiveConsole.
|
|
7
|
+
|
|
8
|
+
Feature flag: OMG_PYTHON_REPL_ENABLED (default: False)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import code
|
|
13
|
+
import contextlib
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import traceback
|
|
19
|
+
import uuid
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from typing import Any, Dict, Generator, List, Optional, Union
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --- Lazy imports for hooks/_common.py ---
|
|
25
|
+
|
|
26
|
+
_get_feature_flag = None
|
|
27
|
+
_atomic_json_write = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ensure_imports():
|
|
31
|
+
"""Lazy import feature flag and atomic write from hooks/_common.py."""
|
|
32
|
+
global _get_feature_flag, _atomic_json_write
|
|
33
|
+
if _get_feature_flag is not None:
|
|
34
|
+
return
|
|
35
|
+
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
36
|
+
if repo_root not in sys.path:
|
|
37
|
+
sys.path.insert(0, repo_root)
|
|
38
|
+
try:
|
|
39
|
+
from hooks._common import get_feature_flag as _gff
|
|
40
|
+
from hooks._common import atomic_json_write as _ajw
|
|
41
|
+
_get_feature_flag = _gff
|
|
42
|
+
_atomic_json_write = _ajw
|
|
43
|
+
except ImportError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- Optional jupyter_client ---
|
|
48
|
+
|
|
49
|
+
_jupyter_client = None
|
|
50
|
+
_HAS_JUPYTER: Optional[bool] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_jupyter() -> bool:
|
|
54
|
+
"""Check if jupyter_client is available (cached after first check)."""
|
|
55
|
+
global _HAS_JUPYTER, _jupyter_client
|
|
56
|
+
if _HAS_JUPYTER is None:
|
|
57
|
+
try:
|
|
58
|
+
import jupyter_client as _jc
|
|
59
|
+
_jupyter_client = _jc
|
|
60
|
+
_HAS_JUPYTER = True
|
|
61
|
+
except ImportError:
|
|
62
|
+
_HAS_JUPYTER = False
|
|
63
|
+
return _HAS_JUPYTER
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- Feature flag ---
|
|
67
|
+
|
|
68
|
+
def _is_enabled() -> bool:
|
|
69
|
+
"""Check if Python REPL feature is enabled."""
|
|
70
|
+
# Fast path: check env var directly
|
|
71
|
+
env_val = os.environ.get("OMG_PYTHON_REPL_ENABLED", "").lower()
|
|
72
|
+
if env_val in ("0", "false", "no"):
|
|
73
|
+
return False
|
|
74
|
+
if env_val in ("1", "true", "yes"):
|
|
75
|
+
return True
|
|
76
|
+
# Fallback to hooks/_common.get_feature_flag
|
|
77
|
+
_ensure_imports()
|
|
78
|
+
if _get_feature_flag is not None:
|
|
79
|
+
return _get_feature_flag("PYTHON_REPL", default=False)
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_sandbox_flag() -> bool:
|
|
84
|
+
"""Check if sandbox mode is enabled for the REPL."""
|
|
85
|
+
env_val = os.environ.get("OMG_REPL_SANDBOX_ENABLED", "").lower()
|
|
86
|
+
if env_val in ("0", "false", "no"):
|
|
87
|
+
return False
|
|
88
|
+
if env_val in ("1", "true", "yes"):
|
|
89
|
+
return True
|
|
90
|
+
_ensure_imports()
|
|
91
|
+
if _get_feature_flag is not None:
|
|
92
|
+
return _get_feature_flag("REPL_SANDBOX", default=False)
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _get_helpers_flag() -> bool:
|
|
97
|
+
"""Check if REPL prelude helpers are enabled."""
|
|
98
|
+
env_val = os.environ.get("OMG_REPL_HELPERS_ENABLED", "").lower()
|
|
99
|
+
if env_val in ("0", "false", "no"):
|
|
100
|
+
return False
|
|
101
|
+
if env_val in ("1", "true", "yes"):
|
|
102
|
+
return True
|
|
103
|
+
_ensure_imports()
|
|
104
|
+
if _get_feature_flag is not None:
|
|
105
|
+
return _get_feature_flag("REPL_HELPERS", default=False)
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _build_prelude_namespace() -> dict:
|
|
110
|
+
"""Build the prelude namespace with helper functions for REPL sessions.
|
|
111
|
+
|
|
112
|
+
Returns a dict of helper functions injected into every session when
|
|
113
|
+
OMG_REPL_HELPERS_ENABLED=true. All helpers use stdlib only and handle
|
|
114
|
+
exceptions gracefully.
|
|
115
|
+
"""
|
|
116
|
+
import re as _re
|
|
117
|
+
|
|
118
|
+
def read_file(path: str) -> str:
|
|
119
|
+
"""Read file content. Returns empty string on error."""
|
|
120
|
+
try:
|
|
121
|
+
with open(path, "r") as f:
|
|
122
|
+
return f.read()
|
|
123
|
+
except Exception:
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
def write_file(path: str, content: str) -> bool:
|
|
127
|
+
"""Write content to file. Blocked in sandbox mode. Returns False on error."""
|
|
128
|
+
if _get_sandbox_flag():
|
|
129
|
+
return False
|
|
130
|
+
try:
|
|
131
|
+
with open(path, "w") as f:
|
|
132
|
+
f.write(content)
|
|
133
|
+
return True
|
|
134
|
+
except Exception:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
def lines(path: str) -> list:
|
|
138
|
+
"""Read file lines as list. Returns empty list on error."""
|
|
139
|
+
try:
|
|
140
|
+
with open(path, "r") as f:
|
|
141
|
+
return f.read().splitlines()
|
|
142
|
+
except Exception:
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
def search_code(pattern: str, path: str = ".", ext=None) -> list:
|
|
146
|
+
"""Grep-like search across files. Returns list of {file, line, match} dicts."""
|
|
147
|
+
results = []
|
|
148
|
+
try:
|
|
149
|
+
compiled = _re.compile(pattern)
|
|
150
|
+
for root, _dirs, files in os.walk(path):
|
|
151
|
+
for fname in files:
|
|
152
|
+
if ext is not None and not fname.endswith(ext):
|
|
153
|
+
continue
|
|
154
|
+
fpath = os.path.join(root, fname)
|
|
155
|
+
try:
|
|
156
|
+
with open(fpath, "r", errors="ignore") as f:
|
|
157
|
+
for lineno, line_text in enumerate(f, 1):
|
|
158
|
+
if compiled.search(line_text):
|
|
159
|
+
results.append({
|
|
160
|
+
"file": fpath,
|
|
161
|
+
"line": lineno,
|
|
162
|
+
"match": line_text.rstrip(),
|
|
163
|
+
})
|
|
164
|
+
except Exception:
|
|
165
|
+
continue
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
return results
|
|
169
|
+
|
|
170
|
+
def grep(pattern: str, text: str) -> list:
|
|
171
|
+
"""Regex grep on a string. Returns matching lines."""
|
|
172
|
+
try:
|
|
173
|
+
compiled = _re.compile(pattern)
|
|
174
|
+
return [line for line in text.splitlines() if compiled.search(line)]
|
|
175
|
+
except Exception:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
def insert_at(lines_list: list, index: int, new_line: str) -> list:
|
|
179
|
+
"""Insert a line at index. Returns new list."""
|
|
180
|
+
try:
|
|
181
|
+
result = list(lines_list)
|
|
182
|
+
result.insert(index, new_line)
|
|
183
|
+
return result
|
|
184
|
+
except Exception:
|
|
185
|
+
return list(lines_list)
|
|
186
|
+
|
|
187
|
+
def delete_lines(lines_list: list, start: int, end: int) -> list:
|
|
188
|
+
"""Delete lines from start to end (exclusive). Returns new list."""
|
|
189
|
+
try:
|
|
190
|
+
result = list(lines_list)
|
|
191
|
+
del result[start:end]
|
|
192
|
+
return result
|
|
193
|
+
except Exception:
|
|
194
|
+
return list(lines_list)
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"read_file": read_file,
|
|
198
|
+
"write_file": write_file,
|
|
199
|
+
"lines": lines,
|
|
200
|
+
"search_code": search_code,
|
|
201
|
+
"grep": grep,
|
|
202
|
+
"insert_at": insert_at,
|
|
203
|
+
"delete_lines": delete_lines,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_DISABLED_MSG = "Python REPL feature is disabled. Set OMG_PYTHON_REPL_ENABLED=true"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --- Session storage ---
|
|
210
|
+
|
|
211
|
+
_sessions: Dict[str, Dict[str, Any]] = {}
|
|
212
|
+
_STATE_DIR = ".omg/state/repl_sessions"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _now_iso() -> str:
|
|
216
|
+
"""Current UTC time as ISO-8601 string."""
|
|
217
|
+
return datetime.now(timezone.utc).isoformat()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _persist_session(session_id: str) -> None:
|
|
221
|
+
"""Persist session metadata to disk (best-effort)."""
|
|
222
|
+
if session_id not in _sessions:
|
|
223
|
+
return
|
|
224
|
+
_ensure_imports()
|
|
225
|
+
if _atomic_json_write is None:
|
|
226
|
+
return
|
|
227
|
+
session = _sessions[session_id]
|
|
228
|
+
meta = {
|
|
229
|
+
"session_id": session["session_id"],
|
|
230
|
+
"created_at": session["created_at"],
|
|
231
|
+
"last_used": session["last_used"],
|
|
232
|
+
"exec_count": session["exec_count"],
|
|
233
|
+
"backend": session.get("backend", "stdlib"),
|
|
234
|
+
}
|
|
235
|
+
path = os.path.join(_STATE_DIR, f"{session_id}.json")
|
|
236
|
+
try:
|
|
237
|
+
_atomic_json_write(path, meta)
|
|
238
|
+
except Exception:
|
|
239
|
+
pass # best-effort
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _session_info(session: Dict[str, Any]) -> Dict[str, Any]:
|
|
243
|
+
"""Extract public session info (no internal _backend key)."""
|
|
244
|
+
return {
|
|
245
|
+
"session_id": session["session_id"],
|
|
246
|
+
"created_at": session["created_at"],
|
|
247
|
+
"last_used": session["last_used"],
|
|
248
|
+
"exec_count": session["exec_count"],
|
|
249
|
+
"backend": session.get("backend", "stdlib"),
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- IPython Kernel Backend ---
|
|
254
|
+
|
|
255
|
+
class _IPythonSession:
|
|
256
|
+
"""Wraps a jupyter_client kernel for code execution."""
|
|
257
|
+
|
|
258
|
+
def __init__(self):
|
|
259
|
+
km, kc = _jupyter_client.manager.start_new_kernel(kernel_name="python3")
|
|
260
|
+
self.kernel_manager = km
|
|
261
|
+
self.kernel_client = kc
|
|
262
|
+
self.kernel_client.start_channels()
|
|
263
|
+
self.kernel_client.wait_for_ready(timeout=30)
|
|
264
|
+
|
|
265
|
+
def execute(self, code_str: str) -> Dict[str, Any]:
|
|
266
|
+
"""Execute code on the IPython kernel and collect output."""
|
|
267
|
+
msg_id = self.kernel_client.execute(code_str)
|
|
268
|
+
stdout_parts: List[str] = []
|
|
269
|
+
stderr_parts: List[str] = []
|
|
270
|
+
result = None
|
|
271
|
+
error = None
|
|
272
|
+
|
|
273
|
+
while True:
|
|
274
|
+
try:
|
|
275
|
+
msg = self.kernel_client.get_iopub_msg(timeout=30)
|
|
276
|
+
except Exception:
|
|
277
|
+
break
|
|
278
|
+
if msg["parent_header"].get("msg_id") != msg_id:
|
|
279
|
+
continue
|
|
280
|
+
msg_type = msg["msg_type"]
|
|
281
|
+
content = msg["content"]
|
|
282
|
+
if msg_type == "stream":
|
|
283
|
+
if content["name"] == "stdout":
|
|
284
|
+
stdout_parts.append(content["text"])
|
|
285
|
+
elif content["name"] == "stderr":
|
|
286
|
+
stderr_parts.append(content["text"])
|
|
287
|
+
elif msg_type in ("execute_result", "display_data"):
|
|
288
|
+
result = content["data"].get("text/plain", "")
|
|
289
|
+
elif msg_type == "error":
|
|
290
|
+
tb = content.get("traceback", [content.get("evalue", "")])
|
|
291
|
+
error = "\n".join(tb)
|
|
292
|
+
elif msg_type == "status" and content.get("execution_state") == "idle":
|
|
293
|
+
break
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
"stdout": "".join(stdout_parts),
|
|
297
|
+
"stderr": "".join(stderr_parts),
|
|
298
|
+
"result": result,
|
|
299
|
+
"error": error,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
def stream_execute(self, code_str: str) -> Generator[Dict[str, str], None, None]:
|
|
303
|
+
"""Execute code on the kernel and yield output chunks."""
|
|
304
|
+
msg_id = self.kernel_client.execute(code_str)
|
|
305
|
+
while True:
|
|
306
|
+
try:
|
|
307
|
+
msg = self.kernel_client.get_iopub_msg(timeout=30)
|
|
308
|
+
except Exception:
|
|
309
|
+
break
|
|
310
|
+
if msg["parent_header"].get("msg_id") != msg_id:
|
|
311
|
+
continue
|
|
312
|
+
msg_type = msg["msg_type"]
|
|
313
|
+
content = msg["content"]
|
|
314
|
+
if msg_type == "stream":
|
|
315
|
+
yield {"type": content["name"], "data": content["text"]}
|
|
316
|
+
elif msg_type in ("execute_result", "display_data"):
|
|
317
|
+
yield {"type": "result", "data": content["data"].get("text/plain", "")}
|
|
318
|
+
elif msg_type == "error":
|
|
319
|
+
tb = content.get("traceback", [content.get("evalue", "")])
|
|
320
|
+
yield {"type": "error", "data": "\n".join(tb)}
|
|
321
|
+
elif msg_type == "status" and content.get("execution_state") == "idle":
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
def close(self):
|
|
325
|
+
"""Shutdown kernel and cleanup."""
|
|
326
|
+
try:
|
|
327
|
+
self.kernel_client.stop_channels()
|
|
328
|
+
except Exception:
|
|
329
|
+
pass
|
|
330
|
+
try:
|
|
331
|
+
self.kernel_manager.shutdown_kernel(now=True)
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# --- Stdlib Fallback Backend ---
|
|
337
|
+
|
|
338
|
+
class _StdlibSession:
|
|
339
|
+
"""Uses code.InteractiveConsole with stdout/stderr capture."""
|
|
340
|
+
|
|
341
|
+
def __init__(self):
|
|
342
|
+
self.namespace: Dict[str, Any] = {"__builtins__": __builtins__}
|
|
343
|
+
self._console = code.InteractiveConsole(locals=self.namespace)
|
|
344
|
+
|
|
345
|
+
def execute(self, code_str: str) -> Dict[str, Any]:
|
|
346
|
+
"""Execute code with stdout/stderr capture via contextlib."""
|
|
347
|
+
stdout_buf = io.StringIO()
|
|
348
|
+
stderr_buf = io.StringIO()
|
|
349
|
+
result = None
|
|
350
|
+
error = None
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
with contextlib.redirect_stdout(stdout_buf), \
|
|
354
|
+
contextlib.redirect_stderr(stderr_buf):
|
|
355
|
+
# Try to evaluate as single expression first
|
|
356
|
+
try:
|
|
357
|
+
tree = ast.parse(code_str, mode="eval")
|
|
358
|
+
compiled = compile(tree, "<repl>", "eval")
|
|
359
|
+
result_val = eval(compiled, self.namespace) # noqa: S307
|
|
360
|
+
if result_val is not None:
|
|
361
|
+
result = repr(result_val)
|
|
362
|
+
except SyntaxError:
|
|
363
|
+
# Fall back to exec for statements
|
|
364
|
+
tree = ast.parse(code_str, mode="exec")
|
|
365
|
+
compiled = compile(tree, "<repl>", "exec")
|
|
366
|
+
exec(compiled, self.namespace) # noqa: S102
|
|
367
|
+
except Exception:
|
|
368
|
+
error = traceback.format_exc()
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
"stdout": stdout_buf.getvalue(),
|
|
372
|
+
"stderr": stderr_buf.getvalue(),
|
|
373
|
+
"result": result,
|
|
374
|
+
"error": error,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
def stream_execute(self, code_str: str) -> Generator[Dict[str, str], None, None]:
|
|
378
|
+
"""Execute code and yield output chunks.
|
|
379
|
+
|
|
380
|
+
Note: stdlib backend doesn't support true streaming —
|
|
381
|
+
executes fully then yields collected output.
|
|
382
|
+
"""
|
|
383
|
+
output = self.execute(code_str)
|
|
384
|
+
if output["stdout"]:
|
|
385
|
+
yield {"type": "stdout", "data": output["stdout"]}
|
|
386
|
+
if output["stderr"]:
|
|
387
|
+
yield {"type": "stderr", "data": output["stderr"]}
|
|
388
|
+
if output["result"] is not None:
|
|
389
|
+
yield {"type": "result", "data": output["result"]}
|
|
390
|
+
if output["error"]:
|
|
391
|
+
yield {"type": "error", "data": output["error"]}
|
|
392
|
+
|
|
393
|
+
def close(self):
|
|
394
|
+
"""Cleanup namespace."""
|
|
395
|
+
self.namespace.clear()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# --- Public API ---
|
|
399
|
+
|
|
400
|
+
def start_repl_session(session_id: Optional[str] = None) -> Dict[str, Any]:
|
|
401
|
+
"""Start or resume a persistent REPL session.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
session_id: Optional ID to resume an existing session.
|
|
405
|
+
If None, creates a new session with a UUID.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Session info dict: {session_id, created_at, last_used, exec_count, backend}
|
|
409
|
+
or {"error": "..."} if feature flag is disabled.
|
|
410
|
+
"""
|
|
411
|
+
if not _is_enabled():
|
|
412
|
+
return {"error": _DISABLED_MSG}
|
|
413
|
+
|
|
414
|
+
# Resume existing session
|
|
415
|
+
if session_id and session_id in _sessions:
|
|
416
|
+
session = _sessions[session_id]
|
|
417
|
+
session["last_used"] = _now_iso()
|
|
418
|
+
_persist_session(session_id)
|
|
419
|
+
return _session_info(session)
|
|
420
|
+
|
|
421
|
+
# Create new session
|
|
422
|
+
new_id = session_id or str(uuid.uuid4())
|
|
423
|
+
|
|
424
|
+
# Try IPython kernel first, fall back to stdlib
|
|
425
|
+
_check_jupyter()
|
|
426
|
+
backend_name = "stdlib"
|
|
427
|
+
backend = None
|
|
428
|
+
|
|
429
|
+
if _HAS_JUPYTER:
|
|
430
|
+
try:
|
|
431
|
+
backend = _IPythonSession()
|
|
432
|
+
backend_name = "ipython"
|
|
433
|
+
except Exception:
|
|
434
|
+
backend = _StdlibSession()
|
|
435
|
+
else:
|
|
436
|
+
backend = _StdlibSession()
|
|
437
|
+
|
|
438
|
+
now = _now_iso()
|
|
439
|
+
_sessions[new_id] = {
|
|
440
|
+
"session_id": new_id,
|
|
441
|
+
"created_at": now,
|
|
442
|
+
"last_used": now,
|
|
443
|
+
"exec_count": 0,
|
|
444
|
+
"backend": backend_name,
|
|
445
|
+
"_backend": backend,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
# Inject prelude helpers if enabled
|
|
449
|
+
if _get_helpers_flag():
|
|
450
|
+
prelude = _build_prelude_namespace()
|
|
451
|
+
if hasattr(backend, "namespace"):
|
|
452
|
+
backend.namespace.update(prelude)
|
|
453
|
+
_persist_session(new_id)
|
|
454
|
+
|
|
455
|
+
return _session_info(_sessions[new_id])
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def execute_code(session_id: str, code_str: str) -> Dict[str, Any]:
|
|
459
|
+
"""Execute code in a session.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
session_id: Session ID from start_repl_session()
|
|
463
|
+
code_str: Python code to execute
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
{stdout, stderr, result, error, exec_count}
|
|
467
|
+
or {"error": "..."} if feature flag is disabled or session not found.
|
|
468
|
+
"""
|
|
469
|
+
if not _is_enabled():
|
|
470
|
+
return {"error": _DISABLED_MSG}
|
|
471
|
+
|
|
472
|
+
if session_id not in _sessions:
|
|
473
|
+
return {"error": f"Session not found: {session_id}"}
|
|
474
|
+
|
|
475
|
+
# Sandbox integration: if sandbox enabled, route through sandboxed executor
|
|
476
|
+
if _get_sandbox_flag():
|
|
477
|
+
from tools.python_sandbox import execute_sandboxed
|
|
478
|
+
session = _sessions[session_id]
|
|
479
|
+
backend = session.get("_backend")
|
|
480
|
+
ns = backend.namespace if hasattr(backend, "namespace") else None
|
|
481
|
+
output = execute_sandboxed(code_str, namespace=ns)
|
|
482
|
+
session["exec_count"] += 1
|
|
483
|
+
session["last_used"] = _now_iso()
|
|
484
|
+
output["exec_count"] = session["exec_count"]
|
|
485
|
+
_persist_session(session_id)
|
|
486
|
+
return output
|
|
487
|
+
|
|
488
|
+
session = _sessions[session_id]
|
|
489
|
+
backend = session["_backend"]
|
|
490
|
+
|
|
491
|
+
try:
|
|
492
|
+
output = backend.execute(code_str)
|
|
493
|
+
except Exception as e:
|
|
494
|
+
output = {
|
|
495
|
+
"stdout": "",
|
|
496
|
+
"stderr": "",
|
|
497
|
+
"result": None,
|
|
498
|
+
"error": f"{type(e).__name__}: {e}",
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
session["exec_count"] += 1
|
|
502
|
+
session["last_used"] = _now_iso()
|
|
503
|
+
output["exec_count"] = session["exec_count"]
|
|
504
|
+
|
|
505
|
+
_persist_session(session_id)
|
|
506
|
+
return output
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def get_session(session_id: str) -> Optional[Dict[str, Any]]:
|
|
510
|
+
"""Get session info by ID.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
session_id: Session ID to look up
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
Session info dict, None if not found,
|
|
517
|
+
or {"error": "..."} if feature flag is disabled.
|
|
518
|
+
"""
|
|
519
|
+
if not _is_enabled():
|
|
520
|
+
return {"error": _DISABLED_MSG}
|
|
521
|
+
|
|
522
|
+
if session_id not in _sessions:
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
return _session_info(_sessions[session_id])
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def close_session(session_id: str) -> Union[bool, Dict[str, Any]]:
|
|
529
|
+
"""Close and cleanup a session.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
session_id: Session ID to close
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
True if closed, False if not found,
|
|
536
|
+
or {"error": "..."} if feature flag is disabled.
|
|
537
|
+
"""
|
|
538
|
+
if not _is_enabled():
|
|
539
|
+
return {"error": _DISABLED_MSG}
|
|
540
|
+
|
|
541
|
+
if session_id not in _sessions:
|
|
542
|
+
return False
|
|
543
|
+
|
|
544
|
+
session = _sessions.pop(session_id)
|
|
545
|
+
backend = session.get("_backend")
|
|
546
|
+
if backend is not None:
|
|
547
|
+
try:
|
|
548
|
+
backend.close()
|
|
549
|
+
except Exception:
|
|
550
|
+
pass
|
|
551
|
+
|
|
552
|
+
return True
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def list_sessions() -> Union[List[Dict[str, Any]], Dict[str, Any]]:
|
|
556
|
+
"""List all active sessions.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
List of session info dicts,
|
|
560
|
+
or {"error": "..."} if feature flag is disabled.
|
|
561
|
+
"""
|
|
562
|
+
if not _is_enabled():
|
|
563
|
+
return {"error": _DISABLED_MSG}
|
|
564
|
+
|
|
565
|
+
return [_session_info(s) for s in _sessions.values()]
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def stream_execute(
|
|
569
|
+
session_id: str, code_str: str
|
|
570
|
+
) -> Generator[Dict[str, str], None, None]:
|
|
571
|
+
"""Execute code and stream output chunks.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
session_id: Session ID from start_repl_session()
|
|
575
|
+
code_str: Python code to execute
|
|
576
|
+
|
|
577
|
+
Yields:
|
|
578
|
+
Dicts with keys: type ("stdout"|"stderr"|"result"|"error"), data (str)
|
|
579
|
+
"""
|
|
580
|
+
if not _is_enabled():
|
|
581
|
+
yield {"type": "error", "data": _DISABLED_MSG}
|
|
582
|
+
return
|
|
583
|
+
|
|
584
|
+
if session_id not in _sessions:
|
|
585
|
+
yield {"type": "error", "data": f"Session not found: {session_id}"}
|
|
586
|
+
return
|
|
587
|
+
|
|
588
|
+
session = _sessions[session_id]
|
|
589
|
+
backend = session["_backend"]
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
for chunk in backend.stream_execute(code_str):
|
|
593
|
+
yield chunk
|
|
594
|
+
except Exception as e:
|
|
595
|
+
yield {"type": "error", "data": f"{type(e).__name__}: {e}"}
|
|
596
|
+
|
|
597
|
+
session["exec_count"] += 1
|
|
598
|
+
session["last_used"] = _now_iso()
|
|
599
|
+
_persist_session(session_id)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# --- CLI Interface ---
|
|
603
|
+
|
|
604
|
+
def _cli_main():
|
|
605
|
+
"""CLI entry point for python_repl.py."""
|
|
606
|
+
import argparse
|
|
607
|
+
|
|
608
|
+
parser = argparse.ArgumentParser(
|
|
609
|
+
description="OMG Python REPL Tool — persistent sessions with IPython or stdlib",
|
|
610
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
611
|
+
)
|
|
612
|
+
parser.add_argument("--exec", dest="code", help="Execute Python code")
|
|
613
|
+
parser.add_argument("--session-id", dest="session_id", help="Session ID to use")
|
|
614
|
+
parser.add_argument(
|
|
615
|
+
"--list-sessions", action="store_true", help="List active sessions"
|
|
616
|
+
)
|
|
617
|
+
parser.add_argument(
|
|
618
|
+
"--close-session", dest="close_id", help="Close a session by ID"
|
|
619
|
+
)
|
|
620
|
+
parser.add_argument(
|
|
621
|
+
"--stream", action="store_true", help="Stream output (with --exec)"
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
args = parser.parse_args()
|
|
625
|
+
|
|
626
|
+
if args.list_sessions:
|
|
627
|
+
result = list_sessions()
|
|
628
|
+
print(json.dumps(result, indent=2))
|
|
629
|
+
return
|
|
630
|
+
|
|
631
|
+
if args.close_id:
|
|
632
|
+
result = close_session(args.close_id)
|
|
633
|
+
print(json.dumps({"closed": result}))
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
if args.code:
|
|
637
|
+
session = start_repl_session(session_id=args.session_id)
|
|
638
|
+
if "error" in session:
|
|
639
|
+
print(json.dumps(session))
|
|
640
|
+
sys.exit(1)
|
|
641
|
+
|
|
642
|
+
sid = session["session_id"]
|
|
643
|
+
|
|
644
|
+
if args.stream:
|
|
645
|
+
for chunk in stream_execute(sid, args.code):
|
|
646
|
+
print(json.dumps(chunk))
|
|
647
|
+
else:
|
|
648
|
+
result = execute_code(sid, args.code)
|
|
649
|
+
print(json.dumps(result, indent=2))
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
parser.print_help()
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
if __name__ == "__main__":
|
|
656
|
+
_cli_main()
|