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/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">&gt;</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()