reverse-api-engineer 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.
- reverse_api/__init__.py +3 -0
- reverse_api/browser.py +419 -0
- reverse_api/cli.py +530 -0
- reverse_api/config.py +52 -0
- reverse_api/engineer.py +209 -0
- reverse_api/messages.py +83 -0
- reverse_api/session.py +71 -0
- reverse_api/tui.py +201 -0
- reverse_api/utils.py +112 -0
- reverse_api_engineer-0.1.0.dist-info/METADATA +212 -0
- reverse_api_engineer-0.1.0.dist-info/RECORD +14 -0
- reverse_api_engineer-0.1.0.dist-info/WHEEL +4 -0
- reverse_api_engineer-0.1.0.dist-info/entry_points.txt +2 -0
- reverse_api_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
reverse_api/cli.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import questionary
|
|
7
|
+
from questionary import Choice
|
|
8
|
+
from prompt_toolkit.completion import WordCompleter
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
from rich.box import MINIMAL, ROUNDED
|
|
14
|
+
|
|
15
|
+
from .browser import ManualBrowser
|
|
16
|
+
from .utils import (
|
|
17
|
+
generate_run_id,
|
|
18
|
+
generate_folder_name,
|
|
19
|
+
get_config_path,
|
|
20
|
+
get_history_path,
|
|
21
|
+
get_har_dir,
|
|
22
|
+
get_scripts_dir,
|
|
23
|
+
get_timestamp,
|
|
24
|
+
)
|
|
25
|
+
from .tui import (
|
|
26
|
+
get_model_choices,
|
|
27
|
+
display_banner,
|
|
28
|
+
display_footer,
|
|
29
|
+
THEME_PRIMARY,
|
|
30
|
+
THEME_SECONDARY,
|
|
31
|
+
THEME_DIM,
|
|
32
|
+
THEME_SUCCESS,
|
|
33
|
+
THEME_ERROR,
|
|
34
|
+
)
|
|
35
|
+
from .config import ConfigManager
|
|
36
|
+
from .session import SessionManager
|
|
37
|
+
from .engineer import run_reverse_engineering
|
|
38
|
+
from .messages import MessageStore
|
|
39
|
+
|
|
40
|
+
from prompt_toolkit import PromptSession
|
|
41
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
42
|
+
from prompt_toolkit.styles import Style as PtStyle
|
|
43
|
+
from prompt_toolkit.formatted_text import HTML
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
console = Console()
|
|
47
|
+
config_manager = ConfigManager(get_config_path())
|
|
48
|
+
session_manager = SessionManager(get_history_path())
|
|
49
|
+
|
|
50
|
+
# Mode definitions
|
|
51
|
+
MODES = ["manual", "engineer"]
|
|
52
|
+
MODE_DESCRIPTIONS = {
|
|
53
|
+
"manual": "full pipeline",
|
|
54
|
+
"engineer": "reverse engineer only",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def prompt_interactive_options(
|
|
59
|
+
prompt: str | None = None,
|
|
60
|
+
url: str | None = None,
|
|
61
|
+
reverse_engineer: bool | None = None,
|
|
62
|
+
model: str | None = None,
|
|
63
|
+
current_mode: str = "manual",
|
|
64
|
+
) -> dict:
|
|
65
|
+
"""Prompt user for essential options interactively (Browgents style).
|
|
66
|
+
|
|
67
|
+
Shift+Tab cycles through modes: manual ↔ engineer
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
# Slash command completer
|
|
71
|
+
command_completer = WordCompleter([
|
|
72
|
+
"/settings", "/history", "/messages", "/help", "/exit", "/quit", "/commands"
|
|
73
|
+
], ignore_case=True)
|
|
74
|
+
|
|
75
|
+
# Track mode state (mutable container for closure)
|
|
76
|
+
mode_state = {"mode": current_mode, "mode_index": MODES.index(current_mode)}
|
|
77
|
+
|
|
78
|
+
# Create key bindings for mode cycling
|
|
79
|
+
kb = KeyBindings()
|
|
80
|
+
|
|
81
|
+
@kb.add("s-tab") # Shift+Tab
|
|
82
|
+
def cycle_mode(event):
|
|
83
|
+
"""Cycle to next mode."""
|
|
84
|
+
mode_state["mode_index"] = (mode_state["mode_index"] + 1) % len(MODES)
|
|
85
|
+
mode_state["mode"] = MODES[mode_state["mode_index"]]
|
|
86
|
+
# Force prompt refresh by invalidating the app
|
|
87
|
+
event.app.invalidate()
|
|
88
|
+
|
|
89
|
+
def get_prompt():
|
|
90
|
+
"""Generate prompt with current mode indicator."""
|
|
91
|
+
mode = mode_state["mode"]
|
|
92
|
+
mode_color = THEME_PRIMARY if mode == "manual" else THEME_DIM
|
|
93
|
+
return HTML(f'<style fg="{mode_color}">[{mode}]</style> <style fg="{THEME_PRIMARY}" bold="true">></style> ')
|
|
94
|
+
|
|
95
|
+
if prompt is None:
|
|
96
|
+
pt_style = PtStyle.from_dict({
|
|
97
|
+
"prompt": f"{THEME_PRIMARY} bold",
|
|
98
|
+
"": THEME_SECONDARY,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
session = PromptSession(
|
|
102
|
+
message=get_prompt, # Dynamic prompt function
|
|
103
|
+
completer=command_completer,
|
|
104
|
+
style=pt_style,
|
|
105
|
+
key_bindings=kb,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
prompt = session.prompt()
|
|
109
|
+
|
|
110
|
+
if prompt is None: # Handle Ctrl+D or Ctrl+C if not caught
|
|
111
|
+
raise click.Abort()
|
|
112
|
+
|
|
113
|
+
prompt = prompt.strip()
|
|
114
|
+
if not prompt:
|
|
115
|
+
return {"command": "/empty", "mode": mode_state["mode"]}
|
|
116
|
+
|
|
117
|
+
if prompt.startswith("/"):
|
|
118
|
+
return {"command": prompt.lower(), "mode": mode_state["mode"]}
|
|
119
|
+
|
|
120
|
+
# Return mode in all cases
|
|
121
|
+
result_mode = mode_state["mode"]
|
|
122
|
+
|
|
123
|
+
# Engineer mode: prompt is the run_id
|
|
124
|
+
if result_mode == "engineer":
|
|
125
|
+
return {
|
|
126
|
+
"mode": result_mode,
|
|
127
|
+
"run_id": prompt,
|
|
128
|
+
"model": model or config_manager.get("model", "claude-sonnet-4-5"),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Manual mode: need URL
|
|
132
|
+
if url is None:
|
|
133
|
+
url = questionary.text(
|
|
134
|
+
" > url",
|
|
135
|
+
instruction="(Enter for none)",
|
|
136
|
+
qmark="",
|
|
137
|
+
style=questionary.Style([
|
|
138
|
+
('question', f'fg:{THEME_SECONDARY}'),
|
|
139
|
+
('instruction', f'fg:{THEME_DIM} italic'),
|
|
140
|
+
])
|
|
141
|
+
).ask()
|
|
142
|
+
|
|
143
|
+
# Use settings defaults for the rest
|
|
144
|
+
if reverse_engineer is None:
|
|
145
|
+
reverse_engineer = True
|
|
146
|
+
|
|
147
|
+
if model is None:
|
|
148
|
+
model = config_manager.get("model", "claude-sonnet-4-5")
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"mode": result_mode,
|
|
152
|
+
"prompt": prompt,
|
|
153
|
+
"url": url if url else None,
|
|
154
|
+
"reverse_engineer": reverse_engineer,
|
|
155
|
+
"model": model,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@click.group(invoke_without_command=True)
|
|
160
|
+
@click.pass_context
|
|
161
|
+
@click.version_option()
|
|
162
|
+
def main(ctx: click.Context):
|
|
163
|
+
"""Reverse API - Capture browser traffic for API reverse engineering."""
|
|
164
|
+
if ctx.invoked_subcommand is None:
|
|
165
|
+
repl_loop()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def repl_loop():
|
|
169
|
+
"""Main interactive loop for the CLI."""
|
|
170
|
+
display_banner(console)
|
|
171
|
+
console.print(f" [dim]shift+tab to cycle modes: manual | engineer[/dim]")
|
|
172
|
+
display_footer(console)
|
|
173
|
+
|
|
174
|
+
current_mode = "manual"
|
|
175
|
+
|
|
176
|
+
while True:
|
|
177
|
+
try:
|
|
178
|
+
options = prompt_interactive_options(current_mode=current_mode)
|
|
179
|
+
|
|
180
|
+
# Update current mode for next iteration
|
|
181
|
+
current_mode = options.get("mode", "manual")
|
|
182
|
+
|
|
183
|
+
if "command" in options:
|
|
184
|
+
cmd = options["command"]
|
|
185
|
+
if cmd == "/empty":
|
|
186
|
+
continue
|
|
187
|
+
if cmd == "/exit" or cmd == "/quit":
|
|
188
|
+
return # Exit the loop and return to main
|
|
189
|
+
elif cmd == "/settings":
|
|
190
|
+
handle_settings()
|
|
191
|
+
elif cmd == "/history":
|
|
192
|
+
handle_history()
|
|
193
|
+
elif cmd == "/help" or cmd == "/commands":
|
|
194
|
+
handle_help()
|
|
195
|
+
elif cmd.startswith("/messages"):
|
|
196
|
+
parts = cmd.split(maxsplit=1)
|
|
197
|
+
if len(parts) > 1:
|
|
198
|
+
handle_messages(parts[1].strip())
|
|
199
|
+
else:
|
|
200
|
+
console.print(f" [dim]![/dim] [red]usage:[/red] /messages <run_id>")
|
|
201
|
+
else:
|
|
202
|
+
console.print(f" [dim]![/dim] [red]unknown command:[/red] {cmd}")
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
mode = options.get("mode", "manual")
|
|
206
|
+
|
|
207
|
+
# Handle different modes
|
|
208
|
+
if mode == "engineer":
|
|
209
|
+
# Engineer mode: only run reverse engineering on existing run_id
|
|
210
|
+
run_id = options.get("run_id")
|
|
211
|
+
if not run_id:
|
|
212
|
+
console.print(f" [dim]![/dim] [red]error:[/red] enter a run_id to reverse engineer")
|
|
213
|
+
continue
|
|
214
|
+
run_engineer(run_id, model=options.get("model"))
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
# Manual or Debug mode: run browser capture
|
|
218
|
+
run_manual_capture(
|
|
219
|
+
prompt=options["prompt"],
|
|
220
|
+
url=options["url"],
|
|
221
|
+
reverse_engineer=options["reverse_engineer"],
|
|
222
|
+
model=options["model"],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
except (click.Abort, KeyboardInterrupt):
|
|
226
|
+
console.print(f"\n [dim]terminated[/dim]")
|
|
227
|
+
return
|
|
228
|
+
except Exception as e:
|
|
229
|
+
console.print(f" [dim]![/dim] [red]error:[/red] {e}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def handle_settings():
|
|
233
|
+
"""Display and manage settings."""
|
|
234
|
+
console.print()
|
|
235
|
+
for k, v in config_manager.config.items():
|
|
236
|
+
console.print(f" [dim]>[/dim] {k:25} [white]{v}[/white]")
|
|
237
|
+
|
|
238
|
+
action = questionary.select(
|
|
239
|
+
"",
|
|
240
|
+
choices=[
|
|
241
|
+
Choice(title="> change model", value="model"),
|
|
242
|
+
Choice(title="> output directory", value="output_dir"),
|
|
243
|
+
Choice(title="> back", value="back"),
|
|
244
|
+
],
|
|
245
|
+
pointer="",
|
|
246
|
+
qmark="",
|
|
247
|
+
style=questionary.Style([
|
|
248
|
+
('highlighted', f'fg:{THEME_PRIMARY} bold'),
|
|
249
|
+
('selected', 'fg:white'),
|
|
250
|
+
])
|
|
251
|
+
).ask()
|
|
252
|
+
|
|
253
|
+
if action is None or action == "back":
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
if action == "model":
|
|
257
|
+
model_choices = [Choice(title=f"> {c['name'].lower()}", value=c['value']) for c in get_model_choices()]
|
|
258
|
+
model_choices.append(Choice(title="> back", value="back"))
|
|
259
|
+
model = questionary.select(
|
|
260
|
+
"",
|
|
261
|
+
choices=model_choices,
|
|
262
|
+
pointer="",
|
|
263
|
+
qmark="",
|
|
264
|
+
style=questionary.Style([
|
|
265
|
+
('highlighted', f'fg:{THEME_PRIMARY} bold'),
|
|
266
|
+
])
|
|
267
|
+
).ask()
|
|
268
|
+
if model and model != "back":
|
|
269
|
+
config_manager.set("model", model)
|
|
270
|
+
console.print(f" [dim]updated[/dim] {model}\n")
|
|
271
|
+
|
|
272
|
+
elif action == "output_dir":
|
|
273
|
+
current = config_manager.get("output_dir")
|
|
274
|
+
new_dir = questionary.text(
|
|
275
|
+
f" > output directory",
|
|
276
|
+
default=current or "",
|
|
277
|
+
instruction="(Enter for default ~/.reverse-api/runs)",
|
|
278
|
+
qmark="",
|
|
279
|
+
style=questionary.Style([
|
|
280
|
+
('question', f'fg:{THEME_SECONDARY}'),
|
|
281
|
+
('instruction', f'fg:{THEME_DIM} italic'),
|
|
282
|
+
])
|
|
283
|
+
).ask()
|
|
284
|
+
if new_dir is not None:
|
|
285
|
+
config_manager.set("output_dir", new_dir if new_dir.strip() else None)
|
|
286
|
+
console.print(f" [dim]updated[/dim] output directory\n")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def handle_history():
|
|
290
|
+
"""Display history of runs."""
|
|
291
|
+
history = session_manager.get_history(limit=15)
|
|
292
|
+
if not history:
|
|
293
|
+
console.print(f" [dim]> no logs found[/dim]")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
choices = []
|
|
297
|
+
for run in history:
|
|
298
|
+
cost = run.get("usage", {}).get("estimated_cost_usd", 0)
|
|
299
|
+
cost_str = f"${cost:.3f}" if cost > 0 else "-"
|
|
300
|
+
title = f"> {run['run_id']:12} {run['prompt'][:40]:40} {cost_str:>8}"
|
|
301
|
+
choices.append(Choice(title=title, value=run['run_id']))
|
|
302
|
+
|
|
303
|
+
choices.append(Choice(title="> back", value="back"))
|
|
304
|
+
|
|
305
|
+
run_id = questionary.select(
|
|
306
|
+
"",
|
|
307
|
+
choices=choices,
|
|
308
|
+
pointer="",
|
|
309
|
+
qmark="",
|
|
310
|
+
style=questionary.Style([
|
|
311
|
+
('highlighted', f'fg:{THEME_PRIMARY} bold'),
|
|
312
|
+
('selected', 'fg:white'),
|
|
313
|
+
])
|
|
314
|
+
).ask()
|
|
315
|
+
|
|
316
|
+
if not run_id or run_id == "back":
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
run = session_manager.get_run(run_id)
|
|
320
|
+
if run:
|
|
321
|
+
console.print(Panel(json.dumps(run, indent=2), border_style=THEME_DIM))
|
|
322
|
+
if questionary.confirm(" > recode?").ask():
|
|
323
|
+
model = run.get("model") or config_manager.get("model", "claude-sonnet-4-5")
|
|
324
|
+
run_engineer(run_id, model=model)
|
|
325
|
+
else:
|
|
326
|
+
console.print(f" [dim]> not found[/dim]")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def handle_help():
|
|
330
|
+
"""Show help for slash commands."""
|
|
331
|
+
console.print()
|
|
332
|
+
console.print(f" [white]commands[/white]")
|
|
333
|
+
console.print(f" [dim]>[/dim] /settings [dim]system[/dim]")
|
|
334
|
+
console.print(f" [dim]>[/dim] /history [dim]logs[/dim]")
|
|
335
|
+
console.print(f" [dim]>[/dim] /messages [dim]view run messages[/dim]")
|
|
336
|
+
console.print(f" [dim]>[/dim] /help [dim]help[/dim]")
|
|
337
|
+
console.print(f" [dim]>[/dim] /exit [dim]quit[/dim]")
|
|
338
|
+
console.print()
|
|
339
|
+
console.print(f" [white]modes[/white] [dim](shift+tab to cycle)[/dim]")
|
|
340
|
+
console.print(f" [dim]>[/dim] manual [dim]full pipeline: browser + reverse engineering[/dim]")
|
|
341
|
+
console.print(f" [dim]>[/dim] engineer [dim]reverse engineer only (enter run_id)[/dim]")
|
|
342
|
+
console.print()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def handle_messages(run_id: str):
|
|
346
|
+
"""Display messages from a previous run."""
|
|
347
|
+
store = MessageStore(run_id)
|
|
348
|
+
messages = store.load()
|
|
349
|
+
|
|
350
|
+
if not messages:
|
|
351
|
+
console.print(f" [dim]>[/dim] [red]no messages found for run:[/red] {run_id}")
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
console.print()
|
|
355
|
+
console.print(f" [white]messages for {run_id}[/white]")
|
|
356
|
+
console.print()
|
|
357
|
+
|
|
358
|
+
for msg in messages:
|
|
359
|
+
msg_type = msg.get("type", "unknown")
|
|
360
|
+
content = msg.get("content", "")
|
|
361
|
+
timestamp = msg.get("timestamp", "")[:19] # Truncate to datetime
|
|
362
|
+
|
|
363
|
+
if msg_type == "prompt":
|
|
364
|
+
console.print(f" [dim]{timestamp}[/dim] [white]prompt[/white]")
|
|
365
|
+
# Show first 200 chars of prompt
|
|
366
|
+
display = str(content)[:200]
|
|
367
|
+
if len(str(content)) > 200:
|
|
368
|
+
display += "..."
|
|
369
|
+
console.print(f" [dim]{display}[/dim]")
|
|
370
|
+
elif msg_type == "tool_start":
|
|
371
|
+
name = content.get("name", "tool")
|
|
372
|
+
console.print(f" [dim]{timestamp}[/dim] [white]{name.lower()}[/white]")
|
|
373
|
+
elif msg_type == "tool_result":
|
|
374
|
+
name = content.get("name", "tool")
|
|
375
|
+
is_error = content.get("is_error", False)
|
|
376
|
+
status = "[red]error[/red]" if is_error else "[dim]ok[/dim]"
|
|
377
|
+
console.print(f" [dim]{timestamp}[/dim] {status}")
|
|
378
|
+
elif msg_type == "thinking":
|
|
379
|
+
display = str(content)[:100].replace("\\n", " ")
|
|
380
|
+
if len(str(content)) > 100:
|
|
381
|
+
display += "..."
|
|
382
|
+
console.print(f" [dim]{timestamp} .. {display}[/dim]")
|
|
383
|
+
elif msg_type == "error":
|
|
384
|
+
console.print(f" [dim]{timestamp}[/dim] [red]error: {content}[/red]")
|
|
385
|
+
elif msg_type == "result":
|
|
386
|
+
console.print(f" [dim]{timestamp}[/dim] [white]complete[/white]")
|
|
387
|
+
if isinstance(content, dict):
|
|
388
|
+
script_path = content.get("script_path", "")
|
|
389
|
+
if script_path:
|
|
390
|
+
console.print(f" [dim]{script_path}[/dim]")
|
|
391
|
+
|
|
392
|
+
console.print()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@main.command()
|
|
397
|
+
@click.option("--prompt", "-p", default=None, help="Capture description.")
|
|
398
|
+
@click.option("--url", "-u", default=None, help="Starting URL.")
|
|
399
|
+
@click.option("--reverse-engineer/--no-engineer", "reverse_engineer", default=True, help="Auto-run Claude.")
|
|
400
|
+
@click.option("--model", "-m", type=click.Choice(["claude-sonnet-4-5", "claude-opus-4-5", "claude-haiku-4-5"]), default=None)
|
|
401
|
+
@click.option("--output-dir", "-o", default=None, help="Custom output directory.")
|
|
402
|
+
def manual(prompt, url, reverse_engineer, model, output_dir):
|
|
403
|
+
"""Start a manual browser session."""
|
|
404
|
+
run_manual_capture(prompt, url, reverse_engineer, model, output_dir)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def run_manual_capture(prompt=None, url=None, reverse_engineer=True, model=None, output_dir=None):
|
|
408
|
+
"""Shared logic for manual capture."""
|
|
409
|
+
output_dir = output_dir or config_manager.get("output_dir")
|
|
410
|
+
|
|
411
|
+
if prompt is None:
|
|
412
|
+
options = prompt_interactive_options(
|
|
413
|
+
prompt=prompt,
|
|
414
|
+
url=url,
|
|
415
|
+
reverse_engineer=reverse_engineer,
|
|
416
|
+
model=model,
|
|
417
|
+
)
|
|
418
|
+
if "command" in options:
|
|
419
|
+
return # Should not happen from here
|
|
420
|
+
prompt = options["prompt"]
|
|
421
|
+
url = options["url"]
|
|
422
|
+
reverse_engineer = options["reverse_engineer"]
|
|
423
|
+
model = options["model"]
|
|
424
|
+
|
|
425
|
+
run_id = generate_run_id()
|
|
426
|
+
timestamp = get_timestamp()
|
|
427
|
+
|
|
428
|
+
# Record initial session
|
|
429
|
+
session_manager.add_run(
|
|
430
|
+
run_id=run_id,
|
|
431
|
+
prompt=prompt,
|
|
432
|
+
timestamp=timestamp,
|
|
433
|
+
url=url,
|
|
434
|
+
model=model,
|
|
435
|
+
paths={
|
|
436
|
+
"har_dir": str(get_har_dir(run_id, output_dir))
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
browser = ManualBrowser(run_id=run_id, prompt=prompt, output_dir=output_dir)
|
|
441
|
+
har_path = browser.start(start_url=url)
|
|
442
|
+
|
|
443
|
+
if reverse_engineer:
|
|
444
|
+
result = run_engineer(
|
|
445
|
+
run_id=run_id,
|
|
446
|
+
har_path=har_path,
|
|
447
|
+
prompt=prompt,
|
|
448
|
+
model=model,
|
|
449
|
+
output_dir=output_dir
|
|
450
|
+
)
|
|
451
|
+
if result:
|
|
452
|
+
session_manager.update_run(
|
|
453
|
+
run_id=run_id,
|
|
454
|
+
usage=result.get("usage", {}),
|
|
455
|
+
paths={"script_path": result.get("script_path")}
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@main.command()
|
|
460
|
+
@click.argument("run_id")
|
|
461
|
+
@click.option("--model", "-m", type=click.Choice(["claude-sonnet-4-5", "claude-opus-4-5", "claude-haiku-4-5"]), default=None)
|
|
462
|
+
@click.option("--output-dir", "-o", default=None, help="Custom output directory.")
|
|
463
|
+
def engineer(run_id, model, output_dir):
|
|
464
|
+
"""Run reverse engineering on a previous run."""
|
|
465
|
+
run_engineer(run_id, model=model, output_dir=output_dir)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def run_engineer(run_id, har_path=None, prompt=None, model=None, output_dir=None):
|
|
469
|
+
"""Shared logic for reverse engineering."""
|
|
470
|
+
if not har_path or not prompt:
|
|
471
|
+
# Load from history if possible
|
|
472
|
+
run_data = session_manager.get_run(run_id)
|
|
473
|
+
if not run_data:
|
|
474
|
+
# Fallback to file search if not in history
|
|
475
|
+
har_dir = get_har_dir(run_id, output_dir)
|
|
476
|
+
har_path = har_dir / "recording.har"
|
|
477
|
+
if not har_path.exists():
|
|
478
|
+
console.print(f" [dim]![/dim] [red]not found:[/red] {run_id}")
|
|
479
|
+
return None
|
|
480
|
+
prompt = "Reverse engineer captured APIs" # Default
|
|
481
|
+
else:
|
|
482
|
+
prompt = run_data["prompt"]
|
|
483
|
+
# Detect where it was saved
|
|
484
|
+
paths = run_data.get("paths", {})
|
|
485
|
+
har_dir = Path(paths.get("har_dir", get_har_dir(run_id, None)))
|
|
486
|
+
har_path = har_dir / "recording.har"
|
|
487
|
+
|
|
488
|
+
result = run_reverse_engineering(
|
|
489
|
+
run_id=run_id,
|
|
490
|
+
har_path=har_path,
|
|
491
|
+
prompt=prompt,
|
|
492
|
+
model=model or config_manager.get("model", "claude-sonnet-4-5"),
|
|
493
|
+
output_dir=output_dir
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
if result:
|
|
497
|
+
# Automatically copy scripts to current directory with a readable name
|
|
498
|
+
scripts_dir = Path(result["script_path"]).parent
|
|
499
|
+
base_name = generate_folder_name(prompt)
|
|
500
|
+
folder_name = base_name
|
|
501
|
+
local_dir = Path.cwd() / "scripts" / folder_name
|
|
502
|
+
|
|
503
|
+
# Handle existing folder - append suffix if needed
|
|
504
|
+
counter = 2
|
|
505
|
+
while local_dir.exists():
|
|
506
|
+
folder_name = f"{base_name}_{counter}"
|
|
507
|
+
local_dir = Path.cwd() / "scripts" / folder_name
|
|
508
|
+
counter += 1
|
|
509
|
+
|
|
510
|
+
local_dir.mkdir(parents=True, exist_ok=True)
|
|
511
|
+
|
|
512
|
+
import shutil
|
|
513
|
+
for item in scripts_dir.iterdir():
|
|
514
|
+
if item.is_file():
|
|
515
|
+
shutil.copy2(item, local_dir / item.name)
|
|
516
|
+
|
|
517
|
+
console.print(f" [dim]>[/dim] [white]decoding complete[/white]")
|
|
518
|
+
console.print(f" [dim]>[/dim] [white]{result['script_path']}[/white]")
|
|
519
|
+
console.print(f" [dim]>[/dim] [white]copied to ./scripts/{folder_name}[/white]\n")
|
|
520
|
+
|
|
521
|
+
session_manager.update_run(
|
|
522
|
+
run_id=run_id,
|
|
523
|
+
usage=result.get("usage", {}),
|
|
524
|
+
paths={"script_path": result.get("script_path")}
|
|
525
|
+
)
|
|
526
|
+
return result
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
if __name__ == "__main__":
|
|
530
|
+
main()
|
reverse_api/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Configuration management for reverse-api."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
DEFAULT_CONFIG = {
|
|
8
|
+
"model": "claude-sonnet-4-5",
|
|
9
|
+
"output_dir": None, # None means use ~/.reverse-api/runs
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigManager:
|
|
14
|
+
"""Handles user settings and persistence."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config_path: Path):
|
|
17
|
+
self.config_path = config_path
|
|
18
|
+
self.config = DEFAULT_CONFIG.copy()
|
|
19
|
+
self.load()
|
|
20
|
+
|
|
21
|
+
def load(self):
|
|
22
|
+
"""Load configuration from disk."""
|
|
23
|
+
if self.config_path.exists():
|
|
24
|
+
try:
|
|
25
|
+
with open(self.config_path, "r") as f:
|
|
26
|
+
user_config = json.load(f)
|
|
27
|
+
# Only keep valid keys
|
|
28
|
+
valid_config = {k: v for k, v in user_config.items() if k in self.config}
|
|
29
|
+
self.config.update(valid_config)
|
|
30
|
+
except (json.JSONDecodeError, OSError):
|
|
31
|
+
# Fallback to defaults if file is corrupted
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def save(self):
|
|
35
|
+
"""Save configuration to disk."""
|
|
36
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
with open(self.config_path, "w") as f:
|
|
38
|
+
json.dump(self.config, f, indent=4)
|
|
39
|
+
|
|
40
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
41
|
+
"""Get a configuration value."""
|
|
42
|
+
return self.config.get(key, default)
|
|
43
|
+
|
|
44
|
+
def set(self, key: str, value: Any):
|
|
45
|
+
"""Set a configuration value and save."""
|
|
46
|
+
self.config[key] = value
|
|
47
|
+
self.save()
|
|
48
|
+
|
|
49
|
+
def update(self, settings: Dict[str, Any]):
|
|
50
|
+
"""Update multiple settings and save."""
|
|
51
|
+
self.config.update(settings)
|
|
52
|
+
self.save()
|