Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Settings live in `~/.reverse-api/config.json` and can be edited via `/settings`
"opencode_model": "claude-sonnet-4-6",
"opencode_provider": "anthropic",
"copilot_model": "gpt-5",
"cursor_model": "composer-2",
"cursor_model": "composer-2.5",
"output_dir": null,
"output_language": "python",
"real_time_sync": true,
Expand Down
2 changes: 1 addition & 1 deletion src/reverse_api/auto_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ async def _handle_tool_permission(self, tool_name: str, input_data: dict[str, An
"""Handle tool permission requests, with interactive UI for AskUserQuestion."""
if tool_name == "AskUserQuestion":
questions = input_data.get("questions", [])
answers = await self._ask_user_interactive(questions)
answers = await self._ask_user_questions(questions)
return PermissionResultAllow(
updated_input={"questions": questions, "answers": answers},
)
Expand Down
39 changes: 39 additions & 0 deletions src/reverse_api/base_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@

OTHER_OPTION = "Other (type your answer)"

NON_INTERACTIVE_ASK_USER_MESSAGE = (
"The user is running the CLI in non-interactive mode and cannot answer. "
"Assume the best reasonable answer from context and continue. "
"If you truly cannot proceed without a human choice, stop and instruct the caller "
"to start a new session with a clearer, more specific prompt."
)


class BaseEngineer(ABC):
"""Abstract base class for API reverse engineering implementations."""
Expand Down Expand Up @@ -72,6 +79,19 @@ def __init__(
# conversation loop in subclasses ends after the first generation.
# Set this from --json / --no-interactive entry points.
self.interactive = interactive
self._json_event_sink: Any = None

def _emit_json_event(self, event: dict[str, Any]) -> None:
sink = self._json_event_sink
if sink:
sink(event)

@staticmethod
def _is_ask_user_tool_name(tool_name: str) -> bool:
n = tool_name.lower().replace("-", "_")
if n == "askuserquestion":
return True
return "ask" in n and "user" in n and "question" in n

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 @@ -147,6 +167,25 @@ def get_sync_status(self) -> dict | None:
return self.sync_watcher.get_status()
return None

async def _ask_user_questions(self, questions: list[dict[str, Any]]) -> dict[str, str]:
"""Resolve AskUserQuestion prompts (interactive UI or non-interactive stub)."""
if not self.interactive:
answers: dict[str, str] = {}
count = 0
for q in questions:
question_text = q.get("question", "") if isinstance(q, dict) else getattr(q, "question", "")
if not question_text:
continue
answers[question_text] = NON_INTERACTIVE_ASK_USER_MESSAGE
count += 1
if count:
self.ui.console.print(
f" [dim]AskUserQuestion skipped ({count} question(s); non-interactive mode)[/dim]"
)
self._emit_json_event({"event": "ask_user_skipped", "count": count})
return answers
return await self._ask_user_interactive(questions)

async def _ask_user_interactive(self, questions: list[dict[str, Any]]) -> dict[str, str]:
"""Prompt the user interactively for answers to questions.

Expand Down
99 changes: 79 additions & 20 deletions src/reverse_api/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def default_model_for_configured_sdk(sdk: str | None = None) -> str:
if s == "copilot":
return config_manager.get("copilot_model", "gpt-5")
if s == "cursor":
return config_manager.get("cursor_model", "composer-2")
return config_manager.get("cursor_model", "composer-2.5")
return config_manager.get("claude_code_model", "claude-sonnet-4-6")


Expand Down Expand Up @@ -189,6 +189,13 @@ def _quiet_consoles_for_json():
c._file = original_inner


def _write_json_stdout(real_stdout, payload: dict, *, json_stream: bool) -> None:
"""Write final CLI JSON payload (single doc or NDJSON terminal event)."""
out = {"event": "result", **payload} if json_stream else payload
real_stdout.write(json.dumps(out) + "\n")
real_stdout.flush()


def _build_dry_run_payload(
*,
prompt: str | None,
Expand Down Expand Up @@ -346,7 +353,7 @@ def _build_dry_run_payload(
"claude": "claude-sonnet-4-6",
"opencode": "claude-opus-4-6",
"copilot": "gpt-5",
"cursor": "composer-2",
"cursor": "composer-2.5",
}.get(sdk, "claude-sonnet-4-6")
resolved_model = model or config_manager.get(sdk_model_key, sdk_default_model)

Expand Down Expand Up @@ -1084,11 +1091,11 @@ def handle_settings(mode_color=THEME_PRIMARY):
console.print(f" [dim]updated[/dim] copilot model: {new_model}\n")

elif action == "cursor_model":
current = config_manager.get("cursor_model", "composer-2")
current = config_manager.get("cursor_model", "composer-2.5")
new_model = questionary.text(
" > cursor model",
default=current or "composer-2",
instruction="(e.g., 'composer-2', 'auto' — see Cursor SDK docs)",
default=current or "composer-2.5",
instruction="(e.g., 'composer-2.5', 'composer-2' — see Cursor SDK docs)",
qmark="",
style=questionary.Style(
[
Expand Down Expand Up @@ -1456,6 +1463,12 @@ def manual(prompt, url, reverse_engineer, model, output_dir):
is_flag=True,
help="Emit a single JSON result on stdout (logs go to stderr). Implies --no-interactive.",
)
@click.option(
"--json-stream",
"json_stream",
is_flag=True,
help="Emit NDJSON progress events on stdout during the run, then a final {\"event\":\"result\",...} line. Implies --no-interactive.",
)
@click.option(
"--headless",
is_flag=True,
Expand All @@ -1466,7 +1479,7 @@ def manual(prompt, url, reverse_engineer, model, output_dir):
is_flag=True,
help="Validate prompt/url/config/env without launching the browser. Emits a manifest of what would run + check results. Implies --json. Exits 0 if all checks pass, 1 if any error.",
)
def agent(prompt, url, model, output_dir, no_interactive, as_json, headless, dry_run):
def agent(prompt, url, model, output_dir, no_interactive, as_json, json_stream, headless, dry_run):
"""Run autonomous agent browser session.

Agent mode runs an integrated capture + reverse-engineering pipeline; if you
Expand All @@ -1483,10 +1496,11 @@ def agent(prompt, url, model, output_dir, no_interactive, as_json, headless, dry
real_stdout.flush()
sys.exit(0 if payload["status"] == "ok" else 1)

no_interactive = no_interactive or as_json
no_interactive = no_interactive or as_json or json_stream
machine_output = as_json or json_stream

if no_interactive and not (prompt and prompt.strip()):
if as_json:
if machine_output:
misuse = _build_agent_payload(
{},
prompt=prompt,
Expand All @@ -1495,16 +1509,18 @@ def agent(prompt, url, model, output_dir, no_interactive, as_json, headless, dry
error="--prompt is required in non-interactive/--json mode",
error_kind_hint="misuse",
)
if json_stream:
misuse = {"event": "result", **misuse}
click.echo(json.dumps(misuse))
else:
click.echo("error: --prompt is required when --no-interactive is set", err=True)
sys.exit(2)

# Either flag must suppress the post-generation follow-up prompt that would
# otherwise block on stdin (`input(" > ")`) inside ClaudeAutoEngineer.
interactive = not (as_json or no_interactive)
interactive = not no_interactive

if not as_json:
if not machine_output:
run_agent_capture(
prompt=prompt,
url=url,
Expand All @@ -1515,8 +1531,15 @@ def agent(prompt, url, model, output_dir, no_interactive, as_json, headless, dry
)
return

from .json_stream import make_json_stream_sink

payload: dict
with _quiet_consoles_for_json() as real_stdout:
sink = (
make_json_stream_sink(lambda line: (real_stdout.write(line + "\n"), real_stdout.flush()))
if json_stream
else None
)
try:
result = run_agent_capture(
prompt=prompt,
Expand All @@ -1525,6 +1548,7 @@ def agent(prompt, url, model, output_dir, no_interactive, as_json, headless, dry
output_dir=output_dir,
interactive=interactive,
headless=headless,
json_event_sink=sink,
)
payload = _build_agent_payload(result, prompt=prompt, url=url, output_dir=output_dir)
except KeyboardInterrupt as e:
Expand All @@ -1538,8 +1562,7 @@ def agent(prompt, url, model, output_dir, no_interactive, as_json, headless, dry
{}, prompt=prompt, url=url, output_dir=output_dir, error=e
)

real_stdout.write(json.dumps(payload) + "\n")
real_stdout.flush()
_write_json_stdout(real_stdout, payload, json_stream=json_stream)
sys.exit(0 if payload["status"] == "ok" else 1)


Expand Down Expand Up @@ -1602,7 +1625,15 @@ def run_manual_capture(prompt=None, url=None, reverse_engineer=True, model=None,
console.print(f" [dim]>[/dim] [dim]use 'reverse-api-engineer engineer {run_id}' to engineer later[/dim]\n")


def run_agent_capture(prompt=None, url=None, model=None, output_dir=None, interactive=True, headless=False):
def run_agent_capture(
prompt=None,
url=None,
model=None,
output_dir=None,
interactive=True,
headless=False,
json_event_sink=None,
):
"""Shared logic for agent capture mode."""
output_dir = output_dir or config_manager.get("output_dir")

Expand All @@ -1628,6 +1659,7 @@ def run_agent_capture(prompt=None, url=None, model=None, output_dir=None, intera
agent_provider=agent_provider,
interactive=interactive,
headless=headless,
json_event_sink=json_event_sink,
)


Expand Down Expand Up @@ -1688,6 +1720,7 @@ def run_auto_capture(
agent_provider="auto",
interactive=True,
headless=False,
json_event_sink=None,
):
"""Auto mode: LLM-driven browser automation + real-time reverse engineering."""
output_dir = output_dir or config_manager.get("output_dir")
Expand Down Expand Up @@ -1792,7 +1825,7 @@ def run_auto_capture(
output_language=output_language,
interactive=interactive,
headless=headless,
cursor_model=model or config_manager.get("cursor_model", "composer-2"),
cursor_model=model or config_manager.get("cursor_model", "composer-2.5"),
cursor_web_search=bool(config_manager.get("cursor_web_search", True)),
cursor_setting_sources=_cursor_src,
)
Expand All @@ -1812,6 +1845,12 @@ def run_auto_capture(
headless=headless,
)

if json_event_sink is not None:
from .json_stream import attach_json_stream_to_engineer

attach_json_stream_to_engineer(engineer, json_event_sink)
json_event_sink({"event": "agent_started", "mode": mode_label, "url": url})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 agent_started event is undocumented and creates an asymmetric event stream. In the agent path, attach_json_stream_to_engineer emits run_started and then this line immediately emits agent_started, so consumers see two startup events. In the engineer path (via run_reverse_engineering), only run_started is emitted. The documentation and PR description list run_started as a common event type but make no mention of agent_started, which means any consumer parsing the agent stream will receive an unexpected extra event before tool_start.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/reverse_api/cli.py
Line: 1852

Comment:
**`agent_started` event is undocumented and creates an asymmetric event stream.** In the `agent` path, `attach_json_stream_to_engineer` emits `run_started` and then this line immediately emits `agent_started`, so consumers see two startup events. In the `engineer` path (via `run_reverse_engineering`), only `run_started` is emitted. The documentation and PR description list `run_started` as a common event type but make no mention of `agent_started`, which means any consumer parsing the `agent` stream will receive an unexpected extra event before `tool_start`.

How can I resolve this? If you propose a fix, please make it concise.


# Start sync before analysis
engineer.start_sync()

Expand Down Expand Up @@ -1918,7 +1957,13 @@ def run_auto_capture(
is_flag=True,
help="Emit a single JSON result on stdout (logs go to stderr). Implies --no-interactive.",
)
def engineer(run_id, prompt, fresh, model, output_dir, no_interactive, as_json):
@click.option(
"--json-stream",
"json_stream",
is_flag=True,
help="Emit NDJSON progress events on stdout during the run, then a final {\"event\":\"result\",...} line. Implies --no-interactive.",
)
def engineer(run_id, prompt, fresh, model, output_dir, no_interactive, as_json, json_stream):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: engineer --json-stream does not return machine-readable output for missing RUN_ID; it still checks only as_json in the early validation path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/reverse_api/cli.py, line 1966:

<comment>`engineer --json-stream` does not return machine-readable output for missing `RUN_ID`; it still checks only `as_json` in the early validation path.</comment>

<file context>
@@ -1918,7 +1957,13 @@ def run_auto_capture(
+    is_flag=True,
+    help="Emit NDJSON progress events on stdout during the run, then a final {\"event\":\"result\",...} line. Implies --no-interactive.",
+)
+def engineer(run_id, prompt, fresh, model, output_dir, no_interactive, as_json, json_stream):
     """Run reverse engineering on a previous run."""
     # `run_id` is declared optional at the click level so that wrappers using
</file context>

"""Run reverse engineering on a previous run."""
# `run_id` is declared optional at the click level so that wrappers using
# --json get a JSON misuse payload instead of Click's plain-text "missing
Expand Down Expand Up @@ -1946,9 +1991,11 @@ def engineer(run_id, prompt, fresh, model, output_dir, no_interactive, as_json):
additional = prompt if (prompt and not fresh) else None
# Either flag must suppress the post-generation follow-up prompt that
# would otherwise block on stdin (`input(" > ")`) inside ClaudeEngineer.
interactive = not (as_json or no_interactive)
no_interactive = no_interactive or as_json or json_stream
interactive = not no_interactive
machine_output = as_json or json_stream

if not as_json:
if not machine_output:
run_engineer(
run_id,
prompt=main_prompt,
Expand All @@ -1960,8 +2007,15 @@ def engineer(run_id, prompt, fresh, model, output_dir, no_interactive, as_json):
)
return

from .json_stream import make_json_stream_sink

payload: dict
with _quiet_consoles_for_json() as real_stdout:
sink = (
make_json_stream_sink(lambda line: (real_stdout.write(line + "\n"), real_stdout.flush()))
if json_stream
else None
)
try:
result = run_engineer(
run_id,
Expand All @@ -1971,6 +2025,7 @@ def engineer(run_id, prompt, fresh, model, output_dir, no_interactive, as_json):
output_dir=output_dir,
is_fresh=fresh,
interactive=interactive,
json_event_sink=sink,
)
payload = _build_engineer_payload(result, run_id=run_id, prompt=prompt, fresh=fresh)
except KeyboardInterrupt as e:
Expand All @@ -1983,8 +2038,7 @@ def engineer(run_id, prompt, fresh, model, output_dir, no_interactive, as_json):
None, run_id=run_id, prompt=prompt, fresh=fresh, error=e
)

real_stdout.write(json.dumps(payload) + "\n")
real_stdout.flush()
_write_json_stdout(real_stdout, payload, json_stream=json_stream)
sys.exit(0 if payload["status"] == "ok" else 1)


Expand All @@ -1998,6 +2052,7 @@ def run_engineer(
is_fresh=False,
output_mode="client",
interactive=True,
json_event_sink=None,
):
"""Shared logic for reverse engineering."""
if not har_path or not prompt:
Expand Down Expand Up @@ -2040,6 +2095,7 @@ def run_engineer(
output_language=output_language,
output_mode=output_mode,
interactive=interactive,
json_event_sink=json_event_sink,
)
elif sdk == "copilot":
result = run_reverse_engineering(
Expand All @@ -2056,11 +2112,12 @@ def run_engineer(
output_language=output_language,
output_mode=output_mode,
interactive=interactive,
json_event_sink=json_event_sink,
)
elif sdk == "cursor":
_cs = config_manager.get("cursor_setting_sources")
_cursor_src = _cs if isinstance(_cs, list) and all(isinstance(x, str) for x in _cs) else None
_cm = model or config_manager.get("cursor_model", "composer-2")
_cm = model or config_manager.get("cursor_model", "composer-2.5")
result = run_reverse_engineering(
run_id=run_id,
har_path=har_path,
Expand All @@ -2077,6 +2134,7 @@ def run_engineer(
output_language=output_language,
output_mode=output_mode,
interactive=interactive,
json_event_sink=json_event_sink,
)
else:
result = run_reverse_engineering(
Expand All @@ -2092,6 +2150,7 @@ def run_engineer(
output_language=output_language,
output_mode=output_mode,
interactive=interactive,
json_event_sink=json_event_sink,
)

if result:
Expand Down
2 changes: 1 addition & 1 deletion src/reverse_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"agent_provider": "auto", # "auto" (Playwright MCP) or "chrome-mcp" (Chrome DevTools MCP)
"claude_code_model": "claude-sonnet-4-6",
"collector_model": "claude-sonnet-4-6", # Model for collector mode
"cursor_model": "composer-2", # Model id for Cursor SDK (see Cursor.models.list())
"cursor_model": "composer-2.5", # Model id for Cursor SDK (see Cursor.models.list())
# When True, local agents load broader Cursor setting layers (plugins/team) so WebFetch/WebSearch
# and other IDE tools match Cursor desktop behavior. Set False for minimal "project+user" only.
"cursor_web_search": True,
Expand Down
2 changes: 1 addition & 1 deletion src/reverse_api/copilot_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class AskUserParams(BaseModel):
async def ask_user_question(params: AskUserParams) -> str:
# Convert pydantic models to dicts for the shared method
question_dicts = [q.model_dump() for q in params.questions]
answers = await engineer._ask_user_interactive(question_dicts)
answers = await engineer._ask_user_questions(question_dicts)
return "\n".join(f"{k}: {v}" for k, v in answers.items())

return ask_user_question
Expand Down
Loading