openhack 0.1.0__py3-none-any.whl
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.
- openhack/__init__.py +2 -0
- openhack/__main__.py +225 -0
- openhack/agents/__init__.py +30 -0
- openhack/agents/base.py +230 -0
- openhack/agents/browser_verifier.py +679 -0
- openhack/agents/browser_verifier_swarm.py +256 -0
- openhack/agents/checkpoint.py +89 -0
- openhack/agents/context_manager.py +356 -0
- openhack/agents/coordinator.py +1105 -0
- openhack/agents/endpoint_analyst.py +307 -0
- openhack/agents/feature_hunter.py +93 -0
- openhack/agents/hunter.py +481 -0
- openhack/agents/hunter_swarm.py +385 -0
- openhack/agents/llm.py +334 -0
- openhack/agents/recon.py +19 -0
- openhack/agents/sandbox_verifier.py +396 -0
- openhack/agents/sandbox_verifier_swarm.py +250 -0
- openhack/agents/session.py +286 -0
- openhack/agents/validator.py +217 -0
- openhack/agents/validator_swarm.py +106 -0
- openhack/auth.py +175 -0
- openhack/browser/__init__.py +12 -0
- openhack/browser/runner.py +385 -0
- openhack/categories.py +130 -0
- openhack/config.py +201 -0
- openhack/deterministic_recon.py +464 -0
- openhack/entry_points.py +745 -0
- openhack/framework_classifier.py +515 -0
- openhack/framework_detection.py +269 -0
- openhack/headless_scan.py +179 -0
- openhack/prompts/__init__.py +108 -0
- openhack/prompts/browser_verifier.py +171 -0
- openhack/prompts/coordinator.py +31 -0
- openhack/prompts/django/__init__.py +32 -0
- openhack/prompts/django/auth_bypass.py +76 -0
- openhack/prompts/django/csrf.py +62 -0
- openhack/prompts/django/data_exposure.py +67 -0
- openhack/prompts/django/idor.py +74 -0
- openhack/prompts/django/injection.py +67 -0
- openhack/prompts/django/misconfiguration.py +70 -0
- openhack/prompts/django/ssrf.py +64 -0
- openhack/prompts/endpoint_analyst.py +122 -0
- openhack/prompts/express/__init__.py +29 -0
- openhack/prompts/express/auth_bypass.py +71 -0
- openhack/prompts/express/data_exposure.py +77 -0
- openhack/prompts/express/idor.py +69 -0
- openhack/prompts/express/injection.py +75 -0
- openhack/prompts/express/misconfiguration.py +72 -0
- openhack/prompts/express/ssrf.py +63 -0
- openhack/prompts/feature_hunter.py +140 -0
- openhack/prompts/flask/__init__.py +29 -0
- openhack/prompts/flask/auth_bypass.py +86 -0
- openhack/prompts/flask/data_exposure.py +78 -0
- openhack/prompts/flask/idor.py +83 -0
- openhack/prompts/flask/injection.py +77 -0
- openhack/prompts/flask/misconfiguration.py +73 -0
- openhack/prompts/flask/ssrf.py +65 -0
- openhack/prompts/hunter.py +362 -0
- openhack/prompts/hunter_continuation_loop.py +12 -0
- openhack/prompts/hunter_continuation_no_findings.py +19 -0
- openhack/prompts/hunter_continuation_no_progress.py +22 -0
- openhack/prompts/hunter_tool_instructions.py +55 -0
- openhack/prompts/nextjs/__init__.py +42 -0
- openhack/prompts/nextjs/auth_bypass.py +80 -0
- openhack/prompts/nextjs/csrf.py +71 -0
- openhack/prompts/nextjs/data_exposure.py +88 -0
- openhack/prompts/nextjs/idor.py +64 -0
- openhack/prompts/nextjs/injection.py +65 -0
- openhack/prompts/nextjs/middleware_bypass.py +75 -0
- openhack/prompts/nextjs/misconfiguration.py +92 -0
- openhack/prompts/nextjs/server_actions.py +97 -0
- openhack/prompts/nextjs/ssrf.py +66 -0
- openhack/prompts/nextjs/xss.py +69 -0
- openhack/prompts/pr_analysis_system.py +80 -0
- openhack/prompts/pr_analysis_user.py +11 -0
- openhack/prompts/project_context.py +89 -0
- openhack/prompts/recon.py +199 -0
- openhack/prompts/reporter.py +88 -0
- openhack/prompts/researchers.py +434 -0
- openhack/prompts/sandbox_verifier.py +128 -0
- openhack/prompts/supabase/__init__.py +39 -0
- openhack/prompts/supabase/auth_tokens.py +131 -0
- openhack/prompts/supabase/edge_functions.py +150 -0
- openhack/prompts/supabase/graphql.py +102 -0
- openhack/prompts/supabase/postgrest.py +99 -0
- openhack/prompts/supabase/realtime.py +93 -0
- openhack/prompts/supabase/rls.py +110 -0
- openhack/prompts/supabase/rpc_functions.py +127 -0
- openhack/prompts/supabase/storage.py +110 -0
- openhack/prompts/supabase/tenant_isolation.py +118 -0
- openhack/prompts/validator.py +319 -0
- openhack/prompts/validator_continuation_incomplete.py +12 -0
- openhack/prompts/validator_tool_instructions.py +29 -0
- openhack/quality.py +231 -0
- openhack/sandbox/__init__.py +12 -0
- openhack/sandbox/orchestrator.py +517 -0
- openhack/sandbox/runner.py +177 -0
- openhack/scan_session.py +245 -0
- openhack/setup.py +452 -0
- openhack/static_validator.py +612 -0
- openhack/tools/__init__.py +1 -0
- openhack/tools/ast_tools.py +307 -0
- openhack/tools/coverage.py +1078 -0
- openhack/tools/filesystem.py +404 -0
- openhack/tools/nextjs.py +258 -0
- openhack/tools/registry.py +52 -0
- openhack/tui.py +3450 -0
- openhack/updates.py +170 -0
- openhack-0.1.0.dist-info/METADATA +189 -0
- openhack-0.1.0.dist-info/RECORD +113 -0
- openhack-0.1.0.dist-info/WHEEL +4 -0
- openhack-0.1.0.dist-info/entry_points.txt +2 -0
- openhack-0.1.0.dist-info/licenses/LICENSE +661 -0
openhack/scan_session.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scan session — tracks which entry points have been analyzed across runs.
|
|
3
|
+
|
|
4
|
+
Provides resume capability: start a scan, stop partway, resume later from
|
|
5
|
+
where you left off. Each session has a unique ID and persists to disk.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
SESSIONS_DIR = Path.home() / ".openhack" / "scans"
|
|
19
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ScanSession:
|
|
23
|
+
"""Tracks scan progress across entry points."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, session_id: str, target_dir: str):
|
|
26
|
+
self.session_id = session_id
|
|
27
|
+
self.target_dir = target_dir
|
|
28
|
+
self.created_at = datetime.now().isoformat()
|
|
29
|
+
self.updated_at = self.created_at
|
|
30
|
+
self.classifications = [] # Framework classifications
|
|
31
|
+
self.entry_points = [] # All detected entry points
|
|
32
|
+
self.findings = [] # Findings from scanned entry points
|
|
33
|
+
self.total_cost = 0.0
|
|
34
|
+
self.attack_surface: Optional[dict] = None
|
|
35
|
+
self.analyzed_files: list[str] = []
|
|
36
|
+
self.zone_coverage: dict[str, dict] = {} # zone_name -> {status, files_total, files_done}
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def scanned_count(self) -> int:
|
|
40
|
+
return sum(1 for ep in self.entry_points if ep.get("status") != "unscanned")
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def unscanned_count(self) -> int:
|
|
44
|
+
return sum(1 for ep in self.entry_points if ep.get("status") == "unscanned")
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def coverage_pct(self) -> float:
|
|
48
|
+
total = len(self.entry_points)
|
|
49
|
+
if total == 0:
|
|
50
|
+
return 0.0
|
|
51
|
+
return (self.scanned_count / total) * 100
|
|
52
|
+
|
|
53
|
+
def mark_scanned(self, file_path: str, status: str = "scanned"):
|
|
54
|
+
"""Mark all entry points in a file as scanned."""
|
|
55
|
+
for ep in self.entry_points:
|
|
56
|
+
if ep.get("file") == file_path:
|
|
57
|
+
ep["status"] = status
|
|
58
|
+
self.updated_at = datetime.now().isoformat()
|
|
59
|
+
|
|
60
|
+
def mark_finding(self, file_path: str):
|
|
61
|
+
"""Mark entry points in a file as having findings."""
|
|
62
|
+
for ep in self.entry_points:
|
|
63
|
+
if ep.get("file") == file_path:
|
|
64
|
+
ep["status"] = "finding_found"
|
|
65
|
+
self.updated_at = datetime.now().isoformat()
|
|
66
|
+
|
|
67
|
+
def get_unscanned_files(self) -> list[str]:
|
|
68
|
+
"""Get list of unique files with unscanned entry points."""
|
|
69
|
+
files = set()
|
|
70
|
+
for ep in self.entry_points:
|
|
71
|
+
if ep.get("status") == "unscanned":
|
|
72
|
+
files.add(ep.get("file", ""))
|
|
73
|
+
return sorted(files)
|
|
74
|
+
|
|
75
|
+
def get_scanned_files(self) -> list[str]:
|
|
76
|
+
"""Get list of files that have been scanned."""
|
|
77
|
+
files = set()
|
|
78
|
+
for ep in self.entry_points:
|
|
79
|
+
if ep.get("status") != "unscanned":
|
|
80
|
+
files.add(ep.get("file", ""))
|
|
81
|
+
return sorted(files)
|
|
82
|
+
|
|
83
|
+
def mark_zone_complete(self, zone_name: str, files_analyzed: list[str], findings_count: int = 0):
|
|
84
|
+
"""Mark a zone as completed after a researcher finishes."""
|
|
85
|
+
self.zone_coverage[zone_name] = {
|
|
86
|
+
"status": "completed",
|
|
87
|
+
"files_analyzed": files_analyzed,
|
|
88
|
+
"files_done": len(files_analyzed),
|
|
89
|
+
"findings_count": findings_count,
|
|
90
|
+
"completed_at": datetime.now().isoformat(),
|
|
91
|
+
}
|
|
92
|
+
for f in files_analyzed:
|
|
93
|
+
if f not in self.analyzed_files:
|
|
94
|
+
self.analyzed_files.append(f)
|
|
95
|
+
self.updated_at = datetime.now().isoformat()
|
|
96
|
+
|
|
97
|
+
def get_completed_zones(self) -> set[str]:
|
|
98
|
+
"""Get names of zones that have been fully analyzed."""
|
|
99
|
+
return {
|
|
100
|
+
name for name, info in self.zone_coverage.items()
|
|
101
|
+
if info.get("status") == "completed"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def get_analyzed_file_set(self) -> set[str]:
|
|
105
|
+
"""Get all files that have been analyzed across all zones."""
|
|
106
|
+
return set(self.analyzed_files)
|
|
107
|
+
|
|
108
|
+
def save(self):
|
|
109
|
+
"""Persist session to disk."""
|
|
110
|
+
path = SESSIONS_DIR / f"{self.session_id}.json"
|
|
111
|
+
data = {
|
|
112
|
+
"session_id": self.session_id,
|
|
113
|
+
"target_dir": self.target_dir,
|
|
114
|
+
"created_at": self.created_at,
|
|
115
|
+
"updated_at": self.updated_at,
|
|
116
|
+
"classifications": self.classifications,
|
|
117
|
+
"entry_points": self.entry_points,
|
|
118
|
+
"findings": self.findings,
|
|
119
|
+
"total_cost": self.total_cost,
|
|
120
|
+
"attack_surface": self.attack_surface,
|
|
121
|
+
"analyzed_files": self.analyzed_files,
|
|
122
|
+
"zone_coverage": self.zone_coverage,
|
|
123
|
+
"stats": {
|
|
124
|
+
"total": len(self.entry_points),
|
|
125
|
+
"scanned": self.scanned_count,
|
|
126
|
+
"unscanned": self.unscanned_count,
|
|
127
|
+
"coverage_pct": round(self.coverage_pct, 1),
|
|
128
|
+
"findings_count": len(self.findings),
|
|
129
|
+
"zones_completed": len(self.get_completed_zones()),
|
|
130
|
+
"files_analyzed": len(self.analyzed_files),
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
path.write_text(json.dumps(data, indent=2, default=str))
|
|
134
|
+
logger.debug(f"Session {self.session_id} saved: {self.scanned_count}/{len(self.entry_points)} scanned")
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def load(cls, session_id: str) -> Optional["ScanSession"]:
|
|
138
|
+
"""Load a session from disk."""
|
|
139
|
+
path = SESSIONS_DIR / f"{session_id}.json"
|
|
140
|
+
if not path.exists():
|
|
141
|
+
return None
|
|
142
|
+
data = json.loads(path.read_text())
|
|
143
|
+
session = cls(data["session_id"], data["target_dir"])
|
|
144
|
+
session.created_at = data.get("created_at", "")
|
|
145
|
+
session.updated_at = data.get("updated_at", "")
|
|
146
|
+
session.classifications = data.get("classifications", [])
|
|
147
|
+
session.entry_points = data.get("entry_points", [])
|
|
148
|
+
session.findings = data.get("findings", [])
|
|
149
|
+
session.total_cost = data.get("total_cost", 0.0)
|
|
150
|
+
session.attack_surface = data.get("attack_surface")
|
|
151
|
+
session.analyzed_files = data.get("analyzed_files", [])
|
|
152
|
+
session.zone_coverage = data.get("zone_coverage", {})
|
|
153
|
+
return session
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def list_sessions(cls, target_dir: Optional[str] = None) -> list[dict]:
|
|
157
|
+
"""List all saved sessions, optionally filtered by target_dir."""
|
|
158
|
+
sessions = []
|
|
159
|
+
for path in sorted(SESSIONS_DIR.glob("*.json"), reverse=True):
|
|
160
|
+
try:
|
|
161
|
+
data = json.loads(path.read_text())
|
|
162
|
+
if target_dir and data.get("target_dir") != target_dir:
|
|
163
|
+
continue
|
|
164
|
+
sessions.append({
|
|
165
|
+
"session_id": data["session_id"],
|
|
166
|
+
"target_dir": data.get("target_dir", "?"),
|
|
167
|
+
"created_at": data.get("created_at", "?"),
|
|
168
|
+
"stats": data.get("stats", {}),
|
|
169
|
+
})
|
|
170
|
+
except Exception:
|
|
171
|
+
continue
|
|
172
|
+
return sessions
|
|
173
|
+
|
|
174
|
+
def print_summary(self):
|
|
175
|
+
"""Print a human-readable summary of the session."""
|
|
176
|
+
print(f"\n{'='*60}")
|
|
177
|
+
print(f" Scan Session: {self.session_id}")
|
|
178
|
+
print(f" Target: {self.target_dir}")
|
|
179
|
+
print(f" Created: {self.created_at}")
|
|
180
|
+
print(f"{'='*60}")
|
|
181
|
+
|
|
182
|
+
# Framework summary
|
|
183
|
+
if self.classifications:
|
|
184
|
+
print(f"\n Frameworks:")
|
|
185
|
+
for c in self.classifications:
|
|
186
|
+
print(f" {c['root']} → {c['language']} [{', '.join(c['frameworks'])}]")
|
|
187
|
+
|
|
188
|
+
# Entry point summary
|
|
189
|
+
total = len(self.entry_points)
|
|
190
|
+
scanned = self.scanned_count
|
|
191
|
+
unscanned = self.unscanned_count
|
|
192
|
+
with_findings = sum(1 for ep in self.entry_points if ep.get("status") == "finding_found")
|
|
193
|
+
|
|
194
|
+
print(f"\n Entry Points: {total}")
|
|
195
|
+
print(f" Scanned: {scanned} ({self.coverage_pct:.1f}%)")
|
|
196
|
+
print(f" Unscanned: {unscanned}")
|
|
197
|
+
print(f" w/ Findings: {with_findings}")
|
|
198
|
+
|
|
199
|
+
# Zone coverage
|
|
200
|
+
if self.zone_coverage:
|
|
201
|
+
completed = self.get_completed_zones()
|
|
202
|
+
print(f"\n Zones: {len(completed)} completed")
|
|
203
|
+
for name, info in self.zone_coverage.items():
|
|
204
|
+
status = info.get("status", "?")
|
|
205
|
+
done = info.get("files_done", 0)
|
|
206
|
+
findings = info.get("findings_count", 0)
|
|
207
|
+
icon = "[✓]" if status == "completed" else "[ ]"
|
|
208
|
+
print(f" {icon} {name} — {done} files, {findings} findings")
|
|
209
|
+
|
|
210
|
+
if self.analyzed_files:
|
|
211
|
+
print(f"\n Files Analyzed: {len(self.analyzed_files)}")
|
|
212
|
+
|
|
213
|
+
# Findings summary
|
|
214
|
+
if self.findings:
|
|
215
|
+
print(f"\n Findings: {len(self.findings)}")
|
|
216
|
+
for f in self.findings:
|
|
217
|
+
sev = f.get("severity", "?").upper()
|
|
218
|
+
cat = f.get("category", "?")
|
|
219
|
+
fp = f.get("file_path", "?")
|
|
220
|
+
print(f" [{sev}] {cat} — {fp}")
|
|
221
|
+
|
|
222
|
+
print(f"\n Cost: ${self.total_cost:.4f}")
|
|
223
|
+
print()
|
|
224
|
+
|
|
225
|
+
def print_entry_points(self, show_all: bool = False):
|
|
226
|
+
"""Print all entry points with their scan status."""
|
|
227
|
+
print(f"\n {'Status':<15} {'Method':<10} {'Path':<50} {'File'}")
|
|
228
|
+
print(f" {'-'*100}")
|
|
229
|
+
for ep in self.entry_points:
|
|
230
|
+
status = ep.get("status", "unscanned")
|
|
231
|
+
if not show_all and status != "unscanned":
|
|
232
|
+
continue
|
|
233
|
+
status_icon = {
|
|
234
|
+
"unscanned": "[ ]",
|
|
235
|
+
"scanned": "[✓]",
|
|
236
|
+
"finding_found": "[!]",
|
|
237
|
+
"clean": "[·]",
|
|
238
|
+
}.get(status, "[?]")
|
|
239
|
+
method = ep.get("method", "?")
|
|
240
|
+
path = ep.get("path", "?")
|
|
241
|
+
file = ep.get("file", "?")
|
|
242
|
+
# Truncate long paths
|
|
243
|
+
if len(path) > 48:
|
|
244
|
+
path = path[:45] + "..."
|
|
245
|
+
print(f" {status_icon:<15} {method:<10} {path:<50} {file}")
|
openhack/setup.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive configuration wizard for OpenHack.
|
|
3
|
+
|
|
4
|
+
Two entry points:
|
|
5
|
+
- run_first_time_setup() — auto-launched when ~/.openhack/config is absent
|
|
6
|
+
- run_setup_command() — triggered by /setup inside the TUI (async)
|
|
7
|
+
|
|
8
|
+
Uses prompt_toolkit for arrow-key driven selection menus, secure password
|
|
9
|
+
input for API keys, and a final confirmation screen.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import getpass
|
|
14
|
+
import os
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from prompt_toolkit import print_formatted_text
|
|
18
|
+
from prompt_toolkit.formatted_text import HTML
|
|
19
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
20
|
+
from prompt_toolkit.application import Application
|
|
21
|
+
from prompt_toolkit.layout.containers import HSplit, Window
|
|
22
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
23
|
+
from prompt_toolkit.layout.layout import Layout
|
|
24
|
+
|
|
25
|
+
from openhack.auth import (
|
|
26
|
+
DeviceLoginCancelled,
|
|
27
|
+
DeviceLoginError,
|
|
28
|
+
DeviceLoginExpired,
|
|
29
|
+
device_login,
|
|
30
|
+
)
|
|
31
|
+
from openhack.config import (
|
|
32
|
+
CONFIG_PATH,
|
|
33
|
+
load_user_config,
|
|
34
|
+
save_user_config,
|
|
35
|
+
resolve_provider,
|
|
36
|
+
reload_settings,
|
|
37
|
+
settings,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
DIM = '<style fg="ansigray">'
|
|
41
|
+
EDIM = '</style>'
|
|
42
|
+
B = '<b>'
|
|
43
|
+
EB = '</b>'
|
|
44
|
+
CYAN = '<ansicyan>'
|
|
45
|
+
ECYAN = '</ansicyan>'
|
|
46
|
+
GREEN = '<ansigreen>'
|
|
47
|
+
EGREEN = '</ansigreen>'
|
|
48
|
+
YELLOW = '<ansiyellow>'
|
|
49
|
+
EYELLOW = '</ansiyellow>'
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _html(text: str) -> None:
|
|
53
|
+
print_formatted_text(HTML(text))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _esc(text: str) -> str:
|
|
57
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _clear() -> None:
|
|
61
|
+
print("\033[2J\033[H", end="", flush=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Provider / model definitions ──────────────────────────────────
|
|
65
|
+
|
|
66
|
+
PROVIDERS = [
|
|
67
|
+
{
|
|
68
|
+
"key": "openhack",
|
|
69
|
+
"display": "OpenHack",
|
|
70
|
+
"hint": "Recommended — no setup required, free tier available",
|
|
71
|
+
"key_field": "openhack_api_key",
|
|
72
|
+
"key_env": "OPENHACK_API_KEY",
|
|
73
|
+
# key_url is built dynamically from settings.openhack_app_url at display time.
|
|
74
|
+
"models": [
|
|
75
|
+
("kimi-k2.5", "Kimi K2.5", "Flagship security analysis model"),
|
|
76
|
+
],
|
|
77
|
+
"default_model": "kimi-k2.5",
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _mask_key(key: str) -> str:
|
|
83
|
+
if not key:
|
|
84
|
+
return "(not set)"
|
|
85
|
+
if len(key) <= 12:
|
|
86
|
+
return key[:2] + "•" * (len(key) - 2)
|
|
87
|
+
return key[:6] + "•" * 8 + key[-4:]
|
|
88
|
+
|
|
89
|
+
def _has_running_loop() -> bool:
|
|
90
|
+
try:
|
|
91
|
+
loop = asyncio.get_running_loop()
|
|
92
|
+
return loop.is_running()
|
|
93
|
+
except RuntimeError:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Arrow-key selection menu ──────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async def _select_menu_async(title: str, items: list[tuple[str, str, str]], default_idx: int = 0) -> int:
|
|
100
|
+
"""Render an arrow-key driven selection menu. Returns the chosen index.
|
|
101
|
+
|
|
102
|
+
items: list of (value, label, hint)
|
|
103
|
+
"""
|
|
104
|
+
selected = [default_idx]
|
|
105
|
+
|
|
106
|
+
def _get_text():
|
|
107
|
+
lines = []
|
|
108
|
+
lines.append(("class:title", f" {title}\n\n"))
|
|
109
|
+
for i, (_, label, hint) in enumerate(items):
|
|
110
|
+
if i == selected[0]:
|
|
111
|
+
lines.append(("class:selected", f" ❯ {label}"))
|
|
112
|
+
if hint:
|
|
113
|
+
lines.append(("class:hint.selected", f" {hint}"))
|
|
114
|
+
lines.append(("", "\n"))
|
|
115
|
+
else:
|
|
116
|
+
lines.append(("class:unselected", f" {label}"))
|
|
117
|
+
if hint:
|
|
118
|
+
lines.append(("class:hint", f" {hint}"))
|
|
119
|
+
lines.append(("", "\n"))
|
|
120
|
+
lines.append(("class:footer", "\n ↑/↓ to move · Enter to select · q to cancel"))
|
|
121
|
+
return lines
|
|
122
|
+
|
|
123
|
+
kb = KeyBindings()
|
|
124
|
+
result = [None]
|
|
125
|
+
|
|
126
|
+
@kb.add("up")
|
|
127
|
+
@kb.add("k")
|
|
128
|
+
def _up(event):
|
|
129
|
+
selected[0] = (selected[0] - 1) % len(items)
|
|
130
|
+
|
|
131
|
+
@kb.add("down")
|
|
132
|
+
@kb.add("j")
|
|
133
|
+
def _down(event):
|
|
134
|
+
selected[0] = (selected[0] + 1) % len(items)
|
|
135
|
+
|
|
136
|
+
@kb.add("enter")
|
|
137
|
+
def _enter(event):
|
|
138
|
+
result[0] = selected[0]
|
|
139
|
+
event.app.exit()
|
|
140
|
+
|
|
141
|
+
@kb.add("q")
|
|
142
|
+
@kb.add("escape")
|
|
143
|
+
def _quit(event):
|
|
144
|
+
result[0] = -1
|
|
145
|
+
event.app.exit()
|
|
146
|
+
|
|
147
|
+
from prompt_toolkit.styles import Style
|
|
148
|
+
style = Style.from_dict({
|
|
149
|
+
"title": "bold",
|
|
150
|
+
"selected": "bold ansibrightcyan",
|
|
151
|
+
"hint.selected": "ansigray",
|
|
152
|
+
"unselected": "",
|
|
153
|
+
"hint": "ansigray",
|
|
154
|
+
"footer": "ansigray italic",
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
control = FormattedTextControl(_get_text)
|
|
158
|
+
window = Window(content=control, always_hide_cursor=True)
|
|
159
|
+
layout = Layout(HSplit([window]))
|
|
160
|
+
app = Application(layout=layout, key_bindings=kb, style=style, full_screen=False)
|
|
161
|
+
await app.run_async()
|
|
162
|
+
|
|
163
|
+
return result[0] if result[0] is not None else -1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _select_menu(title: str, items: list[tuple[str, str, str]], default_idx: int = 0) -> int:
|
|
167
|
+
"""Sync wrapper — delegates to async impl."""
|
|
168
|
+
if _has_running_loop():
|
|
169
|
+
raise RuntimeError("Use _select_menu_async from within an event loop")
|
|
170
|
+
|
|
171
|
+
return asyncio.run(_select_menu_async(title, items, default_idx))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── API key input ─────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def _prompt_api_key(provider: dict, existing_key: Optional[str] = None) -> Optional[str]:
|
|
177
|
+
"""Prompt for an API key with masked display."""
|
|
178
|
+
_html("")
|
|
179
|
+
_html(f' {B}API Key for {_esc(provider["display"])}{EB}')
|
|
180
|
+
key_url = f"{settings.openhack_app_url.rstrip('/')}/settings/api-keys"
|
|
181
|
+
_html(f' {DIM}Get your key at: {_esc(key_url)}{EDIM}')
|
|
182
|
+
_html("")
|
|
183
|
+
|
|
184
|
+
if existing_key:
|
|
185
|
+
_html(f' {DIM}Current: {_esc(_mask_key(existing_key))}{EDIM}')
|
|
186
|
+
_html(f' {DIM}Press Enter to keep existing key, or paste a new one{EDIM}')
|
|
187
|
+
_html("")
|
|
188
|
+
|
|
189
|
+
env_val = os.environ.get(provider["key_env"])
|
|
190
|
+
if env_val:
|
|
191
|
+
_html(f' {DIM}Found in environment: ${_esc(provider["key_env"])} = {_esc(_mask_key(env_val))}{EDIM}')
|
|
192
|
+
_html(f' {DIM}Press Enter to use environment value{EDIM}')
|
|
193
|
+
_html("")
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
key = getpass.getpass(" API Key: ").strip()
|
|
197
|
+
except (EOFError, KeyboardInterrupt):
|
|
198
|
+
return existing_key
|
|
199
|
+
|
|
200
|
+
if not key:
|
|
201
|
+
if existing_key:
|
|
202
|
+
return existing_key
|
|
203
|
+
if env_val:
|
|
204
|
+
return env_val
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
return key
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── Base URL input (for OpenHack provider) ───────────────────────────
|
|
211
|
+
|
|
212
|
+
def _prompt_base_url(existing: Optional[str] = None) -> str:
|
|
213
|
+
if not existing:
|
|
214
|
+
existing = settings.openhack_base_url
|
|
215
|
+
_html("")
|
|
216
|
+
_html(f' {B}OpenHack Base URL{EB}')
|
|
217
|
+
_html(f' {DIM}Default: {_esc(existing)}{EDIM}')
|
|
218
|
+
_html(f' {DIM}Press Enter to keep default{EDIM}')
|
|
219
|
+
_html("")
|
|
220
|
+
try:
|
|
221
|
+
url = input(" Base URL: ").strip()
|
|
222
|
+
except (EOFError, KeyboardInterrupt):
|
|
223
|
+
return existing
|
|
224
|
+
return url if url else existing
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Summary / confirmation ────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
def _show_summary(provider: dict, model_id: str, api_key: Optional[str], base_url: Optional[str] = None, org_name: Optional[str] = None) -> bool:
|
|
230
|
+
_html("")
|
|
231
|
+
_html(f' {"━" * 50}')
|
|
232
|
+
_html(f' {B}Configuration Summary{EB}')
|
|
233
|
+
_html(f' {"━" * 50}')
|
|
234
|
+
_html("")
|
|
235
|
+
_html(f' {B}Provider:{EB} {_esc(provider["display"])}')
|
|
236
|
+
if org_name:
|
|
237
|
+
_html(f' {B}Org:{EB} {_esc(org_name)}')
|
|
238
|
+
_html(f' {B}Model:{EB} {_esc(model_id)}')
|
|
239
|
+
_html(f' {B}API Key:{EB} {_esc(_mask_key(api_key or ""))}')
|
|
240
|
+
if base_url and provider["key"] == "openhack":
|
|
241
|
+
_html(f' {B}Base URL:{EB} {_esc(base_url)}')
|
|
242
|
+
_html("")
|
|
243
|
+
_html(f' {DIM}Config will be saved to {_esc(str(CONFIG_PATH))}{EDIM}')
|
|
244
|
+
_html("")
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
confirm = input(" Save this configuration? [Y/n] ").strip().lower()
|
|
248
|
+
except (EOFError, KeyboardInterrupt):
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
return confirm in ("", "y", "yes")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── First-time setup wizard ──────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
def _banner() -> None:
|
|
257
|
+
_html("")
|
|
258
|
+
_html(f' <b><ansibrightwhite> ██</ansibrightwhite></b>')
|
|
259
|
+
_html(f' <b><ansibrightwhite> ██</ansibrightwhite></b>')
|
|
260
|
+
_html(f' <b><ansibrightwhite> ██</ansibrightwhite></b>')
|
|
261
|
+
_html(f' <b><ansibrightwhite> ████████████████</ansibrightwhite></b>')
|
|
262
|
+
_html("")
|
|
263
|
+
_html(f' <b><ansibrightwhite> ████████████</ansibrightwhite></b>')
|
|
264
|
+
_html("")
|
|
265
|
+
_html(f' <b><ansibrightwhite> ████████</ansibrightwhite></b>')
|
|
266
|
+
_html("")
|
|
267
|
+
_html(f' <b><ansicyan> OpenHack</ansicyan></b> — First Time Setup')
|
|
268
|
+
_html("")
|
|
269
|
+
_html(f' {DIM}Welcome to OpenHack! Let\'s get started with setup.{EDIM}')
|
|
270
|
+
_html("")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _setup_banner() -> None:
|
|
274
|
+
_html("")
|
|
275
|
+
_html(f' <b><ansibrightwhite> ██</ansibrightwhite></b>')
|
|
276
|
+
_html(f' <b><ansibrightwhite> ██</ansibrightwhite></b>')
|
|
277
|
+
_html(f' <b><ansibrightwhite> ██</ansibrightwhite></b>')
|
|
278
|
+
_html(f' <b><ansibrightwhite> ████████████████</ansibrightwhite></b>')
|
|
279
|
+
_html("")
|
|
280
|
+
_html(f' <b><ansibrightwhite> ████████████</ansibrightwhite></b>')
|
|
281
|
+
_html("")
|
|
282
|
+
_html(f' <b><ansibrightwhite> ████████</ansibrightwhite></b>')
|
|
283
|
+
_html("")
|
|
284
|
+
_html(f' <b><ansicyan> OpenHack</ansicyan></b> — Configuration')
|
|
285
|
+
_html("")
|
|
286
|
+
_html(f' {DIM}Update your settings and API key.{EDIM}')
|
|
287
|
+
_html("")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def _run_wizard(is_first_time: bool = True) -> bool:
|
|
291
|
+
"""Run the interactive configuration wizard. Returns True if config was saved."""
|
|
292
|
+
cfg = load_user_config()
|
|
293
|
+
|
|
294
|
+
if is_first_time:
|
|
295
|
+
_banner()
|
|
296
|
+
else:
|
|
297
|
+
_setup_banner()
|
|
298
|
+
|
|
299
|
+
provider = PROVIDERS[0]
|
|
300
|
+
default_model = provider["default_model"]
|
|
301
|
+
default_base_url = cfg.get("openhack_base_url") or settings.openhack_base_url
|
|
302
|
+
|
|
303
|
+
# ── Step 1: Login / API key / Custom ─────────────────────────
|
|
304
|
+
setup_choice = await _select_menu_async(
|
|
305
|
+
"How would you like to proceed?",
|
|
306
|
+
[
|
|
307
|
+
("login", "Login with OpenHack account", "(Recommended, free $20 credits on signup)"),
|
|
308
|
+
("apikey", "Use OpenHack API Key", ""),
|
|
309
|
+
("custom", "Custom setup", ""),
|
|
310
|
+
],
|
|
311
|
+
)
|
|
312
|
+
if setup_choice < 0:
|
|
313
|
+
_html(f' {DIM}Setup cancelled.{EDIM}')
|
|
314
|
+
_html("")
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
api_key: Optional[str] = None
|
|
318
|
+
model_id = default_model
|
|
319
|
+
base_url = default_base_url
|
|
320
|
+
login_result = None
|
|
321
|
+
|
|
322
|
+
if setup_choice == 0:
|
|
323
|
+
# Browser-based device-code login.
|
|
324
|
+
app_url = cfg.get("openhack_app_url") or settings.openhack_app_url
|
|
325
|
+
try:
|
|
326
|
+
login_result = await device_login(app_url)
|
|
327
|
+
api_key = login_result.token
|
|
328
|
+
except DeviceLoginCancelled:
|
|
329
|
+
_html(f' {DIM}Login cancelled.{EDIM}')
|
|
330
|
+
_html("")
|
|
331
|
+
return False
|
|
332
|
+
except DeviceLoginExpired as exc:
|
|
333
|
+
_html(f' {YELLOW}⚠{EYELLOW} {_esc(str(exc))}')
|
|
334
|
+
_html("")
|
|
335
|
+
return False
|
|
336
|
+
except DeviceLoginError as exc:
|
|
337
|
+
_html(f' {YELLOW}⚠{EYELLOW} Login failed: {_esc(str(exc))}')
|
|
338
|
+
_html("")
|
|
339
|
+
return False
|
|
340
|
+
elif setup_choice == 1:
|
|
341
|
+
# User pastes an existing OpenHack API token from the dashboard.
|
|
342
|
+
existing_key = cfg.get(provider["key_field"])
|
|
343
|
+
api_key = _prompt_api_key(provider, existing_key)
|
|
344
|
+
if not api_key:
|
|
345
|
+
_html("")
|
|
346
|
+
_html(f' {YELLOW}⚠{EYELLOW} An API key is required.')
|
|
347
|
+
_html(f' {DIM}Sign up at: {_esc(settings.openhack_app_url)}/signup{EDIM}')
|
|
348
|
+
_html("")
|
|
349
|
+
else:
|
|
350
|
+
# Custom: pick model, base URL, paste key.
|
|
351
|
+
model_items = [
|
|
352
|
+
(m[0], m[1], m[2])
|
|
353
|
+
for m in provider["models"]
|
|
354
|
+
]
|
|
355
|
+
current_model = cfg.get("model") or cfg.get("openhack_model_id")
|
|
356
|
+
default_model_idx = 0
|
|
357
|
+
for i, (mid, _, _) in enumerate(provider["models"]):
|
|
358
|
+
if mid == current_model:
|
|
359
|
+
default_model_idx = i
|
|
360
|
+
break
|
|
361
|
+
|
|
362
|
+
model_idx = await _select_menu_async(
|
|
363
|
+
"Choose a model:",
|
|
364
|
+
model_items,
|
|
365
|
+
default_idx=default_model_idx,
|
|
366
|
+
)
|
|
367
|
+
if model_idx < 0:
|
|
368
|
+
_html(f' {DIM}Setup cancelled.{EDIM}')
|
|
369
|
+
_html("")
|
|
370
|
+
return False
|
|
371
|
+
model_id = provider["models"][model_idx][0]
|
|
372
|
+
|
|
373
|
+
base_url = _prompt_base_url(default_base_url)
|
|
374
|
+
|
|
375
|
+
existing_key = cfg.get(provider["key_field"])
|
|
376
|
+
api_key = _prompt_api_key(provider, existing_key)
|
|
377
|
+
if not api_key:
|
|
378
|
+
_html("")
|
|
379
|
+
_html(f' {YELLOW}⚠{EYELLOW} An API key is required.')
|
|
380
|
+
_html(f' {DIM}Sign up at: {_esc(settings.openhack_app_url)}/signup{EDIM}')
|
|
381
|
+
_html("")
|
|
382
|
+
|
|
383
|
+
# ── Step 3: Summary & confirm ─────────────────────────────────
|
|
384
|
+
org_name = login_result.org_name if login_result else None
|
|
385
|
+
if not _show_summary(provider, model_id, api_key, base_url, org_name):
|
|
386
|
+
_html(f' {DIM}Setup cancelled. No changes saved.{EDIM}')
|
|
387
|
+
_html("")
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
# ── Save ──────────────────────────────────────────────────────
|
|
391
|
+
new_cfg = {
|
|
392
|
+
"provider": "openhack",
|
|
393
|
+
"model": model_id,
|
|
394
|
+
"openhack_model_id": model_id,
|
|
395
|
+
}
|
|
396
|
+
# Only persist base_url if the user explicitly customized it. Otherwise
|
|
397
|
+
# leave it out so the dev/prod default (driven by OPENHACK_DEV) wins.
|
|
398
|
+
if setup_choice == 2 and base_url and base_url != settings.openhack_base_url:
|
|
399
|
+
new_cfg["openhack_base_url"] = base_url
|
|
400
|
+
if api_key:
|
|
401
|
+
new_cfg["openhack_api_key"] = api_key
|
|
402
|
+
if login_result:
|
|
403
|
+
if login_result.org_id:
|
|
404
|
+
new_cfg["openhack_org_id"] = login_result.org_id
|
|
405
|
+
if login_result.org_slug:
|
|
406
|
+
new_cfg["openhack_org_slug"] = login_result.org_slug
|
|
407
|
+
if login_result.org_name:
|
|
408
|
+
new_cfg["openhack_org_name"] = login_result.org_name
|
|
409
|
+
if login_result.user_email:
|
|
410
|
+
new_cfg["openhack_user_email"] = login_result.user_email
|
|
411
|
+
if login_result.user_first_name:
|
|
412
|
+
new_cfg["openhack_user_first_name"] = login_result.user_first_name
|
|
413
|
+
if login_result.user_last_name:
|
|
414
|
+
new_cfg["openhack_user_last_name"] = login_result.user_last_name
|
|
415
|
+
|
|
416
|
+
save_user_config(new_cfg)
|
|
417
|
+
reload_settings()
|
|
418
|
+
|
|
419
|
+
_html("")
|
|
420
|
+
_html(f' {GREEN}✓{EGREEN} {B}Configuration saved!{EB}')
|
|
421
|
+
_html(f' {DIM}Stored in {_esc(str(CONFIG_PATH))}{EDIM}')
|
|
422
|
+
_html("")
|
|
423
|
+
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def needs_first_time_setup() -> bool:
|
|
428
|
+
"""Check if this is a first-time run (no config file exists)."""
|
|
429
|
+
if not CONFIG_PATH.exists():
|
|
430
|
+
return True
|
|
431
|
+
cfg = load_user_config()
|
|
432
|
+
if not cfg:
|
|
433
|
+
return True
|
|
434
|
+
has_provider = cfg.get("provider")
|
|
435
|
+
if not has_provider:
|
|
436
|
+
return True
|
|
437
|
+
# All providers now require an API key
|
|
438
|
+
has_any_key = any(
|
|
439
|
+
cfg.get(p["key_field"])
|
|
440
|
+
for p in PROVIDERS
|
|
441
|
+
)
|
|
442
|
+
return not has_any_key
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def run_first_time_setup() -> bool:
|
|
446
|
+
"""Run the first-time setup wizard. Returns True if setup completed."""
|
|
447
|
+
return asyncio.run(_run_wizard(is_first_time=True))
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async def run_setup_command() -> bool:
|
|
451
|
+
"""Run the /setup configuration wizard (async, for use inside TUI). Returns True if config was saved."""
|
|
452
|
+
return await _run_wizard(is_first_time=False)
|