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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,55 @@ Use these slash commands while in the CLI:
- `/help` - Show all commands
- `/exit` - Quit

### Scripted / Agent Usage

The CLI subcommands can be driven by another agent or a wrapper script. Pass `--no-interactive` (and/or `--json`) so they fail fast instead of opening questionary prompts, and pipe the structured output into `jq`.

When `--json` is set: stdout contains exactly one JSON document (the final result), Rich logs and progress are diverted to stderr, and the process exits with a stable code.

```bash
# Run an autonomous agent capture and get a single JSON result on stdout
reverse-api-engineer agent \
--prompt "capture the public jobs api" \
--url https://example.com/jobs \
--json | jq

# List runs / inspect a run as JSON (empty history -> [])
reverse-api-engineer list --json
reverse-api-engineer show <run_id> --json

# Run a generated script non-interactively
# --no-interactive : never open the script-picker / install confirm
# --auto-install : install missing deps on retry without asking
reverse-api-engineer run <run_id> --file api_client.py \
--no-interactive --auto-install -- --org acme
```

#### `agent --json` output schema

| Field | Type | Notes |
|------------------|---------------------|------------------------------------------------------------------------|
| `schema_version` | `int` | Currently `1`. Bumped on breaking changes. |
| `status` | `"ok"` \| `"error"` | Top-level result. |
| `run_id` | `string` \| `null` | Stable id for follow-up `show` / `engineer` / `run` calls. |
| `prompt` | `string` | The prompt that was passed in. |
| `url` | `string` \| `null` | Optional starting URL. |
| `mode` | `string` \| `null` | Provider used (`"auto"` for Playwright MCP, `"chrome-mcp"`). |
| `har_path` | `string` \| `null` | Absolute path to the captured HAR (`recording.har`). |
| `script_path` | `string` \| `null` | Absolute path to the generated client when reverse engineering ran. |
| `usage` | `object` | Token + cost usage from the engineer SDK (`{input_tokens, output_tokens, total_cost}`).|
| `error` | `string` \| `null` | Human-readable error message when `status == "error"`. |

#### Exit codes

| Code | Meaning |
|------|---------------------------------------------------------------------------|
| `0` | Success. |
| `1` | Runtime error (capture or engineering failed; details in `error`). |
| `2` | Misuse — required arg missing under `--no-interactive` / `--json`. |

For `run`, the exit code is the underlying script's return code on success, or `1` if no script was found, or non-zero if `--no-interactive` would otherwise have to prompt.

## 🔌 Claude Code Plugin

Install the plugin in [Claude Code](https://claude.com/claude-code):
Expand Down
94 changes: 68 additions & 26 deletions src/reverse_api/auto_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ def __init__(
**kwargs,
):
"""Initialize auto engineer with expected HAR path (created by MCP)."""
# `headless` is auto-engineer specific (controls the MCP-spawned browser),
# not BaseEngineer concept; pop before super() to avoid an unknown kwarg.
headless = kwargs.pop("headless", False)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"

Expand All @@ -50,6 +53,7 @@ def __init__(
)
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.headless = headless

def _build_auto_prompts(self) -> tuple[str, str]:
"""Build (system_prompt, user_message) for auto mode.
Expand Down Expand Up @@ -112,22 +116,35 @@ async def _handle_tool_permission(self, tool_name: str, input_data: dict[str, An
return PermissionResultAllow(updated_input=input_data)

def _get_mcp_config(self) -> tuple[str, dict]:
"""Return (server_name, mcp_config) based on agent_provider."""
"""Return (server_name, mcp_config) based on agent_provider.

Auto-connect requires a real headed Chrome instance with a remote
debugging server, so it is dropped in headless mode and the MCP
spawns its own headless Chromium instead.
"""
if self.agent_provider == "chrome-mcp":
args = ["chrome-devtools-mcp@latest", "--no-usage-statistics"]
if self.headless:
args.append("--headless")
else:
args.append("--autoConnect")
return "chrome-devtools", {
"type": "stdio",
"command": "npx",
"args": ["chrome-devtools-mcp@latest", "--autoConnect", "--no-usage-statistics"],
"args": args,
}
playwright_args = [
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
]
if self.headless:
playwright_args.append("--headless")
return "playwright", {
"type": "stdio",
"command": "npx",
"args": [
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
],
"args": playwright_args,
}

async def analyze_and_generate(self) -> dict[str, Any] | None:
Expand Down Expand Up @@ -210,6 +227,7 @@ class OpenCodeAutoEngineer(OpenCodeEngineer):

def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agent_provider: str = "auto", **kwargs):
"""Initialize auto engineer with expected HAR path (created by MCP)."""
headless = kwargs.pop("headless", False)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"

Expand All @@ -223,36 +241,50 @@ def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agen
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.mcp_name = None
self.headless = headless

def _get_active_prompts(self) -> tuple[str, str]:
return ClaudeAutoEngineer._build_auto_prompts(self)

def _get_opencode_mcp_config(self) -> dict:
"""Return OpenCode MCP registration payload based on agent_provider."""
"""Return OpenCode MCP registration payload based on agent_provider.

Auto-connect requires a headed Chrome with a remote debugging server,
so it is dropped in headless mode in favor of an MCP-spawned headless
Chromium.
"""
if self.agent_provider == "chrome-mcp":
self.mcp_name = f"chrome-devtools-{self._session_id}"
cmd = ["npx", "-y", "chrome-devtools-mcp@latest", "--no-usage-statistics"]
if self.headless:
cmd.append("--headless")
else:
cmd.append("--autoConnect")
return {
"name": self.mcp_name,
"config": {
"type": "local",
"command": ["npx", "-y", "chrome-devtools-mcp@latest", "--autoConnect", "--no-usage-statistics"],
"command": cmd,
"enabled": True,
"timeout": 30000,
},
}
self.mcp_name = f"playwright-{self._session_id}"
cmd = [
"npx",
"-y",
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
]
if self.headless:
cmd.append("--headless")
return {
"name": self.mcp_name,
"config": {
"type": "local",
"command": [
"npx",
"-y",
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
],
"command": cmd,
"enabled": True,
"timeout": 30000,
},
Expand Down Expand Up @@ -450,6 +482,7 @@ def __init__(
):
from .copilot_engineer import CopilotEngineer

headless = kwargs.pop("headless", False)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"

Expand All @@ -463,6 +496,7 @@ def __init__(
)
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.headless = headless

def start_sync(self) -> None:
self._engineer.start_sync()
Expand Down Expand Up @@ -526,25 +560,33 @@ def on_event(event: Any) -> None:

if self.agent_provider == "chrome-mcp":
mcp_server_name = "chrome-devtools"
chrome_args = ["-y", "chrome-devtools-mcp@latest", "--no-usage-statistics"]
if self.headless:
chrome_args.append("--headless")
else:
chrome_args.append("--autoConnect")
mcp_config = {
"type": "local",
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect", "--no-usage-statistics"],
"args": chrome_args,
"tools": ["*"],
"timeout": 30000,
}
else:
mcp_server_name = "playwright"
pw_args = [
"-y",
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
]
if self.headless:
pw_args.append("--headless")
mcp_config = {
"type": "local",
"command": "npx",
"args": [
"-y",
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
],
"args": pw_args,
"tools": ["*"],
"timeout": 30000,
}
Expand Down
16 changes: 14 additions & 2 deletions src/reverse_api/base_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def __init__(
is_fresh: bool = False,
output_language: str = "python",
output_mode: str = "client",
interactive: bool = True,
):
self.run_id = run_id
self.har_path = har_path
Expand All @@ -67,6 +68,10 @@ def __init__(
self.sync_watcher: FileSyncWatcher | None = None
self.local_scripts_dir: Path | None = None
self._stderr_error_shown = False
# When False, _prompt_follow_up() returns None immediately so the
# conversation loop in subclasses ends after the first generation.
# Set this from --json / --no-interactive entry points.
self.interactive = interactive

def _handle_cli_stderr(self, line: str) -> None:
"""Filter CLI subprocess stderr. Shows full output in DEBUG mode, otherwise shows a single clean error."""
Expand Down Expand Up @@ -279,9 +284,16 @@ async def _ask_user_interactive(self, questions: list[dict[str, Any]]) -> dict[s
async def _prompt_follow_up(self) -> str | None:
"""Prompt user for a follow-up message. Returns None to finish.

Uses plain input() via executor instead of questionary to avoid
terminal state issues after the SDK subprocess exits.
In non-interactive mode (e.g. --json / --no-interactive) returns None
immediately so the conversation loop terminates after the first
generation. Otherwise uses plain input() via executor instead of
questionary to avoid terminal state issues after the SDK subprocess
exits.
"""
if not self.interactive:
# Still flush sync so any partial output reaches disk before we exit.
self.flush_sync()
return None
# Ensure all files are synced locally before waiting for user input
self.flush_sync()
self.ui.console.print()
Expand Down
Loading
Loading