Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ CLI tool that captures browser traffic and automatically generates production-re
- 🌐 **Browser Automation**: Built on Playwright with stealth mode for realistic browsing
- 📊 **HAR Recording**: Captures all network traffic in HTTP Archive format
- 🤖 **AI-Powered Generation**: Uses Claude 4.5 to analyze traffic and generate clean Python code
- 🔌 **OpenCode SDK Support**: Native integration with OpenCode SDK for more flexibility
- 💻 **Interactive CLI**: Minimalist terminal interface with mode cycling (Shift+Tab)
- 📦 **Production Ready**: Generated scripts include error handling, type hints, and documentation
- 💾 **Session History**: All runs saved locally with full message logs
Expand Down Expand Up @@ -115,10 +116,19 @@ Settings are stored in `~/.reverse-api/config.json`:
```json
{
"model": "claude-sonnet-4-5",
"sdk": "claude",
"output_dir": null
}
```

### SDK Selection

Choose between two SDKs:
- **OpenCode**: Uses OpenCode SDK for AI-powered reverse engineering. Requires OpenCode to be running locally.
- **Claude** (default): Direct integration with Anthropic's Claude API.

Change SDK in `/settings` or edit `config.json` directly. When using OpenCode SDK, ensure OpenCode is running (`opencode` command).

## 📁 Project Structure

```
Expand Down Expand Up @@ -160,11 +170,9 @@ Generated `api_client.py` includes:
## 🗺️ Roadmap

### SDK Support
Expanding support for additional SDKs and platforms:
- **OpenCode** - Integration with OpenCode SDK
- **Cursor Agent CLI** - Support for Cursor's agent CLI
- **Droid** - Android SDK integration
- **Codex** - Codex SDK support
- ✅ **Claude** - Integration with Claude Code
- ✅ **OpenCode** - Integration with OpenCode
- 🔄 **Codex** - Codex SDK support

### Fully Automated Extraction
Adding browser agent capabilities for fully automated API extraction:
Expand Down Expand Up @@ -193,7 +201,7 @@ uv build
## 🔐 Requirements

- Python 3.10+
- Claude Code
- Claude Code / OpenCode
- Playwright browsers installed

## 🤝 Contributing
Expand Down
70 changes: 70 additions & 0 deletions src/reverse_api/base_engineer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Abstract base class for API reverse engineering."""

from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional, Dict, Any

from .utils import get_scripts_dir, get_timestamp
from .tui import ClaudeUI
from .messages import MessageStore


class BaseEngineer(ABC):
"""Abstract base class for API reverse engineering implementations."""

def __init__(
self,
run_id: str,
har_path: Path,
prompt: str,
model: Optional[str] = None,
additional_instructions: Optional[str] = None,
output_dir: Optional[str] = None,
verbose: bool = True,
):
self.run_id = run_id
self.har_path = har_path
self.prompt = prompt
self.model = model
self.additional_instructions = additional_instructions
self.scripts_dir = get_scripts_dir(run_id, output_dir)
self.ui = ClaudeUI(verbose=verbose)
self.usage_metadata: Dict[str, Any] = {}
self.message_store = MessageStore(run_id, output_dir)

def _build_analysis_prompt(self) -> str:
"""Build the prompt for analyzing the HAR file."""
base_prompt = f"""Analyze the HAR file at {self.har_path} and reverse engineer the APIs captured.

Original user prompt: {self.prompt}

Your task:
1. Read and analyze the HAR file to understand the API calls made
2. Identify authentication patterns (cookies, tokens, headers)
3. Extract request/response patterns for each endpoint
4. Generate a clean, well-documented Python script that replicates these API calls

The Python script should:
- Use the `requests` library
- Include proper authentication handling
- Have functions for each distinct API endpoint
- Include type hints and docstrings
- Handle errors gracefully
- Be production-ready

Save the generated Python script to: {self.scripts_dir / 'api_client.py'}
Also create a brief README.md in the same folder explaining the APIs discovered.
Always test your implementation to ensure it works. If it doesn't try again if you think you can fix it. You can go up to 5 attempts.
Sometimes websites have bot detection and that kind of things so keep in mind.
If you see you can't achieve with requests, feel free to use playwright with the real user browser with CDP to bypass bot detection.
No matter which implementation you choose, always try to make it production ready and test it.
"""
if self.additional_instructions:
base_prompt += f"\n\nAdditional instructions:\n{self.additional_instructions}"

return base_prompt

@abstractmethod
async def analyze_and_generate(self) -> Optional[Dict[str, Any]]:
"""Run the reverse engineering analysis. Must be implemented by subclasses."""
pass
19 changes: 12 additions & 7 deletions src/reverse_api/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page
from playwright_stealth import Stealth
from rich.console import Console
from rich.status import Status

from .utils import get_har_dir, get_timestamp
from .tui import THEME_PRIMARY, THEME_DIM, THEME_SUCCESS
Expand Down Expand Up @@ -264,7 +265,7 @@ def _start_with_real_chrome(self, start_url: Optional[str] = None) -> Path:
# Wait for browser to close
try:
while self._context.pages:
self._context.pages[0].wait_for_timeout(500)
self._context.pages[0].wait_for_timeout(100) # Faster polling
except Exception:
pass

Expand Down Expand Up @@ -354,7 +355,7 @@ def _start_with_stealth_chromium(self, start_url: Optional[str] = None) -> Path:
# Wait for browser to close
try:
while self._context.pages:
self._context.pages[0].wait_for_timeout(500)
self._context.pages[0].wait_for_timeout(100) # Faster polling
except Exception:
pass

Expand Down Expand Up @@ -388,12 +389,16 @@ def close(self) -> Path:
"""Close the browser and save HAR file. Returns HAR path."""
end_time = get_timestamp()

console.print(f" [dim]browser closed[/dim]")

if self._context:
try:
self._context.close() # This saves the HAR file
except Exception:
pass
self._context = None
with Status(" [dim]handling har... can take a bit[/dim]", console=console, spinner="dots") as status:
try:
status.update(" [dim]saving har file...[/dim]")
self._context.close() # This saves the HAR file
except Exception:
pass
self._context = None

# Only close browser if not using persistent context
if self._browser and not self._using_persistent:
Expand Down
91 changes: 62 additions & 29 deletions src/reverse_api/cli.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import sys
from pathlib import Path
import json

import click
import questionary
from questionary import Choice
from prompt_toolkit.completion import WordCompleter
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.text import Text
from rich.box import MINIMAL, ROUNDED

from .browser import ManualBrowser
from .utils import (
Expand All @@ -19,7 +14,6 @@
get_config_path,
get_history_path,
get_har_dir,
get_scripts_dir,
get_timestamp,
)
from .tui import (
Expand All @@ -29,8 +23,6 @@
THEME_PRIMARY,
THEME_SECONDARY,
THEME_DIM,
THEME_SUCCESS,
THEME_ERROR,
)
from .config import ConfigManager
from .session import SessionManager
Expand All @@ -41,6 +33,8 @@
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.styles import Style as PtStyle
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory


console = Console()
Expand Down Expand Up @@ -68,9 +62,25 @@ def prompt_interactive_options(
"""

# Slash command completer
command_completer = WordCompleter([
commands = [
"/settings", "/history", "/messages", "/help", "/exit", "/quit", "/commands"
], ignore_case=True)
]

class FilteredCompleter(Completer):
def get_completions(self, document, complete_event):
text = document.text_before_cursor
if not text.startswith('/'):
return

# Only suggest if we are still on the first word (the command)
if ' ' in text:
return

for cmd in commands:
if cmd.startswith(text):
yield Completion(cmd, start_position=-len(text))

command_completer = FilteredCompleter()

# Track mode state (mutable container for closure)
mode_state = {"mode": current_mode, "mode_index": MODES.index(current_mode)}
Expand Down Expand Up @@ -101,6 +111,8 @@ def get_prompt():
session = PromptSession(
message=get_prompt, # Dynamic prompt function
completer=command_completer,
auto_suggest=AutoSuggestFromHistory(),
complete_while_typing=True,
style=pt_style,
key_bindings=kb,
)
Expand Down Expand Up @@ -168,7 +180,7 @@ def main(ctx: click.Context):
def repl_loop():
"""Main interactive loop for the CLI."""
display_banner(console)
console.print(f" [dim]shift+tab to cycle modes: manual | engineer[/dim]")
console.print(" [dim]shift+tab to cycle modes: manual | engineer[/dim]")
display_footer(console)

current_mode = "manual"
Expand Down Expand Up @@ -197,7 +209,7 @@ def repl_loop():
if len(parts) > 1:
handle_messages(parts[1].strip())
else:
console.print(f" [dim]![/dim] [red]usage:[/red] /messages <run_id>")
console.print(" [dim]![/dim] [red]usage:[/red] /messages <run_id>")
else:
console.print(f" [dim]![/dim] [red]unknown command:[/red] {cmd}")
continue
Expand All @@ -209,7 +221,7 @@ def repl_loop():
# Engineer mode: only run reverse engineering on existing run_id
run_id = options.get("run_id")
if not run_id:
console.print(f" [dim]![/dim] [red]error:[/red] enter a run_id to reverse engineer")
console.print(" [dim]![/dim] [red]error:[/red] enter a run_id to reverse engineer")
continue
run_engineer(run_id, model=options.get("model"))
continue
Expand All @@ -223,7 +235,7 @@ def repl_loop():
)

except (click.Abort, KeyboardInterrupt):
console.print(f"\n [dim]terminated[/dim]")
console.print("\n [dim]terminated[/dim]")
return
except Exception as e:
console.print(f" [dim]![/dim] [red]error:[/red] {e}")
Expand All @@ -239,6 +251,7 @@ def handle_settings():
"",
choices=[
Choice(title="> change model", value="model"),
Choice(title="> change sdk", value="sdk"),
Choice(title="> output directory", value="output_dir"),
Choice(title="> back", value="back"),
],
Expand Down Expand Up @@ -269,10 +282,29 @@ def handle_settings():
config_manager.set("model", model)
console.print(f" [dim]updated[/dim] {model}\n")

elif action == "sdk":
sdk_choices = [
Choice(title="> opencode", value="opencode"),
Choice(title="> claude", value="claude"),
Choice(title="> back", value="back"),
]
sdk = questionary.select(
"",
choices=sdk_choices,
pointer="",
qmark="",
style=questionary.Style([
('highlighted', f'fg:{THEME_PRIMARY} bold'),
])
).ask()
if sdk and sdk != "back":
config_manager.set("sdk", sdk)
console.print(f" [dim]updated[/dim] sdk: {sdk}\n")

elif action == "output_dir":
current = config_manager.get("output_dir")
new_dir = questionary.text(
f" > output directory",
" > output directory",
default=current or "",
instruction="(Enter for default ~/.reverse-api/runs)",
qmark="",
Expand All @@ -283,14 +315,14 @@ def handle_settings():
).ask()
if new_dir is not None:
config_manager.set("output_dir", new_dir if new_dir.strip() else None)
console.print(f" [dim]updated[/dim] output directory\n")
console.print(" [dim]updated[/dim] output directory\n")


def handle_history():
"""Display history of runs."""
history = session_manager.get_history(limit=15)
if not history:
console.print(f" [dim]> no logs found[/dim]")
console.print(" [dim]> no logs found[/dim]")
return

choices = []
Expand Down Expand Up @@ -323,22 +355,22 @@ def handle_history():
model = run.get("model") or config_manager.get("model", "claude-sonnet-4-5")
run_engineer(run_id, model=model)
else:
console.print(f" [dim]> not found[/dim]")
console.print(" [dim]> not found[/dim]")


def handle_help():
"""Show help for slash commands."""
console.print()
console.print(f" [white]commands[/white]")
console.print(f" [dim]>[/dim] /settings [dim]system[/dim]")
console.print(f" [dim]>[/dim] /history [dim]logs[/dim]")
console.print(f" [dim]>[/dim] /messages [dim]view run messages[/dim]")
console.print(f" [dim]>[/dim] /help [dim]help[/dim]")
console.print(f" [dim]>[/dim] /exit [dim]quit[/dim]")
console.print(" [white]commands[/white]")
console.print(" [dim]>[/dim] /settings [dim]system[/dim]")
console.print(" [dim]>[/dim] /history [dim]logs[/dim]")
console.print(" [dim]>[/dim] /messages [dim]view run messages[/dim]")
console.print(" [dim]>[/dim] /help [dim]help[/dim]")
console.print(" [dim]>[/dim] /exit [dim]quit[/dim]")
console.print()
console.print(f" [white]modes[/white] [dim](shift+tab to cycle)[/dim]")
console.print(f" [dim]>[/dim] manual [dim]full pipeline: browser + reverse engineering[/dim]")
console.print(f" [dim]>[/dim] engineer [dim]reverse engineer only (enter run_id)[/dim]")
console.print(" [white]modes[/white] [dim](shift+tab to cycle)[/dim]")
console.print(" [dim]>[/dim] manual [dim]full pipeline: browser + reverse engineering[/dim]")
console.print(" [dim]>[/dim] engineer [dim]reverse engineer only (enter run_id)[/dim]")
console.print()


Expand Down Expand Up @@ -490,7 +522,8 @@ def run_engineer(run_id, har_path=None, prompt=None, model=None, output_dir=None
har_path=har_path,
prompt=prompt,
model=model or config_manager.get("model", "claude-sonnet-4-5"),
output_dir=output_dir
output_dir=output_dir,
sdk=config_manager.get("sdk", "opencode"),
)

if result:
Expand All @@ -514,7 +547,7 @@ def run_engineer(run_id, har_path=None, prompt=None, model=None, output_dir=None
if item.is_file():
shutil.copy2(item, local_dir / item.name)

console.print(f" [dim]>[/dim] [white]decoding complete[/white]")
console.print(" [dim]>[/dim] [white]decoding complete[/white]")
console.print(f" [dim]>[/dim] [white]{result['script_path']}[/white]")
console.print(f" [dim]>[/dim] [white]copied to ./scripts/{folder_name}[/white]\n")

Expand Down
Loading