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.
Files changed (113) hide show
  1. openhack/__init__.py +2 -0
  2. openhack/__main__.py +225 -0
  3. openhack/agents/__init__.py +30 -0
  4. openhack/agents/base.py +230 -0
  5. openhack/agents/browser_verifier.py +679 -0
  6. openhack/agents/browser_verifier_swarm.py +256 -0
  7. openhack/agents/checkpoint.py +89 -0
  8. openhack/agents/context_manager.py +356 -0
  9. openhack/agents/coordinator.py +1105 -0
  10. openhack/agents/endpoint_analyst.py +307 -0
  11. openhack/agents/feature_hunter.py +93 -0
  12. openhack/agents/hunter.py +481 -0
  13. openhack/agents/hunter_swarm.py +385 -0
  14. openhack/agents/llm.py +334 -0
  15. openhack/agents/recon.py +19 -0
  16. openhack/agents/sandbox_verifier.py +396 -0
  17. openhack/agents/sandbox_verifier_swarm.py +250 -0
  18. openhack/agents/session.py +286 -0
  19. openhack/agents/validator.py +217 -0
  20. openhack/agents/validator_swarm.py +106 -0
  21. openhack/auth.py +175 -0
  22. openhack/browser/__init__.py +12 -0
  23. openhack/browser/runner.py +385 -0
  24. openhack/categories.py +130 -0
  25. openhack/config.py +201 -0
  26. openhack/deterministic_recon.py +464 -0
  27. openhack/entry_points.py +745 -0
  28. openhack/framework_classifier.py +515 -0
  29. openhack/framework_detection.py +269 -0
  30. openhack/headless_scan.py +179 -0
  31. openhack/prompts/__init__.py +108 -0
  32. openhack/prompts/browser_verifier.py +171 -0
  33. openhack/prompts/coordinator.py +31 -0
  34. openhack/prompts/django/__init__.py +32 -0
  35. openhack/prompts/django/auth_bypass.py +76 -0
  36. openhack/prompts/django/csrf.py +62 -0
  37. openhack/prompts/django/data_exposure.py +67 -0
  38. openhack/prompts/django/idor.py +74 -0
  39. openhack/prompts/django/injection.py +67 -0
  40. openhack/prompts/django/misconfiguration.py +70 -0
  41. openhack/prompts/django/ssrf.py +64 -0
  42. openhack/prompts/endpoint_analyst.py +122 -0
  43. openhack/prompts/express/__init__.py +29 -0
  44. openhack/prompts/express/auth_bypass.py +71 -0
  45. openhack/prompts/express/data_exposure.py +77 -0
  46. openhack/prompts/express/idor.py +69 -0
  47. openhack/prompts/express/injection.py +75 -0
  48. openhack/prompts/express/misconfiguration.py +72 -0
  49. openhack/prompts/express/ssrf.py +63 -0
  50. openhack/prompts/feature_hunter.py +140 -0
  51. openhack/prompts/flask/__init__.py +29 -0
  52. openhack/prompts/flask/auth_bypass.py +86 -0
  53. openhack/prompts/flask/data_exposure.py +78 -0
  54. openhack/prompts/flask/idor.py +83 -0
  55. openhack/prompts/flask/injection.py +77 -0
  56. openhack/prompts/flask/misconfiguration.py +73 -0
  57. openhack/prompts/flask/ssrf.py +65 -0
  58. openhack/prompts/hunter.py +362 -0
  59. openhack/prompts/hunter_continuation_loop.py +12 -0
  60. openhack/prompts/hunter_continuation_no_findings.py +19 -0
  61. openhack/prompts/hunter_continuation_no_progress.py +22 -0
  62. openhack/prompts/hunter_tool_instructions.py +55 -0
  63. openhack/prompts/nextjs/__init__.py +42 -0
  64. openhack/prompts/nextjs/auth_bypass.py +80 -0
  65. openhack/prompts/nextjs/csrf.py +71 -0
  66. openhack/prompts/nextjs/data_exposure.py +88 -0
  67. openhack/prompts/nextjs/idor.py +64 -0
  68. openhack/prompts/nextjs/injection.py +65 -0
  69. openhack/prompts/nextjs/middleware_bypass.py +75 -0
  70. openhack/prompts/nextjs/misconfiguration.py +92 -0
  71. openhack/prompts/nextjs/server_actions.py +97 -0
  72. openhack/prompts/nextjs/ssrf.py +66 -0
  73. openhack/prompts/nextjs/xss.py +69 -0
  74. openhack/prompts/pr_analysis_system.py +80 -0
  75. openhack/prompts/pr_analysis_user.py +11 -0
  76. openhack/prompts/project_context.py +89 -0
  77. openhack/prompts/recon.py +199 -0
  78. openhack/prompts/reporter.py +88 -0
  79. openhack/prompts/researchers.py +434 -0
  80. openhack/prompts/sandbox_verifier.py +128 -0
  81. openhack/prompts/supabase/__init__.py +39 -0
  82. openhack/prompts/supabase/auth_tokens.py +131 -0
  83. openhack/prompts/supabase/edge_functions.py +150 -0
  84. openhack/prompts/supabase/graphql.py +102 -0
  85. openhack/prompts/supabase/postgrest.py +99 -0
  86. openhack/prompts/supabase/realtime.py +93 -0
  87. openhack/prompts/supabase/rls.py +110 -0
  88. openhack/prompts/supabase/rpc_functions.py +127 -0
  89. openhack/prompts/supabase/storage.py +110 -0
  90. openhack/prompts/supabase/tenant_isolation.py +118 -0
  91. openhack/prompts/validator.py +319 -0
  92. openhack/prompts/validator_continuation_incomplete.py +12 -0
  93. openhack/prompts/validator_tool_instructions.py +29 -0
  94. openhack/quality.py +231 -0
  95. openhack/sandbox/__init__.py +12 -0
  96. openhack/sandbox/orchestrator.py +517 -0
  97. openhack/sandbox/runner.py +177 -0
  98. openhack/scan_session.py +245 -0
  99. openhack/setup.py +452 -0
  100. openhack/static_validator.py +612 -0
  101. openhack/tools/__init__.py +1 -0
  102. openhack/tools/ast_tools.py +307 -0
  103. openhack/tools/coverage.py +1078 -0
  104. openhack/tools/filesystem.py +404 -0
  105. openhack/tools/nextjs.py +258 -0
  106. openhack/tools/registry.py +52 -0
  107. openhack/tui.py +3450 -0
  108. openhack/updates.py +170 -0
  109. openhack-0.1.0.dist-info/METADATA +189 -0
  110. openhack-0.1.0.dist-info/RECORD +113 -0
  111. openhack-0.1.0.dist-info/WHEEL +4 -0
  112. openhack-0.1.0.dist-info/entry_points.txt +2 -0
  113. openhack-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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)