From 8840ceda419a30fc0cfbd5ee846b201715e2aeb9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 24 May 2026 04:41:34 +0000 Subject: [PATCH] Add non-interactive AskUserQuestion auto-reply and --json-stream When interactive=False, AskUserQuestion returns a fixed message instructing the model to assume reasonable answers instead of blocking on questionary. Add --json-stream on agent and engineer for NDJSON progress on stdout plus a terminal result event. Default Cursor model is composer-2.5. Co-authored-by: kalil0321 --- README.md | 2 +- src/reverse_api/auto_engineer.py | 2 +- src/reverse_api/base_engineer.py | 39 +++++++ src/reverse_api/cli.py | 99 ++++++++++++---- src/reverse_api/config.py | 2 +- src/reverse_api/copilot_engineer.py | 2 +- src/reverse_api/cursor_bridge/run.mjs | 2 +- src/reverse_api/cursor_engineer.py | 7 +- src/reverse_api/engineer.py | 10 +- src/reverse_api/json_stream.py | 108 ++++++++++++++++++ src/reverse_api/prompts/engineer/system.md | 4 +- tests/test_cli_json_stream.py | 71 ++++++++++++ tests/test_config.py | 2 +- tests/test_engineer.py | 45 ++++++++ website/content/docs/cli/scripted-usage.mdx | 17 +++ website/content/docs/configuration/models.mdx | 4 +- website/content/docs/configuration/sdks.mdx | 2 +- 17 files changed, 384 insertions(+), 34 deletions(-) create mode 100644 src/reverse_api/json_stream.py create mode 100644 tests/test_cli_json_stream.py diff --git a/README.md b/README.md index dd6a0c9..715a731 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/src/reverse_api/auto_engineer.py b/src/reverse_api/auto_engineer.py index 05191f8..da33162 100644 --- a/src/reverse_api/auto_engineer.py +++ b/src/reverse_api/auto_engineer.py @@ -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}, ) diff --git a/src/reverse_api/base_engineer.py b/src/reverse_api/base_engineer.py index 0f83ae1..c0af4de 100644 --- a/src/reverse_api/base_engineer.py +++ b/src/reverse_api/base_engineer.py @@ -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.""" @@ -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.""" @@ -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. diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py index e769d9d..6dd036a 100644 --- a/src/reverse_api/cli.py +++ b/src/reverse_api/cli.py @@ -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") @@ -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, @@ -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) @@ -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( [ @@ -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, @@ -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 @@ -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, @@ -1495,6 +1509,8 @@ 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) @@ -1502,9 +1518,9 @@ def agent(prompt, url, model, output_dir, no_interactive, as_json, headless, dry # 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, @@ -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, @@ -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: @@ -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) @@ -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") @@ -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, ) @@ -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") @@ -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, ) @@ -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}) + # Start sync before analysis engineer.start_sync() @@ -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): """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 @@ -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, @@ -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, @@ -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: @@ -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) @@ -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: @@ -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( @@ -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, @@ -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( @@ -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: diff --git a/src/reverse_api/config.py b/src/reverse_api/config.py index 90c108c..b4ffb5f 100644 --- a/src/reverse_api/config.py +++ b/src/reverse_api/config.py @@ -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, diff --git a/src/reverse_api/copilot_engineer.py b/src/reverse_api/copilot_engineer.py index 4c9dc90..6eb7756 100644 --- a/src/reverse_api/copilot_engineer.py +++ b/src/reverse_api/copilot_engineer.py @@ -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 diff --git a/src/reverse_api/cursor_bridge/run.mjs b/src/reverse_api/cursor_bridge/run.mjs index 74865db..e35553d 100644 --- a/src/reverse_api/cursor_bridge/run.mjs +++ b/src/reverse_api/cursor_bridge/run.mjs @@ -64,7 +64,7 @@ if (!cwd || typeof cwd !== "string") { process.exit(1); } -const modelId = input.modelId || "composer-2"; +const modelId = input.modelId || "composer-2.5"; const mcpServers = input.mcpServers && typeof input.mcpServers === "object" ? input.mcpServers : undefined; const resumeAgentId = input.resumeAgentId || null; const prompt = input.prompt; diff --git a/src/reverse_api/cursor_engineer.py b/src/reverse_api/cursor_engineer.py index 5e4e481..fd2a1e4 100644 --- a/src/reverse_api/cursor_engineer.py +++ b/src/reverse_api/cursor_engineer.py @@ -73,7 +73,7 @@ def __init__( cursor_setting_sources: list[str] | None = None, **kwargs: Any, ): - cm = cursor_model or model or "composer-2" + cm = cursor_model or model or "composer-2.5" super().__init__(run_id=run_id, har_path=har_path, prompt=prompt, model=cm, **kwargs) self.cursor_model = cm self.cursor_web_search = cursor_web_search @@ -165,6 +165,11 @@ async def _dispatch_stream_event(self, event: dict[str, Any]) -> None: self._cursor_flush_narrative() args = self._cursor_coerce_args(event.get("args")) self._cursor_emit_todo_ui(name, args) + if not self.interactive and self._is_ask_user_tool_name(name): + self._emit_json_event({"event": "ask_user_skipped", "count": 1, "tool": name}) + self.ui.console.print( + f" [dim]AskUserQuestion skipped ({name}; non-interactive mode)[/dim]" + ) self.ui.tool_start(name, args) self.message_store.save_tool_start(name, args) else: diff --git a/src/reverse_api/engineer.py b/src/reverse_api/engineer.py index 2ad5dde..f936e7c 100644 --- a/src/reverse_api/engineer.py +++ b/src/reverse_api/engineer.py @@ -31,7 +31,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}, ) @@ -218,6 +218,7 @@ def run_reverse_engineering( output_language: str = "python", output_mode: str = "client", interactive: bool = True, + json_event_sink: Any = None, ) -> dict[str, Any] | None: """Run reverse engineering with the specified SDK. @@ -226,7 +227,7 @@ def run_reverse_engineering( opencode_provider: Provider ID for OpenCode (e.g., "anthropic") opencode_model: Model ID for OpenCode (e.g., "claude-sonnet-4-6") copilot_model: Model ID for Copilot (e.g., "gpt-5") - cursor_model: Model id for Cursor SDK (e.g., "composer-2") + cursor_model: Model id for Cursor SDK (e.g., "composer-2.5") cursor_web_search: When True, load extra Cursor setting layers so WebFetch/WebSearch and plugins apply. cursor_setting_sources: Optional explicit list (overrides cursor_web_search), e.g. ["project","user","all"]. enable_sync: Enable real-time file syncing during engineering @@ -311,6 +312,11 @@ def run_reverse_engineering( interactive=interactive, ) + 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) + # Start sync before analysis engineer.start_sync() diff --git a/src/reverse_api/json_stream.py b/src/reverse_api/json_stream.py new file mode 100644 index 0000000..7987df6 --- /dev/null +++ b/src/reverse_api/json_stream.py @@ -0,0 +1,108 @@ +"""NDJSON event streaming for scripted CLI invocations (--json-stream).""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +def make_json_stream_sink(write_line: Callable[[str], None]) -> Callable[[dict[str, Any]], None]: + """Build a sink that writes one JSON object per line.""" + + import json + + def sink(event: dict[str, Any]) -> None: + write_line(json.dumps(event, default=str)) + + return sink + + +class StreamingUIWrapper: + """Delegate to an inner UI and emit json-stream events for key lifecycle hooks.""" + + def __init__(self, inner: Any, sink: Callable[[dict[str, Any]], None]) -> None: + self._inner = inner + self._sink = sink + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + def header(self, run_id: str, prompt: str, model: str | None = None, sdk: str | None = None, mode: str | None = None) -> None: + self._sink( + { + "event": "header", + "run_id": run_id, + "prompt": prompt, + "model": model, + "sdk": sdk, + "mode": mode, + } + ) + self._inner.header(run_id, prompt, model=model, sdk=sdk, mode=mode) + + def start_analysis(self) -> None: + self._sink({"event": "progress", "message": "analysis_started"}) + self._inner.start_analysis() + + def tool_start(self, tool_name: str, tool_input: dict | Any = None) -> None: + summary = None + if isinstance(tool_input, dict): + summary = str(tool_input)[:200] if tool_input else None + self._sink( + { + "event": "tool_start", + "name": tool_name, + "input_summary": summary, + } + ) + self._inner.tool_start(tool_name, tool_input) + + def tool_result(self, tool_name: str, is_error: bool = False, output: str | None = None) -> None: + self._sink( + { + "event": "tool_end", + "name": tool_name, + "is_error": is_error, + "output_preview": (output[:200] if output else None), + } + ) + self._inner.tool_result(tool_name, is_error, output) + + def thinking(self, text: str, max_length: int = 500) -> None: + preview = text[:500] if text else "" + if preview.strip(): + self._sink({"event": "thinking", "text": preview}) + if hasattr(self._inner, "thinking"): + try: + self._inner.thinking(text, max_length=max_length) + except TypeError: + self._inner.thinking(text) + else: + self._inner.thinking(text) + + def thinking_block(self, text: str, max_chars: int = 8000) -> None: + preview = text[:800] if text else "" + if preview.strip(): + self._sink({"event": "thinking_block", "text": preview}) + self._inner.thinking_block(text, max_chars=max_chars) + + def success(self, script_path: str, local_path: str | None = None) -> None: + self._sink({"event": "success", "script_path": script_path, "local_path": local_path}) + self._inner.success(script_path, local_path) + + def error(self, message: str) -> None: + self._sink({"event": "error", "message": message}) + self._inner.error(message) + + +def attach_json_stream_to_engineer(engineer: Any, sink: Callable[[dict[str, Any]], None]) -> None: + """Enable NDJSON UI events on an engineer instance.""" + engineer._json_event_sink = sink + engineer.ui = StreamingUIWrapper(engineer.ui, sink) + sink( + { + "event": "run_started", + "run_id": engineer.run_id, + "sdk": getattr(engineer, "sdk", None), + } + ) diff --git a/src/reverse_api/prompts/engineer/system.md b/src/reverse_api/prompts/engineer/system.md index 6c3a82b..ce4a600 100644 --- a/src/reverse_api/prompts/engineer/system.md +++ b/src/reverse_api/prompts/engineer/system.md @@ -2,7 +2,7 @@ You are tasked with analyzing a HAR (HTTP Archive) file to {mode_description} th **Core principle:** The generated output must work immediately with zero user effort. Hardcode all credentials, cookies, tokens, and session data found in the traffic. No env vars, no config files, no manual setup. If the traffic reveals a token refresh or login flow, implement automatic re-authentication so the script doesn't go stale when cookies or tokens expire. -You have access to the AskUserQuestion tool to ask clarifying questions during your analysis. Use it when you need to clarify requirements, prioritize features, or choose between approaches. It supports single-select, multi-select, and free-form questions. +You have access to the AskUserQuestion tool to ask clarifying questions during your analysis when running interactively. Use it when you need to clarify requirements, prioritize features, or choose between approaches. It supports single-select, multi-select, and free-form questions. In non-interactive or scripted CLI runs, do not use AskUserQuestion — make reasonable assumptions from the HAR and prompt instead. ## Analysis guidelines @@ -11,7 +11,7 @@ These are guidelines for your analysis, not a rigid checklist — use your judgm 1. **Read the HAR file** — understand the API surface: endpoints, methods, headers, request/response shapes, status codes 2. **Identify auth patterns** — cookies, Bearer tokens, API keys, CSRF tokens, session tokens. Hardcode whatever you find 3. **Extract endpoint patterns** — required vs optional params, data formats, query vs body params -4. **Ask the user** if anything is ambiguous (which auth to prioritize, feature priorities, implementation approach) +4. **Ask the user** if anything is ambiguous and you are in an interactive session; otherwise document assumptions in your summary {codegen_instructions} diff --git a/tests/test_cli_json_stream.py b/tests/test_cli_json_stream.py new file mode 100644 index 0000000..c58eb73 --- /dev/null +++ b/tests/test_cli_json_stream.py @@ -0,0 +1,71 @@ +"""Tests for --json-stream NDJSON CLI output.""" + +import json +from unittest.mock import patch + +from click.testing import CliRunner + +from reverse_api.cli import agent, engineer, main + + +class TestJsonStreamAgent: + def test_agent_json_stream_emits_ndjson_and_result(self): + runner = CliRunner() + fake_result = { + "run_id": "abc123", + "mode": "auto", + "script_path": "/tmp/api_client.py", + "usage": {}, + } + + def fake_capture(**kwargs): + sink = kwargs.get("json_event_sink") + if sink: + sink({"event": "run_started", "run_id": "abc123", "sdk": "claude"}) + return fake_result + + with patch("reverse_api.cli.run_agent_capture", side_effect=fake_capture): + result = runner.invoke( + agent, + ["--json-stream", "-p", "capture api", "-u", "https://example.com"], + ) + + assert result.exit_code == 0 + lines = [ln for ln in result.output.strip().split("\n") if ln] + assert len(lines) >= 2 + events = [json.loads(ln) for ln in lines] + assert events[0]["event"] == "run_started" + assert events[-1]["event"] == "result" + assert events[-1]["status"] == "ok" + assert events[-1]["run_id"] == "abc123" + + def test_agent_json_stream_implies_no_interactive(self): + runner = CliRunner() + with patch("reverse_api.cli.run_agent_capture") as mock_run: + mock_run.return_value = {"run_id": "x", "mode": "auto", "usage": {}} + runner.invoke(agent, ["--json-stream", "-p", "x"]) + assert mock_run.call_args.kwargs["interactive"] is False + + +class TestJsonStreamEngineer: + def test_engineer_json_stream_emits_result_event(self): + runner = CliRunner() + def fake_engineer(run_id, **kwargs): + sink = kwargs.get("json_event_sink") + if sink: + sink({"event": "run_started", "run_id": run_id, "sdk": "claude"}) + return {"script_path": "/tmp/c.py", "usage": {}} + + with patch("reverse_api.cli.run_engineer", side_effect=fake_engineer): + result = runner.invoke(engineer, ["abc123", "--json-stream", "-p", "add tests"]) + + assert result.exit_code == 0 + lines = [ln for ln in result.output.strip().split("\n") if ln] + events = [json.loads(ln) for ln in lines] + assert events[-1]["event"] == "result" + assert events[-1]["run_id"] == "abc123" + + def test_help_lists_json_stream(self): + runner = CliRunner() + result = runner.invoke(main, ["agent", "--help"]) + assert "--json-stream" in result.output diff --git a/tests/test_config.py b/tests/test_config.py index 47fa160..1009d3d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -164,6 +164,6 @@ def test_default_values(self): assert DEFAULT_CONFIG["output_dir"] is None assert DEFAULT_CONFIG["output_language"] == "python" assert DEFAULT_CONFIG["real_time_sync"] is True - assert DEFAULT_CONFIG["cursor_model"] == "composer-2" + assert DEFAULT_CONFIG["cursor_model"] == "composer-2.5" assert DEFAULT_CONFIG["cursor_web_search"] is True assert DEFAULT_CONFIG["cursor_setting_sources"] is None diff --git a/tests/test_engineer.py b/tests/test_engineer.py index 06b9bbb..1f4cb27 100644 --- a/tests/test_engineer.py +++ b/tests/test_engineer.py @@ -402,6 +402,51 @@ async def test_ask_user_question_delegates(self, tmp_path): ) assert result.updated_input["answers"]["What?"] == "user answer" + @pytest.mark.asyncio + async def test_ask_user_question_non_interactive_stub(self, tmp_path): + """AskUserQuestion returns a fixed message when interactive=False.""" + from reverse_api.base_engineer import NON_INTERACTIVE_ASK_USER_MESSAGE + + eng = self._make_engineer(tmp_path) + eng.interactive = False + + with patch("reverse_api.base_engineer.questionary.text") as mock_text: + result = await eng._handle_tool_permission( + "AskUserQuestion", + {"questions": [{"question": "What?", "header": "", "multiSelect": False, "options": []}]}, + None, + ) + mock_text.assert_not_called() + assert result.updated_input["answers"]["What?"] == NON_INTERACTIVE_ASK_USER_MESSAGE + + +class TestAskUserQuestions: + """Test _ask_user_questions routing.""" + + def _make_engineer(self, tmp_path, *, interactive: bool = True): + har_path = tmp_path / "test.har" + har_path.touch() + with patch("reverse_api.base_engineer.get_scripts_dir", return_value=tmp_path / "scripts"): + with patch("reverse_api.base_engineer.MessageStore"): + eng = ClaudeEngineer( + run_id="test123", + har_path=har_path, + prompt="test prompt", + output_dir=str(tmp_path), + interactive=interactive, + ) + return eng + + @pytest.mark.asyncio + async def test_non_interactive_no_questionary(self, tmp_path): + from reverse_api.base_engineer import NON_INTERACTIVE_ASK_USER_MESSAGE + + eng = self._make_engineer(tmp_path, interactive=False) + answers = await eng._ask_user_questions( + [{"question": "Pick auth?", "header": "", "multiSelect": False, "options": []}] + ) + assert answers["Pick auth?"] == NON_INTERACTIVE_ASK_USER_MESSAGE + class TestClaudeEngineerAnalyzeAndGenerate: """Test analyze_and_generate method.""" diff --git a/website/content/docs/cli/scripted-usage.mdx b/website/content/docs/cli/scripted-usage.mdx index c524d06..6cc57f7 100644 --- a/website/content/docs/cli/scripted-usage.mdx +++ b/website/content/docs/cli/scripted-usage.mdx @@ -14,6 +14,12 @@ When `--json` is set: - **stderr** receives Rich logs and progress updates. - The process exits with a **stable exit code**. +When `--json-stream` is set (on `agent` and `engineer`): + +- **stdout** receives **NDJSON**: one JSON object per line while the run progresses, then a final `{"event":"result",...}` line with the same fields as `--json`. +- **stderr** receives Rich logs (same as `--json`). +- Implies **non-interactive** mode; `AskUserQuestion` is auto-answered with a fixed message instead of prompting on stdin. + ## Examples ### Run an autonomous capture and read the result as JSON @@ -25,6 +31,17 @@ reverse-api-engineer agent \ --json | jq ``` +### Stream progress as NDJSON (wrapper agents) + +```bash +reverse-api-engineer agent \ + --prompt "capture the public jobs api" \ + --url https://example.com/jobs \ + --json-stream +``` + +Common event types: `run_started`, `tool_start`, `tool_end`, `thinking`, `ask_user_skipped`, `result`. + ### List and inspect runs ```bash diff --git a/website/content/docs/configuration/models.mdx b/website/content/docs/configuration/models.mdx index 173f917..2c4514f 100644 --- a/website/content/docs/configuration/models.mdx +++ b/website/content/docs/configuration/models.mdx @@ -42,7 +42,7 @@ The full default config: "agent_provider": "auto", "claude_code_model": "claude-sonnet-4-6", "collector_model": "claude-sonnet-4-6", - "cursor_model": "composer-2", + "cursor_model": "composer-2.5", "cursor_web_search": true, "cursor_setting_sources": null, "copilot_model": "gpt-5", @@ -64,5 +64,5 @@ for the available model IDs. Set them via the `opencode_model` and ## Copilot and Cursor users Copilot uses `copilot_model` (default `gpt-5`). Cursor uses `cursor_model` -(default `composer-2`) plus `cursor_web_search` / `cursor_setting_sources` +(default `composer-2.5`) plus `cursor_web_search` / `cursor_setting_sources` for controlling which Cursor settings layers are loaded. diff --git a/website/content/docs/configuration/sdks.mdx b/website/content/docs/configuration/sdks.mdx index e1b4f78..2b13489 100644 --- a/website/content/docs/configuration/sdks.mdx +++ b/website/content/docs/configuration/sdks.mdx @@ -55,7 +55,7 @@ so WebFetch/WebSearch and configured tools match your desktop setup. ```json { "sdk": "cursor", - "cursor_model": "composer-2", + "cursor_model": "composer-2.5", "cursor_web_search": true, "cursor_setting_sources": null }