From 1579db14454d6ff63db6e0d962c011c1bae6a6e1 Mon Sep 17 00:00:00 2001 From: Sultan Berisa Date: Mon, 3 Aug 2026 11:29:04 +0200 Subject: [PATCH 01/13] Add PowerShell (pwsh 7+) as a supported output language --- README.md | 4 +- src/reverse_api/base_engineer.py | 21 ++++++++ src/reverse_api/cli.py | 1 + src/reverse_api/config.py | 2 +- src/reverse_api/prompts/__init__.py | 3 +- .../prompts/partials/_language_powershell.md | 34 +++++++++++++ src/reverse_api/utils.py | 8 +++ tests/test_base_engineer.py | 49 ++++++++++++++++++- tests/test_engineer.py | 2 +- tests/test_run_command.py | 12 ++++- website/content/docs/index.mdx | 10 ++-- website/src/app/(home)/page.tsx | 4 +- website/src/app/llms.txt/route.ts | 2 +- 13 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 src/reverse_api/prompts/partials/_language_powershell.md diff --git a/README.md b/README.md index b93de153..690c7fd3 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ 1. You give it a website and a goal ("fetch all Apple jobs"). 2. A browser visits the site, either driven by you or by an AI agent. 3. Network traffic is captured to a HAR file. -4. Your configured model reads the traffic and writes a working API client in Python, JavaScript, TypeScript, Go, Java, C#, PHP, Ruby, or C. +4. Your configured model reads the traffic and writes a working API client in Python, JavaScript, TypeScript, Go, Java, C#, PHP, Ruby, C, or PowerShell. No more manually opening DevTools, copying cURL commands, and gluing together a client. @@ -116,7 +116,7 @@ Settings live in `~/.reverse-api/config.json` and can be edited via `/settings` - **SDK**: `claude` (default), `opencode`, `cursor`, or `copilot` (GitHub Copilot). - **OpenCode setup**: with `sdk: "opencode"`, RAE reuses an existing server or downloads/starts `opencode-ai@latest` through `npx`; a global OpenCode installation is not required. Fresh configurations default to the free `opencode/big-pickle` model. `/settings` shows a loading spinner, then offers a searchable **OpenCode Provider / Model** picker populated from the server's connected, tool-capable catalog and marks free OpenCode options. Before creating a session, RAE validates the saved pair again and suggests currently available free models when configuration is invalid. Compatible older servers are reused with a version warning. Node.js 20+ is required for automatic startup. Password-protected servers use `OPENCODE_SERVER_PASSWORD` and optional `OPENCODE_SERVER_USERNAME`. Override startup with `OPENCODE_BASE_URL`, `RAE_OPENCODE_PACKAGE`, or `RAE_OPENCODE_AUTO_START=0`. - **Ollama through OpenCode**: choose provider `ollama` in `/settings`; RAE starts an installed Ollama daemon if needed, lists only installed models with tool calling and 64k+ context, and supplies OpenCode's provider config inline. Models are never downloaded silently. Override with `RAE_OLLAMA_BASE_URL` or `RAE_OLLAMA_AUTO_START=0`. -- **Output language**: `python`, `javascript`, `typescript`, `go`, `java`, `csharp`, `php`, `ruby`, or `c`. C needs a POSIX toolchain (`cc`, libcurl headers) — macOS/Linux, or WSL/MSYS2 on Windows. +- **Output language**: `python`, `javascript`, `typescript`, `go`, `java`, `csharp`, `php`, `ruby`, `c`, or `powershell`. C needs a POSIX toolchain (`cc`, libcurl headers) — macOS/Linux, or WSL/MSYS2 on Windows. PowerShell needs `pwsh` 7+ (PowerShell Core, not Windows PowerShell 5.1). ## CLI diff --git a/src/reverse_api/base_engineer.py b/src/reverse_api/base_engineer.py index ecbda952..fdc8c875 100644 --- a/src/reverse_api/base_engineer.py +++ b/src/reverse_api/base_engineer.py @@ -482,6 +482,7 @@ def _get_language_name(self) -> str: "php": "PHP", "ruby": "Ruby", "c": "C", + "powershell": "PowerShell", }.get(self.output_language, "Python") def _get_existing_client_guidance(self) -> str: @@ -613,6 +614,24 @@ def _get_run_command(self) -> str: cjson = self._quote_path(str(resolved / "cJSON.c")) binary = self._quote_path(str(resolved / "api_client")) return f"cc {source} {cjson} -lcurl -o {binary} && {binary}" + if self.output_language == "powershell": + # Unlike python/node/npx (which happily take a plain relative + # filename regardless of the agent's actual cwd, scripts_dir. + # parent.parent — see analyze_and_generate's ClaudeAgentOptions), + # the module itself (api_client.psm1) isn't runnable — the + # command targets the fixed companion Example.ps1, which Imports + # the module and calls its exported functions, the same + # project-file indirection used for Java's pom.xml and C#'s + # csproj. shlex.quote()-equivalent via _quote_path(), not manual + # double-quoting — output_dir (and so scripts_dir) isn't + # guaranteed free of shell metacharacters, and naive f'"{path}"' + # still lets $()/backticks expand inside double quotes. + # .resolve(): a relative --output-dir would otherwise be + # re-interpreted against the agent's cwd (scripts_dir.parent. + # parent) instead of the original cwd it was relative to, + # pointing -File at the wrong, doubly-nested location. + example = self._quote_path(str(self.scripts_dir.resolve() / "Example.ps1")) + return f"pwsh -NoProfile -File {example}" return { "python": "python api_client.py", "javascript": "node api_client.js", @@ -751,6 +770,8 @@ def _get_auto_output_files(self, language_name: str, client_filename: str) -> st f"\n3. `{self.scripts_dir}/cJSON.c` and `{self.scripts_dir}/cJSON.h` - " "Vendored JSON library" ) + elif self.output_language == "powershell": + return base + f"\n3. `{self.scripts_dir}/Example.ps1` - Imports the module and demonstrates usage" return base @abstractmethod diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py index a756e35f..75ba940d 100644 --- a/src/reverse_api/cli.py +++ b/src/reverse_api/cli.py @@ -1269,6 +1269,7 @@ def _handle_settings_action(mode_color=THEME_PRIMARY) -> bool: Choice(title="php", value="php"), Choice(title="ruby", value="ruby"), Choice(title="c", value="c"), + Choice(title="powershell", value="powershell"), Choice(title="back", value="back"), ] lang = questionary.select( diff --git a/src/reverse_api/config.py b/src/reverse_api/config.py index 9586db5c..0c11b401 100644 --- a/src/reverse_api/config.py +++ b/src/reverse_api/config.py @@ -28,7 +28,7 @@ "ollama_auto_start": True, "ollama_base_url": "http://127.0.0.1:11434", "output_dir": None, # None means use ~/.reverse-api/runs - "output_language": "python", # "python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby", or "c" + "output_language": "python", # "python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby", "c", or "powershell" "real_time_sync": True, # Enable real-time file sync during engineering "sdk": "claude", # "claude", "opencode", "copilot", or "cursor" } diff --git a/src/reverse_api/prompts/__init__.py b/src/reverse_api/prompts/__init__.py index 77da219d..f48a6984 100644 --- a/src/reverse_api/prompts/__init__.py +++ b/src/reverse_api/prompts/__init__.py @@ -54,7 +54,8 @@ def load_language_partial(language: str, **kwargs: str) -> str: """Load the language-specific codegen instructions partial. Args: - language: One of "python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby", "c". + language: One of "python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby", "c", + "powershell". **kwargs: Placeholder values (scripts_dir, client_filename, run_command). """ return load(f"partials/_language_{language}", **kwargs) diff --git a/src/reverse_api/prompts/partials/_language_powershell.md b/src/reverse_api/prompts/partials/_language_powershell.md new file mode 100644 index 00000000..2ebbe2ed --- /dev/null +++ b/src/reverse_api/prompts/partials/_language_powershell.md @@ -0,0 +1,34 @@ +**Generate a PowerShell module** that replicates the API calls found in the traffic. The following are guidelines — use your judgment on what's appropriate for the specific API: + +- Target PowerShell 7+ (`pwsh`), not Windows PowerShell 5.1. Do not use Windows-only cmdlets or `System.Web` types that require .NET Framework +- Module file is a single `.psm1` with one or more advanced functions. Every public function: + - Uses an approved verb (`Get-Verb` list) — `Get-`, `Invoke-`, `New-`, `Set-`, `Remove-`, etc. Never invent unapproved verbs like `Fetch-` or `Do-` + - Has `[CmdletBinding()]` and typed `[Parameter()]` blocks (mandatory/optional, `[string]`, `[hashtable]`, `[switch]` etc. — no untyped params) + - Is exported explicitly via `Export-ModuleMember -Function ` at the bottom of the file. Do not use wildcard export (`Export-ModuleMember -Function *`) +- HTTP calls use `Invoke-RestMethod` (or `Invoke-WebRequest` only when raw headers/status codes are needed). Never shell out to `curl.exe` or `curl` +- Session/cookie handling: use `-SessionVariable`/`-WebSession` with `[Microsoft.PowerShell.Commands.WebRequestSession]`, not manual cookie header construction, unless the API requires a cookie value that PowerShell's cookie jar can't express +- Error handling: every network call wrapped in a `try`/`catch` block with `-ErrorAction Stop` on the call itself. Catch blocks should surface `$_.Exception.Message` and, where the failure is an HTTP error, the response body if retrievable, not swallow the error silently +- Use `[PSCustomObject]` for structured return values, not raw hashtables, so downstream `ConvertTo-Json` and property access behave predictably +- Prefer `ConvertTo-Json`/`ConvertFrom-Json` (built-in) over any third-party JSON handling +- No `Write-Host` for data output — use `Write-Output`/return values. `Write-Verbose`/`Write-Error` are fine for diagnostics +- Create a separate exported function for each distinct API endpoint + +**Authentication & credentials:** +- Hardcode all cookies, tokens, session IDs, and auth headers found in the traffic directly in the module +- The user should be able to run the example immediately with zero configuration — no env vars, no config files, no manual setup +- If the API uses cookies, populate a `WebRequestSession` with them and reuse it across calls +- If the API uses Bearer tokens or API keys, hardcode them in the request headers +- Handle auth refresh so the module doesn't go stale: if you see a token refresh endpoint, OAuth refresh flow, or login endpoint in the traffic, implement automatic re-authentication when a request returns 401/403. If cookies have expiry, re-fetch them before they expire + +**Testing:** +- Run: `{run_command}` +- You have up to 5 attempts to fix issues + +Save the module to: `{scripts_dir}/{client_filename}` +Save documentation to: `{scripts_dir}/README.md` +Save the example script to: `{scripts_dir}/Example.ps1`, which does: +```powershell +Import-Module "$PSScriptRoot\{client_filename}" -Force +# example invocation(s) of the exported function(s) +``` +Do not generate a `.psd1` module manifest. diff --git a/src/reverse_api/utils.py b/src/reverse_api/utils.py index ea25e908..c170697a 100644 --- a/src/reverse_api/utils.py +++ b/src/reverse_api/utils.py @@ -25,6 +25,7 @@ "php": ".php", "ruby": ".rb", "c": ".c", + "powershell": ".psm1", } SCRIPT_EXTENSIONS = frozenset(OUTPUT_LANGUAGE_EXTENSIONS.values()) @@ -829,6 +830,13 @@ def build_script_commands(script: Path, script_args: tuple[str, ...] = ()) -> tu compile_cmd.append(str(cjson)) compile_cmd += ["-lcurl", "-o", str(binary)] return [compile_cmd, [str(binary), *script_args]], "cc" + if suffix == ".psm1": + # A .psm1 is a module, not a runnable entry point — like Java/C#'s + # pom.xml/csproj, the actual command targets a fixed companion file + # (Example.ps1) that Imports the module and calls its exported + # functions, not the script argument itself. + example = d / "Example.ps1" + return [["pwsh", "-NoProfile", "-File", str(example), *script_args]], "pwsh" raise ValueError(f"unsupported script type: {script.name}") diff --git a/tests/test_base_engineer.py b/tests/test_base_engineer.py index 648348c9..3699460d 100644 --- a/tests/test_base_engineer.py +++ b/tests/test_base_engineer.py @@ -218,6 +218,10 @@ def test_get_output_extension_c(self, tmp_path): """C extension.""" eng = self._make_engineer(tmp_path, output_language="c") assert eng._get_output_extension() == ".c" + def test_get_output_extension_powershell(self, tmp_path): + """PowerShell extension.""" + eng = self._make_engineer(tmp_path, output_language="powershell") + assert eng._get_output_extension() == ".psm1" def test_get_output_extension_unknown(self, tmp_path): """Unknown language defaults to .py.""" @@ -229,6 +233,11 @@ def test_get_client_filename_python(self, tmp_path): eng = self._make_engineer(tmp_path, output_language="python") assert eng._get_client_filename() == "api_client.py" + def test_get_client_filename_powershell(self, tmp_path): + """Client filename for PowerShell.""" + eng = self._make_engineer(tmp_path, output_language="powershell") + assert eng._get_client_filename() == "api_client.psm1" + def test_get_client_filename_docs(self, tmp_path): """Client filename for docs mode.""" eng = self._make_engineer(tmp_path, output_mode="docs") @@ -427,6 +436,38 @@ def test_get_run_command_c_resolves_relative_output_dir(self, tmp_path): assert tokens[2] == str(resolved / "cJSON.c") assert tokens[5] == str(resolved / "api_client") + def test_get_run_command_powershell(self, tmp_path): + """Run command for PowerShell points -File at this run's own + (resolved, shell-quoted) Example.ps1, not the module itself — the + agent's cwd is scripts_dir.parent.parent (see analyze_and_generate), + and api_client.psm1 isn't directly runnable, only importable.""" + eng = self._make_engineer(tmp_path, output_language="powershell") + expected_example = shlex.quote(str(eng.scripts_dir.resolve() / "Example.ps1")) + assert eng._get_run_command() == f"pwsh -NoProfile -File {expected_example}" + + def test_get_run_command_powershell_quotes_metacharacters(self, tmp_path): + """A scripts_dir containing shell metacharacters must round-trip + back to the literal path, not be left open to $()/backtick + expansion — what the naive f'"{path}"' approach got wrong.""" + eng = self._make_engineer(tmp_path, output_language="powershell") + eng.scripts_dir = Path("/tmp/weird$(rm -rf ~) dir") + tokens = shlex.split(eng._get_run_command()) + assert tokens[:3] == ["pwsh", "-NoProfile", "-File"] + assert tokens[3] == str(eng.scripts_dir.resolve() / "Example.ps1") + + def test_get_run_command_powershell_resolves_relative_output_dir(self, tmp_path): + """A relative scripts_dir must be resolved to an absolute path before + being embedded in the command — otherwise, once the agent's cwd + moves to scripts_dir.parent.parent, the same relative string gets + re-interpreted from there and points at the wrong, doubly-nested + location.""" + eng = self._make_engineer(tmp_path, output_language="powershell") + eng.scripts_dir = Path("relative_output/scripts/run123") + tokens = shlex.split(eng._get_run_command()) + example_arg = tokens[3] + assert Path(example_arg).is_absolute() + assert example_arg == str(eng.scripts_dir.resolve() / "Example.ps1") + def test_get_run_command_unknown(self, tmp_path): """Unknown language defaults to Python command.""" eng = self._make_engineer(tmp_path, output_language="rust") @@ -442,7 +483,7 @@ def test_get_codegen_instructions_base_version_has_no_verification_instruction(s review: an earlier version of this appended it here directly, which would have told every other backend's agent to call a tool that was never registered in its environment.""" - for language in ("python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby", "c"): + for language in ("python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby", "c", "powershell"): eng = self._make_engineer(tmp_path, output_language=language, output_mode="client") assert REPORT_CLIENT_VERIFIED_INSTRUCTION not in eng._get_codegen_instructions(), language @@ -540,6 +581,12 @@ def test_c_prompt(self, tmp_path): system_prompt, user_message = eng._build_prompts() assert "C program" in system_prompt assert "libcurl" in system_prompt + def test_powershell_prompt(self, tmp_path): + """PowerShell prompt includes PowerShell-specific instructions.""" + eng = self._make_engineer(tmp_path, output_language="powershell") + system_prompt, user_message = eng._build_prompts() + assert "PowerShell module" in system_prompt + assert "Invoke-RestMethod" in system_prompt def test_docs_prompt(self, tmp_path): """Docs mode prompt includes OpenAPI instructions.""" diff --git a/tests/test_engineer.py b/tests/test_engineer.py index b3759b2b..bbc4c682 100644 --- a/tests/test_engineer.py +++ b/tests/test_engineer.py @@ -972,7 +972,7 @@ def test_get_codegen_instructions_appends_for_every_language(self, tmp_path): way, since it's appended in Python after loading whichever one.""" from reverse_api.base_engineer import REPORT_CLIENT_VERIFIED_INSTRUCTION - for language in ("python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby", "c"): + for language in ("python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby", "c", "powershell"): eng = self._make_engineer(tmp_path, output_language=language, output_mode="client") assert eng._get_codegen_instructions().endswith(REPORT_CLIENT_VERIFIED_INSTRUCTION), language diff --git a/tests/test_run_command.py b/tests/test_run_command.py index c0981fe2..a2c46f4e 100644 --- a/tests/test_run_command.py +++ b/tests/test_run_command.py @@ -255,12 +255,12 @@ def test_finds_all_language_extensions(self, scripts_dir_empty, tmp_path): for name in [ "api_client.py", "api_client.js", "api_client.ts", "api_client.go", "api_client.java", "api_client.cs", "api_client.php", - "api_client.rb", "api_client.c", + "api_client.rb", "api_client.c", "api_client.psm1", ]: (scripts_dir_empty / name).write_text("") with patch("reverse_api.utils.get_base_output_dir", return_value=tmp_path): scripts = discover_scripts("abc123def456") - assert len(scripts) == 9 + assert len(scripts) == 10 def test_excludes_vendored_cjson(self, scripts_dir_empty, tmp_path): (scripts_dir_empty / "api_client.c").write_text("") @@ -813,6 +813,14 @@ def test_c_without_cjson(self, tmp_path): steps, _ = build_script_commands(script) assert steps[0] == ["cc", str(script), "-lcurl", "-o", str(tmp_path / "api_client")] + def test_powershell(self, tmp_path): + from reverse_api.utils import build_script_commands + script = tmp_path / "api_client.psm1" + steps, tool = build_script_commands(script, ("--flag",)) + example = str(tmp_path / "Example.ps1") + assert steps == [["pwsh", "-NoProfile", "-File", example, "--flag"]] + assert tool == "pwsh" + def test_unsupported_extension_raises(self, tmp_path): from reverse_api.utils import build_script_commands with pytest.raises(ValueError, match="unsupported script type"): diff --git a/website/content/docs/index.mdx b/website/content/docs/index.mdx index 383836da..33914f09 100644 --- a/website/content/docs/index.mdx +++ b/website/content/docs/index.mdx @@ -5,7 +5,7 @@ description: Capture browser traffic and turn it into a production-ready typed A **Reverse API Engineer** is a CLI tool that captures browser traffic and uses your configured AI SDK to generate production-ready API clients in Python, -JavaScript, TypeScript, Go, Java, C#, PHP, Ruby, or C. No more manual reverse +JavaScript, TypeScript, Go, Java, C#, PHP, Ruby, C, or PowerShell. No more manual reverse engineering: browse, capture, and get clean API code. ${appTagline} -${appName} is an open-source CLI that captures browser traffic — in the default agent mode via a browser MCP server (Playwright or Chrome DevTools) or the Vercel agent-browser CLI, or in manual mode via a local Playwright browser (optional \`[manual]\` extra) — and uses your configured AI SDK to generate a typed API client from the captured requests. Output languages: Python, JavaScript, TypeScript, Go, Java, C#, PHP, Ruby, and C. +${appName} is an open-source CLI that captures browser traffic — in the default agent mode via a browser MCP server (Playwright or Chrome DevTools) or the Vercel agent-browser CLI, or in manual mode via a local Playwright browser (optional \`[manual]\` extra) — and uses your configured AI SDK to generate a typed API client from the captured requests. Output languages: Python, JavaScript, TypeScript, Go, Java, C#, PHP, Ruby, C, and PowerShell. ## Documentation From 4a729c58c66bcada763e473a48531d23dce44106 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Sun, 20 Sep 2026 19:23:39 +0200 Subject: [PATCH 02/13] Harden PowerShell client execution and runtime tests --- README.md | 2 +- .../prompts/partials/_language_powershell.md | 11 ++- src/reverse_api/utils.py | 16 ++-- tests/test_base_engineer.py | 8 +- tests/test_powershell_runtime.py | 80 +++++++++++++++++++ tests/test_run_command.py | 5 +- 6 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 tests/test_powershell_runtime.py diff --git a/README.md b/README.md index e2e1a850..d8c614df 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Settings live in `~/.reverse-api/config.json` and can be edited via `/settings` - **SDK**: `claude` (default), `opencode`, `cursor`, or `copilot` (GitHub Copilot). - **OpenCode setup**: with `sdk: "opencode"`, RAE reuses an existing server or downloads/starts `opencode-ai@latest` through `npx`; a global OpenCode installation is not required. Fresh configurations default to the free `opencode/big-pickle` model. `/settings` shows a loading spinner, then offers a searchable **OpenCode Provider / Model** picker populated from the server's connected, tool-capable catalog and marks free OpenCode options. Before creating a session, RAE validates the saved pair again and suggests currently available free models when configuration is invalid. Compatible older servers are reused with a version warning. Node.js 20+ is required for automatic startup. Password-protected servers use `OPENCODE_SERVER_PASSWORD` and optional `OPENCODE_SERVER_USERNAME`. Override startup with `OPENCODE_BASE_URL`, `RAE_OPENCODE_PACKAGE`, or `RAE_OPENCODE_AUTO_START=0`. - **Ollama through OpenCode**: choose provider `ollama` in `/settings`; RAE starts an installed Ollama daemon if needed, lists only installed models with tool calling and 64k+ context, and supplies OpenCode's provider config inline. Models are never downloaded silently. Override with `RAE_OLLAMA_BASE_URL` or `RAE_OLLAMA_AUTO_START=0`. -- **Output language**: `python`, `javascript`, `typescript`, `go`, `java`, `csharp`, `php`, `ruby`, `c`, or `powershell`. C needs a POSIX toolchain (`cc`, libcurl headers) — macOS/Linux, or WSL/MSYS2 on Windows. PowerShell needs `pwsh` 7+ (PowerShell Core, not Windows PowerShell 5.1). +- **Output language**: `python`, `javascript`, `typescript`, `go`, `java`, `csharp`, `php`, `ruby`, `c`, or `powershell`. C needs a POSIX toolchain (`cc`, libcurl headers) — macOS/Linux, or WSL/MSYS2 on Windows. PowerShell needs `pwsh` 7+ (PowerShell Core, not Windows PowerShell 5.1). Its runner executes the generated `Example.ps1` beside `api_client.psm1`; argument overrides are not supported. Edit the example or import the module to call its exported functions with your own arguments. ## CLI diff --git a/src/reverse_api/prompts/partials/_language_powershell.md b/src/reverse_api/prompts/partials/_language_powershell.md index 2ebbe2ed..96f07775 100644 --- a/src/reverse_api/prompts/partials/_language_powershell.md +++ b/src/reverse_api/prompts/partials/_language_powershell.md @@ -7,7 +7,7 @@ - Is exported explicitly via `Export-ModuleMember -Function ` at the bottom of the file. Do not use wildcard export (`Export-ModuleMember -Function *`) - HTTP calls use `Invoke-RestMethod` (or `Invoke-WebRequest` only when raw headers/status codes are needed). Never shell out to `curl.exe` or `curl` - Session/cookie handling: use `-SessionVariable`/`-WebSession` with `[Microsoft.PowerShell.Commands.WebRequestSession]`, not manual cookie header construction, unless the API requires a cookie value that PowerShell's cookie jar can't express -- Error handling: every network call wrapped in a `try`/`catch` block with `-ErrorAction Stop` on the call itself. Catch blocks should surface `$_.Exception.Message` and, where the failure is an HTTP error, the response body if retrievable, not swallow the error silently +- Error handling: every network call wrapped in a `try`/`catch` block with `-ErrorAction Stop` on the call itself. Catch blocks should surface `$_.Exception.Message` and, where the failure is an HTTP error, the response body if retrievable, then rethrow with `throw`; never swallow the error or only call `Write-Error` and continue - Use `[PSCustomObject]` for structured return values, not raw hashtables, so downstream `ConvertTo-Json` and property access behave predictably - Prefer `ConvertTo-Json`/`ConvertFrom-Json` (built-in) over any third-party JSON handling - No `Write-Host` for data output — use `Write-Output`/return values. `Write-Verbose`/`Write-Error` are fine for diagnostics @@ -26,9 +26,14 @@ Save the module to: `{scripts_dir}/{client_filename}` Save documentation to: `{scripts_dir}/README.md` +The example uses fixed demonstration arguments. CLI argument overrides are not supported; document how to edit the example or import the module and call its functions directly. + Save the example script to: `{scripts_dir}/Example.ps1`, which does: ```powershell -Import-Module "$PSScriptRoot\{client_filename}" -Force -# example invocation(s) of the exported function(s) +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot '{client_filename}') -Force -ErrorAction Stop +# Invoke the exported function(s), using valid demonstration arguments. +# Serialize returned data with ConvertTo-Json -Depth 20. +# Let terminating errors propagate so pwsh exits nonzero on failure. ``` Do not generate a `.psd1` module manifest. diff --git a/src/reverse_api/utils.py b/src/reverse_api/utils.py index 1e9874fe..b53eb644 100644 --- a/src/reverse_api/utils.py +++ b/src/reverse_api/utils.py @@ -831,12 +831,18 @@ def build_script_commands(script: Path, script_args: tuple[str, ...] = ()) -> tu compile_cmd += ["-lcurl", "-o", str(binary)] return [compile_cmd, [str(binary), *script_args]], "cc" if suffix == ".psm1": - # A .psm1 is a module, not a runnable entry point — like Java/C#'s - # pom.xml/csproj, the actual command targets a fixed companion file - # (Example.ps1) that Imports the module and calls its exported - # functions, not the script argument itself. + # The generated example is bound to api_client.psm1, not an arbitrary module. + if script.name != "api_client.psm1": + raise ValueError("PowerShell execution only supports api_client.psm1 with its companion Example.ps1") + if script_args: + raise ValueError( + "script arguments are not supported for PowerShell clients: " + "edit Example.ps1 or import api_client.psm1 and call its exported functions directly" + ) example = d / "Example.ps1" - return [["pwsh", "-NoProfile", "-File", str(example), *script_args]], "pwsh" + if not example.is_file(): + raise ValueError(f"cannot run {script.name}: companion Example.ps1 is missing") + return [["pwsh", "-NoProfile", "-File", str(example)]], "pwsh" raise ValueError(f"unsupported script type: {script.name}") diff --git a/tests/test_base_engineer.py b/tests/test_base_engineer.py index 670f7734..4e134c48 100644 --- a/tests/test_base_engineer.py +++ b/tests/test_base_engineer.py @@ -460,7 +460,7 @@ def test_get_run_command_powershell(self, tmp_path): agent's cwd is scripts_dir.parent.parent (see analyze_and_generate), and api_client.psm1 isn't directly runnable, only importable.""" eng = self._make_engineer(tmp_path, output_language="powershell") - expected_example = shlex.quote(str(eng.scripts_dir.resolve() / "Example.ps1")) + expected_example = eng._quote_path(str(eng.scripts_dir.resolve() / "Example.ps1")) assert eng._get_run_command() == f"pwsh -NoProfile -File {expected_example}" def test_get_run_command_powershell_quotes_metacharacters(self, tmp_path): @@ -468,8 +468,8 @@ def test_get_run_command_powershell_quotes_metacharacters(self, tmp_path): back to the literal path, not be left open to $()/backtick expansion — what the naive f'"{path}"' approach got wrong.""" eng = self._make_engineer(tmp_path, output_language="powershell") - eng.scripts_dir = Path("/tmp/weird$(rm -rf ~) dir") - tokens = shlex.split(eng._get_run_command()) + eng.scripts_dir = tmp_path / "weird$(marker) dir" + tokens = _split_run_command(eng._get_run_command()) assert tokens[:3] == ["pwsh", "-NoProfile", "-File"] assert tokens[3] == str(eng.scripts_dir.resolve() / "Example.ps1") @@ -481,7 +481,7 @@ def test_get_run_command_powershell_resolves_relative_output_dir(self, tmp_path) location.""" eng = self._make_engineer(tmp_path, output_language="powershell") eng.scripts_dir = Path("relative_output/scripts/run123") - tokens = shlex.split(eng._get_run_command()) + tokens = _split_run_command(eng._get_run_command()) example_arg = tokens[3] assert Path(example_arg).is_absolute() assert example_arg == str(eng.scripts_dir.resolve() / "Example.ps1") diff --git a/tests/test_powershell_runtime.py b/tests/test_powershell_runtime.py new file mode 100644 index 00000000..86573933 --- /dev/null +++ b/tests/test_powershell_runtime.py @@ -0,0 +1,80 @@ +"""Exercise the generated-module runner using a real PowerShell process.""" + +import json +import os +import shutil +import subprocess + +import pytest + +from reverse_api.utils import build_script_commands + + +@pytest.fixture +def module_path(tmp_path): + directory = tmp_path / "client space é $literal" + directory.mkdir() + module = directory / "api_client.psm1" + module.write_text( + "function Get-Result { [CmdletBinding()] param(); " + "[PSCustomObject]@{ value = 'café'; count = 2 } }; " + "Export-ModuleMember -Function Get-Result\n", + encoding="utf-8", + ) + (directory / "Example.ps1").write_text( + "$ErrorActionPreference = 'Stop'\n" + "Import-Module (Join-Path $PSScriptRoot 'api_client.psm1') -Force -ErrorAction Stop\n" + "Get-Result | ConvertTo-Json -Depth 20\n", + encoding="utf-8", + ) + return module + + +@pytest.fixture +def pwsh(): + executable = shutil.which("pwsh") + if executable is None: + if os.environ.get("RAE_REQUIRE_PWSH") == "1": + pytest.fail("PowerShell 7+ is required for this test job") + pytest.skip("PowerShell 7+ is not installed") + return executable + + +def test_powershell_runs_from_other_directory(module_path, tmp_path, pwsh): + commands, _ = build_script_commands(module_path) + commands[0][0] = pwsh + result = subprocess.run(commands[0], cwd=tmp_path, capture_output=True, text=True, encoding="utf-8", timeout=30) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == {"value": "café", "count": 2} + + +def test_powershell_failure_is_nonzero(module_path, pwsh): + module_path.write_text( + "function Get-Result { [CmdletBinding()] param(); " + "try { throw 'request failed' } catch { throw } }; " + "Export-ModuleMember -Function Get-Result\n", + encoding="utf-8", + ) + commands, _ = build_script_commands(module_path) + commands[0][0] = pwsh + result = subprocess.run(commands[0], capture_output=True, text=True, encoding="utf-8", timeout=30) + assert result.returncode != 0 + assert "request failed" in result.stderr + + +def test_powershell_rejects_silently_ignored_arguments(module_path): + with pytest.raises(ValueError, match="script arguments are not supported"): + build_script_commands(module_path, ("-Value", "requested")) + + +def test_powershell_rejects_unrelated_module(module_path): + other = module_path.with_name("other.psm1") + other.write_text("throw 'wrong module'", encoding="utf-8") + with pytest.raises(ValueError, match="only supports api_client.psm1"): + build_script_commands(other) + + +def test_powershell_reports_missing_example(module_path): + module_path.with_name("Example.ps1").unlink() + with pytest.raises(ValueError, match="companion Example.ps1 is missing"): + build_script_commands(module_path) diff --git a/tests/test_run_command.py b/tests/test_run_command.py index 2329c259..5b4ed626 100644 --- a/tests/test_run_command.py +++ b/tests/test_run_command.py @@ -817,9 +817,10 @@ def test_c_without_cjson(self, tmp_path): def test_powershell(self, tmp_path): from reverse_api.utils import build_script_commands script = tmp_path / "api_client.psm1" - steps, tool = build_script_commands(script, ("--flag",)) + (tmp_path / "Example.ps1").write_text("", encoding="utf-8") + steps, tool = build_script_commands(script) example = str(tmp_path / "Example.ps1") - assert steps == [["pwsh", "-NoProfile", "-File", example, "--flag"]] + assert steps == [["pwsh", "-NoProfile", "-File", example]] assert tool == "pwsh" def test_unsupported_extension_raises(self, tmp_path): From daf733ddba2947039b1756be04fdc3ba3a28a6b6 Mon Sep 17 00:00:00 2001 From: kalil0321 <92564081+kalil0321@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:26:10 +0200 Subject: [PATCH 03/13] Require PowerShell runtime tests and CLI smoke checks in CI Increased timeout for pytest job and added PowerShell version verification step. --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87c8be2f..310fef2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,10 +46,13 @@ jobs: test: name: pytest (py${{ matrix.python-version }}, ${{ matrix.os }}) + timeout-minutes: 15 runs-on: ${{ matrix.os }} env: # Make the matrix interpreter explicit for every uv invocation. UV_PYTHON: ${{ matrix.python-version }} + # Exercise real PowerShell execution on every platform; do not silently skip. + RAE_REQUIRE_PWSH: "1" strategy: fail-fast: false matrix: @@ -63,4 +66,10 @@ jobs: python-version: ${{ matrix.python-version }} # --locked: fail loudly if uv.lock is stale instead of silently re-resolving. - run: uv sync --all-extras --locked + - name: Verify PowerShell 7+ + shell: pwsh + run: | + if ($PSVersionTable.PSVersion.Major -lt 7) { throw 'PowerShell 7+ required' } + - run: uv run reverse-api-engineer --version + - run: uv run reverse-api-engineer --help - run: uv run pytest -q From 99379e399ee096fcb3b9779916769c48a73ad6d9 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Sun, 20 Sep 2026 19:36:44 +0200 Subject: [PATCH 04/13] Add live browser-to-client generation acceptance harness --- .gitignore | 1 + scripts/ci/generation_e2e.py | 164 +++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 scripts/ci/generation_e2e.py diff --git a/.gitignore b/.gitignore index 9772cd93..2d674a13 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ cython_debug/ har/ scripts/* !scripts/clean_build.sh +!scripts/ci/ scripts_built/ src/reverse_api/scripts/ .reverse-api/ diff --git a/scripts/ci/generation_e2e.py b/scripts/ci/generation_e2e.py new file mode 100644 index 00000000..5805c786 --- /dev/null +++ b/scripts/ci/generation_e2e.py @@ -0,0 +1,164 @@ +"""Live CI acceptance: browser capture -> free model -> generated client -> fresh API data. + +Run only on an ephemeral GitHub-hosted runner. This deliberately uses no model +mock, production website, repository secret, or paid-model fallback. +""" + +import argparse +import asyncio +import json +import os +import secrets +import subprocess +import sys +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import httpx2 + +from reverse_api.opencode_runtime import ( + ensure_opencode_server, + get_opencode_model_catalog, + opencode_model_is_selectable, + stop_managed_opencode_server, +) + + +class FixtureServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self): + super().__init__(("127.0.0.1", 0), FixtureHandler) + self.nonce = secrets.token_hex(16) + self.page_hits = 0 + self.api_hits = 0 + + def payload(self): + return {"items": [{"id": 1, "name": "sample product"}], "nonce": self.nonce} + + +class FixtureHandler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_GET(self): + if self.path == "/": + self.server.page_hits += 1 + data = ( + b'CI catalog

Product catalog

' + b'
'
+                b''
+            )
+            content_type = "text/html; charset=utf-8"
+        elif self.path == "/api/products":
+            self.server.api_hits += 1
+            data = json.dumps(self.server.payload()).encode()
+            content_type = "application/json"
+        else:
+            self.send_error(404)
+            return
+        self.send_response(200)
+        self.send_header("Content-Type", content_type)
+        self.send_header("Content-Length", str(len(data)))
+        self.send_header("Cache-Control", "no-store")
+        self.end_headers()
+        self.wfile.write(data)
+
+
+async def require_free_model(base_url, model_id):
+    async with httpx2.AsyncClient(base_url=base_url) as client:
+        await ensure_opencode_server(client, base_url=base_url)
+        catalog = await get_opencode_model_catalog(client)
+    provider = next((p for p in catalog["providers"] if p.get("id") == "opencode"), {})
+    model = provider.get("models", {}).get(model_id, {})
+    cost = model.get("cost", {})
+    if not opencode_model_is_selectable(model) or cost.get("input") != 0 or cost.get("output") != 0:
+        raise RuntimeError(f"opencode/{model_id} is unavailable or not explicitly free; no fallback allowed")
+
+
+def invoke_cli(args, cwd, timeout):
+    result = subprocess.run(
+        [sys.executable, "-c", "from reverse_api.cli import main; main()", *args],
+        cwd=cwd, capture_output=True, text=True, encoding="utf-8", timeout=timeout,
+    )
+    if result.returncode:
+        raise RuntimeError(f"CLI {args[0]} failed ({result.returncode})\n{result.stderr[-6000:]}\n{result.stdout[-6000:]}")
+    payload = json.loads(result.stdout)
+    if payload.get("status") != "ok":
+        raise RuntimeError(f"CLI {args[0]} did not succeed: {payload}")
+    return payload
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--language", choices=["python", "powershell"], required=True)
+    args = parser.parse_args()
+    if os.environ.get("GITHUB_ACTIONS") != "true":
+        raise SystemExit("This live test requires an ephemeral GitHub Actions runner")
+    model = "big-pickle"
+    config_dir = Path.home() / ".reverse-api"
+    config_dir.mkdir(exist_ok=True)
+    config_path = config_dir / "config.json"
+    if config_path.exists():
+        raise SystemExit("Refusing to overwrite an existing RAE configuration")
+    config_path.write_text(json.dumps({
+        "sdk": "opencode", "opencode_provider": "opencode", "opencode_model": model,
+        "output_language": args.language, "real_time_sync": False, "agent_provider": "auto",
+    }), encoding="utf-8")
+    os.environ["RAE_OPENCODE_PACKAGE"] = "opencode-ai@1.18.31"
+    os.environ["PYTHONIOENCODING"] = "utf-8"
+    server = FixtureServer()
+    worker = threading.Thread(target=server.serve_forever, daemon=True)
+    worker.start()
+    base_url = "http://127.0.0.1:4096"
+    try:
+        asyncio.run(require_free_model(base_url, model))
+        with tempfile.TemporaryDirectory(prefix="rae live ") as directory:
+            url = f"http://127.0.0.1:{server.server_port}"
+            prompt = (
+                f"Open {url} using the browser MCP and load the products. Capture the API traffic. "
+                f"Generate a {args.language} client for the products API. Its example/main must "
+                "fetch the current response and print exactly that response as JSON, without labels. "
+                "Do not hardcode response data. Preserve all response fields. Test the client. "
+                "Use only this local website, no authentication and no external API."
+            )
+            generated = invoke_cli(["agent", "--headless", "--json", "--url", url, "--prompt", prompt], directory, 600)
+            script = Path(generated["script_path"])
+            expected_name = "api_client.psm1" if args.language == "powershell" else "api_client.py"
+            if script.name != expected_name or not script.is_file() or not script.stat().st_size:
+                raise RuntimeError("Generation did not create the requested client")
+            har = Path(generated.get("har_path") or "missing.har")
+            if not har.is_file():
+                raise RuntimeError("Browser capture did not produce a HAR")
+            entries = json.loads(har.read_text(encoding="utf-8"))["log"]["entries"]
+            if not any(e["request"]["url"] == f"{url}/api/products" for e in entries):
+                raise RuntimeError("HAR does not contain the fixture API request")
+            if not server.page_hits or not server.api_hits:
+                raise RuntimeError("The fixture website and API were not visited")
+            # A new value unavailable during generation rejects cached/hardcoded examples.
+            server.nonce = secrets.token_hex(16)
+            previous_hits = server.api_hits
+            replayed = invoke_cli([
+                "run", generated["run_id"], "--file", expected_name, "--json", "--auto-install",
+            ], directory, 120)
+            if replayed.get("returncode") != 0 or json.loads(replayed["stdout"]) != server.payload():
+                raise RuntimeError("Generated client did not return fresh, correct API data")
+            if server.api_hits <= previous_hits:
+                raise RuntimeError("Client replay never contacted the API")
+            print(json.dumps({"status": "ok", "language": args.language, "model": f"opencode/{model}",
+                              "har_entries": len(entries), "page_hits": server.page_hits, "api_hits": server.api_hits,
+                              "fresh_response_verified": True}))
+    finally:
+        server.shutdown()
+        server.server_close()
+        worker.join(timeout=5)
+        stop_managed_opencode_server()
+        config_path.unlink(missing_ok=True)
+
+
+if __name__ == "__main__":
+    main()

From c8934ffbc27b287b3f576dbf9a82c3dcee28c0cf Mon Sep 17 00:00:00 2001
From: kalil0321 <92564081+kalil0321@users.noreply.github.com>
Date: Sun, 20 Sep 2026 19:37:45 +0200
Subject: [PATCH 05/13] Run live OpenCode generation on Windows and Linux

---
 .github/workflows/generation-e2e.yml | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 .github/workflows/generation-e2e.yml

diff --git a/.github/workflows/generation-e2e.yml b/.github/workflows/generation-e2e.yml
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/.github/workflows/generation-e2e.yml
@@ -0,0 +1 @@
+

From b8bb82f5b1c8ca9dcc534c2a17436abb496d478a Mon Sep 17 00:00:00 2001
From: kalil0321 <92564081+kalil0321@users.noreply.github.com>
Date: Sun, 20 Sep 2026 19:38:54 +0200
Subject: [PATCH 06/13] Configure live generation matrix and free-model
 acceptance checks

---
 .github/workflows/generation-e2e.yml | 57 ++++++++++++++++++++++++++++
 1 file changed, 57 insertions(+)

diff --git a/.github/workflows/generation-e2e.yml b/.github/workflows/generation-e2e.yml
index 8b137891..0c5bd09f 100644
--- a/.github/workflows/generation-e2e.yml
+++ b/.github/workflows/generation-e2e.yml
@@ -1 +1,58 @@
+name: Live client generation
 
+on:
+  workflow_dispatch:
+  pull_request:
+    paths:
+      - 'src/**'
+      - 'scripts/ci/**'
+      - 'pyproject.toml'
+      - 'uv.lock'
+      - '.github/workflows/generation-e2e.yml'
+  push:
+    branches: [main]
+    paths:
+      - 'src/**'
+      - 'scripts/ci/**'
+      - 'pyproject.toml'
+      - 'uv.lock'
+      - '.github/workflows/generation-e2e.yml'
+
+permissions:
+  contents: read
+
+concurrency:
+  group: live-generation-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  generate:
+    name: ${{ matrix.os }} / ${{ matrix.language }} / OpenCode free
+    runs-on: ${{ matrix.os }}
+    timeout-minutes: 15
+    strategy:
+      fail-fast: false
+      max-parallel: 2
+      matrix:
+        os: [windows-latest, ubuntu-latest]
+        language: [powershell, python]
+    env:
+      UV_PYTHON: '3.13'
+      PYTHONIOENCODING: utf-8
+      RAE_OPENCODE_PACKAGE: opencode-ai@1.18.31
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+        with:
+          persist-credentials: false
+      - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
+        with:
+          python-version: '3.13'
+          enable-cache: true
+      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+        with:
+          node-version: '22'
+      - run: uv sync --locked --all-extras
+      - name: Install browser matching rae-playwright-mcp
+        run: npx --yes playwright@1.58.0-alpha-2025-12-29 install --with-deps chromium
+      - name: Generate and execute a real client against fresh fixture data
+        run: uv run python scripts/ci/generation_e2e.py --language ${{ matrix.language }}

From 2d251bdaa9ba97973bdcaae0919ec7a91634e04e Mon Sep 17 00:00:00 2001
From: kalil0321 <92564081+kalil0321@users.noreply.github.com>
Date: Sun, 20 Sep 2026 19:46:35 +0200
Subject: [PATCH 07/13] Keep live client generation local; remove slow E2E
 workflow

---
 .github/workflows/generation-e2e.yml | 58 ----------------------------
 1 file changed, 58 deletions(-)
 delete mode 100644 .github/workflows/generation-e2e.yml

diff --git a/.github/workflows/generation-e2e.yml b/.github/workflows/generation-e2e.yml
deleted file mode 100644
index 0c5bd09f..00000000
--- a/.github/workflows/generation-e2e.yml
+++ /dev/null
@@ -1,58 +0,0 @@
-name: Live client generation
-
-on:
-  workflow_dispatch:
-  pull_request:
-    paths:
-      - 'src/**'
-      - 'scripts/ci/**'
-      - 'pyproject.toml'
-      - 'uv.lock'
-      - '.github/workflows/generation-e2e.yml'
-  push:
-    branches: [main]
-    paths:
-      - 'src/**'
-      - 'scripts/ci/**'
-      - 'pyproject.toml'
-      - 'uv.lock'
-      - '.github/workflows/generation-e2e.yml'
-
-permissions:
-  contents: read
-
-concurrency:
-  group: live-generation-${{ github.ref }}
-  cancel-in-progress: true
-
-jobs:
-  generate:
-    name: ${{ matrix.os }} / ${{ matrix.language }} / OpenCode free
-    runs-on: ${{ matrix.os }}
-    timeout-minutes: 15
-    strategy:
-      fail-fast: false
-      max-parallel: 2
-      matrix:
-        os: [windows-latest, ubuntu-latest]
-        language: [powershell, python]
-    env:
-      UV_PYTHON: '3.13'
-      PYTHONIOENCODING: utf-8
-      RAE_OPENCODE_PACKAGE: opencode-ai@1.18.31
-    steps:
-      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
-        with:
-          persist-credentials: false
-      - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
-        with:
-          python-version: '3.13'
-          enable-cache: true
-      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
-        with:
-          node-version: '22'
-      - run: uv sync --locked --all-extras
-      - name: Install browser matching rae-playwright-mcp
-        run: npx --yes playwright@1.58.0-alpha-2025-12-29 install --with-deps chromium
-      - name: Generate and execute a real client against fresh fixture data
-        run: uv run python scripts/ci/generation_e2e.py --language ${{ matrix.language }}

From f343d02f7be6c4f67e35106f84224aa6803e4b9d Mon Sep 17 00:00:00 2001
From: kalil0321 
Date: Sun, 20 Sep 2026 19:51:14 +0200
Subject: [PATCH 08/13] Move live generation validation to an isolated local
 test

---
 .gitignore                           |   2 +-
 scripts/ci/generation_e2e.py         | 164 ---------------------
 scripts/validation/README.md         |  33 +++++
 scripts/validation/generation_e2e.py | 206 +++++++++++++++++++++++++++
 4 files changed, 240 insertions(+), 165 deletions(-)
 delete mode 100644 scripts/ci/generation_e2e.py
 create mode 100644 scripts/validation/README.md
 create mode 100644 scripts/validation/generation_e2e.py

diff --git a/.gitignore b/.gitignore
index 2d674a13..e41171fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,7 +81,7 @@ cython_debug/
 har/
 scripts/*
 !scripts/clean_build.sh
-!scripts/ci/
+!scripts/validation/
 scripts_built/
 src/reverse_api/scripts/
 .reverse-api/
diff --git a/scripts/ci/generation_e2e.py b/scripts/ci/generation_e2e.py
deleted file mode 100644
index 5805c786..00000000
--- a/scripts/ci/generation_e2e.py
+++ /dev/null
@@ -1,164 +0,0 @@
-"""Live CI acceptance: browser capture -> free model -> generated client -> fresh API data.
-
-Run only on an ephemeral GitHub-hosted runner. This deliberately uses no model
-mock, production website, repository secret, or paid-model fallback.
-"""
-
-import argparse
-import asyncio
-import json
-import os
-import secrets
-import subprocess
-import sys
-import tempfile
-import threading
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-from pathlib import Path
-
-import httpx2
-
-from reverse_api.opencode_runtime import (
-    ensure_opencode_server,
-    get_opencode_model_catalog,
-    opencode_model_is_selectable,
-    stop_managed_opencode_server,
-)
-
-
-class FixtureServer(ThreadingHTTPServer):
-    daemon_threads = True
-
-    def __init__(self):
-        super().__init__(("127.0.0.1", 0), FixtureHandler)
-        self.nonce = secrets.token_hex(16)
-        self.page_hits = 0
-        self.api_hits = 0
-
-    def payload(self):
-        return {"items": [{"id": 1, "name": "sample product"}], "nonce": self.nonce}
-
-
-class FixtureHandler(BaseHTTPRequestHandler):
-    def log_message(self, *_args):
-        pass
-
-    def do_GET(self):
-        if self.path == "/":
-            self.server.page_hits += 1
-            data = (
-                b'CI catalog

Product catalog

' - b'
'
-                b''
-            )
-            content_type = "text/html; charset=utf-8"
-        elif self.path == "/api/products":
-            self.server.api_hits += 1
-            data = json.dumps(self.server.payload()).encode()
-            content_type = "application/json"
-        else:
-            self.send_error(404)
-            return
-        self.send_response(200)
-        self.send_header("Content-Type", content_type)
-        self.send_header("Content-Length", str(len(data)))
-        self.send_header("Cache-Control", "no-store")
-        self.end_headers()
-        self.wfile.write(data)
-
-
-async def require_free_model(base_url, model_id):
-    async with httpx2.AsyncClient(base_url=base_url) as client:
-        await ensure_opencode_server(client, base_url=base_url)
-        catalog = await get_opencode_model_catalog(client)
-    provider = next((p for p in catalog["providers"] if p.get("id") == "opencode"), {})
-    model = provider.get("models", {}).get(model_id, {})
-    cost = model.get("cost", {})
-    if not opencode_model_is_selectable(model) or cost.get("input") != 0 or cost.get("output") != 0:
-        raise RuntimeError(f"opencode/{model_id} is unavailable or not explicitly free; no fallback allowed")
-
-
-def invoke_cli(args, cwd, timeout):
-    result = subprocess.run(
-        [sys.executable, "-c", "from reverse_api.cli import main; main()", *args],
-        cwd=cwd, capture_output=True, text=True, encoding="utf-8", timeout=timeout,
-    )
-    if result.returncode:
-        raise RuntimeError(f"CLI {args[0]} failed ({result.returncode})\n{result.stderr[-6000:]}\n{result.stdout[-6000:]}")
-    payload = json.loads(result.stdout)
-    if payload.get("status") != "ok":
-        raise RuntimeError(f"CLI {args[0]} did not succeed: {payload}")
-    return payload
-
-
-def main():
-    parser = argparse.ArgumentParser()
-    parser.add_argument("--language", choices=["python", "powershell"], required=True)
-    args = parser.parse_args()
-    if os.environ.get("GITHUB_ACTIONS") != "true":
-        raise SystemExit("This live test requires an ephemeral GitHub Actions runner")
-    model = "big-pickle"
-    config_dir = Path.home() / ".reverse-api"
-    config_dir.mkdir(exist_ok=True)
-    config_path = config_dir / "config.json"
-    if config_path.exists():
-        raise SystemExit("Refusing to overwrite an existing RAE configuration")
-    config_path.write_text(json.dumps({
-        "sdk": "opencode", "opencode_provider": "opencode", "opencode_model": model,
-        "output_language": args.language, "real_time_sync": False, "agent_provider": "auto",
-    }), encoding="utf-8")
-    os.environ["RAE_OPENCODE_PACKAGE"] = "opencode-ai@1.18.31"
-    os.environ["PYTHONIOENCODING"] = "utf-8"
-    server = FixtureServer()
-    worker = threading.Thread(target=server.serve_forever, daemon=True)
-    worker.start()
-    base_url = "http://127.0.0.1:4096"
-    try:
-        asyncio.run(require_free_model(base_url, model))
-        with tempfile.TemporaryDirectory(prefix="rae live ") as directory:
-            url = f"http://127.0.0.1:{server.server_port}"
-            prompt = (
-                f"Open {url} using the browser MCP and load the products. Capture the API traffic. "
-                f"Generate a {args.language} client for the products API. Its example/main must "
-                "fetch the current response and print exactly that response as JSON, without labels. "
-                "Do not hardcode response data. Preserve all response fields. Test the client. "
-                "Use only this local website, no authentication and no external API."
-            )
-            generated = invoke_cli(["agent", "--headless", "--json", "--url", url, "--prompt", prompt], directory, 600)
-            script = Path(generated["script_path"])
-            expected_name = "api_client.psm1" if args.language == "powershell" else "api_client.py"
-            if script.name != expected_name or not script.is_file() or not script.stat().st_size:
-                raise RuntimeError("Generation did not create the requested client")
-            har = Path(generated.get("har_path") or "missing.har")
-            if not har.is_file():
-                raise RuntimeError("Browser capture did not produce a HAR")
-            entries = json.loads(har.read_text(encoding="utf-8"))["log"]["entries"]
-            if not any(e["request"]["url"] == f"{url}/api/products" for e in entries):
-                raise RuntimeError("HAR does not contain the fixture API request")
-            if not server.page_hits or not server.api_hits:
-                raise RuntimeError("The fixture website and API were not visited")
-            # A new value unavailable during generation rejects cached/hardcoded examples.
-            server.nonce = secrets.token_hex(16)
-            previous_hits = server.api_hits
-            replayed = invoke_cli([
-                "run", generated["run_id"], "--file", expected_name, "--json", "--auto-install",
-            ], directory, 120)
-            if replayed.get("returncode") != 0 or json.loads(replayed["stdout"]) != server.payload():
-                raise RuntimeError("Generated client did not return fresh, correct API data")
-            if server.api_hits <= previous_hits:
-                raise RuntimeError("Client replay never contacted the API")
-            print(json.dumps({"status": "ok", "language": args.language, "model": f"opencode/{model}",
-                              "har_entries": len(entries), "page_hits": server.page_hits, "api_hits": server.api_hits,
-                              "fresh_response_verified": True}))
-    finally:
-        server.shutdown()
-        server.server_close()
-        worker.join(timeout=5)
-        stop_managed_opencode_server()
-        config_path.unlink(missing_ok=True)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/scripts/validation/README.md b/scripts/validation/README.md
new file mode 100644
index 00000000..54229c5f
--- /dev/null
+++ b/scripts/validation/README.md
@@ -0,0 +1,33 @@
+# Local live generation check
+
+Run this manually with the Python environment containing the RAE package you
+want to validate. Node.js/npm and a supported Playwright browser are required;
+PowerShell 7+ is required for the PowerShell case.
+
+```sh
+python scripts/validation/generation_e2e.py --language python
+python scripts/validation/generation_e2e.py --language powershell
+```
+
+The script starts a local website, asks OpenCode to capture its API and generate
+a client, then runs that client through `reverse-api-engineer run`. It changes
+the server response after generation and checks both the returned data and a new
+HTTP request, so cached or hardcoded answers cannot pass.
+
+Only `opencode/big-pickle` is allowed. Its advertised input/output prices must
+both be zero; otherwise the check fails without selecting another model. Free
+model availability and network access can still affect this optional test.
+
+RAE configuration is redirected inside the test process and CLI subprocesses;
+OpenCode uses separate XDG directories and the browser uses an isolated profile.
+No existing user configuration is overwritten. The interpreter must be the one
+from the installation under test (for example `/path/to/venv/bin/python`).
+
+Logs, HAR capture, generated clients and OpenCode state are retained in the
+printed temporary directory. Pass `--artifacts-dir /path/to/new-directory` to
+choose a location; an existing directory is rejected. The local fixture server
+stops when the test ends, so retained clients need a new matching server before
+they can be replayed again.
+
+This check does not run in GitHub Actions. Regular CI keeps the deterministic
+unit, CLI startup and real PowerShell execution checks across all three OSes.
diff --git a/scripts/validation/generation_e2e.py b/scripts/validation/generation_e2e.py
new file mode 100644
index 00000000..5e4894b4
--- /dev/null
+++ b/scripts/validation/generation_e2e.py
@@ -0,0 +1,206 @@
+"""Opt-in local acceptance: browser capture -> free model -> client -> fresh API data.
+
+Run with the Python environment containing the version of RAE to test. All test
+configuration and artifacts live in a new directory; user settings are untouched.
+No model mock, production website, credentials, or paid-model fallback is used.
+"""
+
+import argparse
+import asyncio
+import json
+import os
+import secrets
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from importlib.metadata import version
+from pathlib import Path
+
+import httpx2
+
+from reverse_api.opencode_runtime import (
+    ensure_opencode_server,
+    get_opencode_model_catalog,
+    opencode_model_is_selectable,
+    stop_managed_opencode_server,
+)
+
+
+class FixtureServer(ThreadingHTTPServer):
+    daemon_threads = True
+
+    def __init__(self):
+        super().__init__(("127.0.0.1", 0), FixtureHandler)
+        self.nonce = secrets.token_hex(16)
+        self.page_hits = 0
+        self.api_hits = 0
+
+    def payload(self):
+        return {"items": [{"id": 1, "name": "sample product"}], "nonce": self.nonce}
+
+
+class FixtureHandler(BaseHTTPRequestHandler):
+    def log_message(self, *_args):
+        pass
+
+    def do_GET(self):
+        if self.path == "/":
+            self.server.page_hits += 1
+            data = (
+                b'CI catalog

Product catalog

' + b'
'
+                b''
+            )
+            content_type = "text/html; charset=utf-8"
+        elif self.path == "/api/products":
+            self.server.api_hits += 1
+            data = json.dumps(self.server.payload()).encode()
+            content_type = "application/json"
+        else:
+            self.send_error(404)
+            return
+        self.send_response(200)
+        self.send_header("Content-Type", content_type)
+        self.send_header("Content-Length", str(len(data)))
+        self.send_header("Cache-Control", "no-store")
+        self.end_headers()
+        self.wfile.write(data)
+
+
+async def require_free_model(base_url, model_id):
+    async with httpx2.AsyncClient(base_url=base_url) as client:
+        await ensure_opencode_server(client, base_url=base_url)
+        catalog = await get_opencode_model_catalog(client)
+    provider = next((p for p in catalog["providers"] if p.get("id") == "opencode"), {})
+    model = provider.get("models", {}).get(model_id, {})
+    cost = model.get("cost", {})
+    if not opencode_model_is_selectable(model) or cost.get("input") != 0 or cost.get("output") != 0:
+        raise RuntimeError(f"opencode/{model_id} is unavailable or not explicitly free; no fallback allowed")
+    return {"provider": "opencode", "model": model_id, "cost": cost}
+
+
+def invoke_cli(args, cwd, timeout):
+    result = subprocess.run(
+        [sys.executable, "-c",
+         "import os; from pathlib import Path; import reverse_api.utils as u; "
+         "u.get_app_dir = lambda: Path(os.environ['RAE_E2E_CONFIG_DIR']); "
+         "from reverse_api.cli import main; main()", *args],
+        cwd=cwd, capture_output=True, text=True, encoding="utf-8", timeout=timeout,
+    )
+    (Path(cwd) / f"{args[0]}.stdout.log").write_text(result.stdout, encoding="utf-8")
+    (Path(cwd) / f"{args[0]}.stderr.log").write_text(result.stderr, encoding="utf-8")
+    if result.returncode:
+        raise RuntimeError(f"CLI {args[0]} failed ({result.returncode})\n{result.stderr[-6000:]}\n{result.stdout[-6000:]}")
+    payload = json.loads(result.stdout)
+    if payload.get("status") != "ok":
+        raise RuntimeError(f"CLI {args[0]} did not succeed: {payload}")
+    return payload
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--language", choices=["python", "powershell"], required=True)
+    parser.add_argument("--artifacts-dir", type=Path, help="New directory to retain logs and generated files")
+    args = parser.parse_args()
+    if args.artifacts_dir:
+        directory = args.artifacts_dir.resolve()
+        directory.mkdir(parents=True, exist_ok=False)
+    else:
+        directory = Path(tempfile.mkdtemp(prefix="rae live ")).resolve()
+    print(f"Artifacts: {directory}", file=sys.stderr, flush=True)
+    model = "big-pickle"
+    config_dir = directory / "rae-config"
+    config_dir.mkdir(exist_ok=True)
+    config_path = config_dir / "config.json"
+    if config_path.exists():
+        raise SystemExit("Refusing to overwrite an existing RAE configuration")
+    import reverse_api.utils as utils
+
+    utils.get_app_dir = lambda: config_dir
+    with socket.socket() as probe:
+        probe.bind(("127.0.0.1", 0))
+        base_url = f"http://127.0.0.1:{probe.getsockname()[1]}"
+    os.environ.update({
+        "RAE_E2E_CONFIG_DIR": str(config_dir),
+        "OPENCODE_BASE_URL": base_url,
+        "XDG_CONFIG_HOME": str(directory / "xdg-config"),
+        "XDG_DATA_HOME": str(directory / "xdg-data"),
+        "XDG_CACHE_HOME": str(directory / "xdg-cache"),
+        "XDG_STATE_HOME": str(directory / "xdg-state"),
+        "OPENCODE_CONFIG_DIR": str(directory / "opencode-config"),
+        "OPENCODE_CONFIG_CONTENT": json.dumps({
+            "enabled_providers": ["opencode"], "model": "opencode/big-pickle",
+            "small_model": "opencode/big-pickle", "share": "disabled",
+            "provider": {"opencode": {"whitelist": ["big-pickle"]}},
+        }),
+        "PLAYWRIGHT_MCP_ISOLATED": "true",
+        "PLAYWRIGHT_MCP_SAVE_HAR_PATH": str(config_dir / "runs" / "har"),
+    })
+    # Do not inherit optional provider credentials or external config overrides.
+    for key in list(os.environ):
+        if key.endswith(("_API_KEY", "_AUTH_TOKEN")) or key in {"OPENCODE_CONFIG", "OPENCODE_SERVER_PASSWORD"}:
+            os.environ.pop(key)
+    config_path.write_text(json.dumps({
+        "sdk": "opencode", "opencode_provider": "opencode", "opencode_model": model,
+        "output_language": args.language, "real_time_sync": False, "agent_provider": "auto",
+    }), encoding="utf-8")
+    os.environ["RAE_OPENCODE_PACKAGE"] = "opencode-ai@1.18.31"
+    os.environ["PYTHONIOENCODING"] = "utf-8"
+    server = FixtureServer()
+    worker = threading.Thread(target=server.serve_forever, daemon=True)
+    worker.start()
+    os.chdir(directory)
+    try:
+        model_info = asyncio.run(require_free_model(base_url, model))
+        (directory / "model.json").write_text(json.dumps(model_info, indent=2), encoding="utf-8")
+        url = f"http://127.0.0.1:{server.server_port}"
+        prompt = (
+            f"Open {url} using the browser MCP and load the products. Capture the API traffic. "
+            f"Generate a {args.language} client for the products API. Its example/main must "
+            "fetch the current response and print exactly that response as JSON, without labels. "
+            "Do not hardcode response data. Preserve all response fields. Test the client. "
+            "Use only this local website, no authentication and no external API."
+        )
+        generated = invoke_cli(["agent", "--headless", "--json", "--url", url, "--prompt", prompt], directory, 600)
+        script = Path(generated["script_path"])
+        expected_name = "api_client.psm1" if args.language == "powershell" else "api_client.py"
+        if script.name != expected_name or not script.is_file() or not script.stat().st_size:
+            raise RuntimeError("Generation did not create the requested client")
+        har = Path(generated.get("har_path") or "missing.har")
+        if not har.is_file():
+            raise RuntimeError("Browser capture did not produce a HAR")
+        entries = json.loads(har.read_text(encoding="utf-8"))["log"]["entries"]
+        if not any(e["request"]["url"] == f"{url}/api/products" for e in entries):
+            raise RuntimeError("HAR does not contain the fixture API request")
+        if not server.page_hits or not server.api_hits:
+            raise RuntimeError("The fixture website and API were not visited")
+        # A new value unavailable during generation rejects cached/hardcoded examples.
+        server.nonce = secrets.token_hex(16)
+        previous_hits = server.api_hits
+        replayed = invoke_cli([
+            "run", generated["run_id"], "--file", expected_name, "--json", "--auto-install",
+        ], directory, 120)
+        if replayed.get("returncode") != 0 or json.loads(replayed["stdout"]) != server.payload():
+            raise RuntimeError("Generated client did not return fresh, correct API data")
+        if server.api_hits <= previous_hits:
+            raise RuntimeError("Client replay never contacted the API")
+        report = {"status": "ok", "language": args.language, "model": f"opencode/{model}",
+                  "package_version": version("reverse-api-engineer"), "script_path": str(script),
+                  "har_entries": len(entries), "page_hits": server.page_hits, "api_hits": server.api_hits,
+                  "fresh_response_verified": True}
+        (directory / "result.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
+        print(json.dumps(report))
+    finally:
+        server.shutdown()
+        server.server_close()
+        worker.join(timeout=5)
+        stop_managed_opencode_server()
+
+
+if __name__ == "__main__":
+    main()

From 748ffb2b02d514df196ed05ac4f369840a7c4378 Mon Sep 17 00:00:00 2001
From: kalil0321 <92564081+kalil0321@users.noreply.github.com>
Date: Sun, 20 Sep 2026 20:19:26 +0200
Subject: [PATCH 09/13] Add opt-in Windows generation using preinstalled Chrome

Add live Windows generation option to CI workflow
---
 .github/workflows/ci.yml | 58 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 58 insertions(+)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 310fef2b..8318a3ef 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,11 @@ on:
     branches: [main]
   pull_request:
   workflow_dispatch:
+    inputs:
+      live_windows:
+        description: 'Run only live Windows generation using preinstalled Chrome and a free model'
+        type: boolean
+        default: false
 
 permissions:
   contents: read
@@ -17,6 +22,7 @@ concurrency:
 # Pin action revisions so upstream tag changes cannot alter CI code.
 jobs:
   lint:
+    if: github.event_name != 'workflow_dispatch' || !inputs.live_windows
     name: ruff
     runs-on: ubuntu-latest
     steps:
@@ -33,6 +39,7 @@ jobs:
   # in the logs, but it never blocks a merge. Remove `continue-on-error` once
   # `uv run mypy src` is clean.
   typecheck:
+    if: github.event_name != 'workflow_dispatch' || !inputs.live_windows
     name: mypy (informational)
     runs-on: ubuntu-latest
     continue-on-error: true
@@ -45,6 +52,7 @@ jobs:
       - run: uv run mypy src
 
   test:
+    if: github.event_name != 'workflow_dispatch' || !inputs.live_windows
     name: pytest (py${{ matrix.python-version }}, ${{ matrix.os }})
     timeout-minutes: 15
     runs-on: ${{ matrix.os }}
@@ -73,3 +81,53 @@ jobs:
       - run: uv run reverse-api-engineer --version
       - run: uv run reverse-api-engineer --help
       - run: uv run pytest -q
+
+  live-windows:
+    name: Live Windows generation (Chrome already installed)
+    if: github.event_name == 'workflow_dispatch' && inputs.live_windows
+    runs-on: windows-latest
+    timeout-minutes: 12
+    env:
+      UV_PYTHON: '3.13'
+      PYTHONIOENCODING: utf-8
+      PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
+      PLAYWRIGHT_MCP_BROWSER: chrome
+      RAE_OPENCODE_PACKAGE: opencode-ai@1.18.31
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+        with:
+          persist-credentials: false
+      - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
+        with:
+          python-version: '3.13'
+          enable-cache: true
+      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+        with:
+          node-version: '22'
+      - name: Locate npm cache
+        id: npm-cache
+        shell: pwsh
+        run: '"path=$(npm config get cache)" >> $env:GITHUB_OUTPUT'
+      - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+        with:
+          path: ${{ steps.npm-cache.outputs.path }}
+          key: windows-node22-opencode1.18.31-rae-mcp0.0.8-v1
+      - name: Verify preinstalled Chrome and PowerShell
+        shell: pwsh
+        run: |
+          $chrome = Join-Path $env:ProgramFiles 'Google/Chrome/Application/chrome.exe'
+          if (!(Test-Path $chrome)) { throw 'Preinstalled Chrome missing; refusing browser download' }
+          if ($PSVersionTable.PSVersion.Major -lt 7) { throw 'PowerShell 7+ required' }
+          "PLAYWRIGHT_MCP_EXECUTABLE_PATH=$chrome" >> $env:GITHUB_ENV
+          (Get-Item $chrome).VersionInfo.ProductVersion
+      - run: uv sync --locked --no-dev
+      - name: Warm OpenCode package cache
+        run: npx --yes opencode-ai@1.18.31 --version
+      - name: Generate and execute PowerShell and Python clients
+        shell: pwsh
+        run: |
+          foreach ($language in @('powershell', 'python')) {
+            uv run --no-sync python scripts/validation/generation_e2e.py --language $language --artifacts-dir "$env:RUNNER_TEMP/rae-$language"
+            if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+            Get-Content "$env:RUNNER_TEMP/rae-$language/result.json" >> $env:GITHUB_STEP_SUMMARY
+          }

From 6d15f798f58c73b53420d25a37407847eff9ce88 Mon Sep 17 00:00:00 2001
From: kalil0321 
Date: Sun, 20 Sep 2026 20:20:01 +0200
Subject: [PATCH 10/13] Document manual Windows generation checks

---
 scripts/validation/README.md | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/scripts/validation/README.md b/scripts/validation/README.md
index 54229c5f..3c8fc020 100644
--- a/scripts/validation/README.md
+++ b/scripts/validation/README.md
@@ -29,5 +29,12 @@ choose a location; an existing directory is rejected. The local fixture server
 stops when the test ends, so retained clients need a new matching server before
 they can be replayed again.
 
-This check does not run in GitHub Actions. Regular CI keeps the deterministic
-unit, CLI startup and real PowerShell execution checks across all three OSes.
+To run on Windows in GitHub Actions, open **CI → Run workflow** and enable
+**live_windows**. This starts one Windows job, running PowerShell then Python,
+with preinstalled Chrome. It does not download browsers or install Windows media
+components. Python and npm dependencies are cached; the first run can be slower.
+The result for each language is included in the run summary. This uses the same
+free-model checks and fresh-response validation as the local command.
+
+Ordinary pushes and PRs only run the deterministic unit, CLI startup and real
+PowerShell execution checks across all three OSes. The live test is manual only.

From 57b1bd6b925cdd8946ae0e41f1fa7fe2297dbbab Mon Sep 17 00:00:00 2001
From: kalil0321 
Date: Sun, 20 Sep 2026 20:29:09 +0200
Subject: [PATCH 11/13] docs: record PowerShell support and Windows validation
 in changelog

---
 CHANGELOG.md | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63bfd847..8093d202 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+- **PowerShell 7+ output**: generate an `api_client.psm1` module and `Example.ps1`, with CLI execution through `pwsh` on Windows, macOS, and Linux.
+- **Live client validation**: an opt-in local test captures a fixture API, generates a Python or PowerShell client with a verified free OpenCode model, and checks fresh API data on replay. The manual `live_windows` CI option validates both languages using preinstalled Chrome and cached Python/npm dependencies, without downloading Chromium. Regular CI requires real PowerShell execution and CLI startup checks across all three operating systems.
+
 ### Fixed
+- **PowerShell execution validation**: reject unsupported module names, missing `Example.ps1`, and script arguments that the example cannot forward, rather than silently running a different command. Generation instructions require portable module imports and terminating errors so failed API calls produce a nonzero exit status.
 - Refresh the locked Claude Agent SDK and MCP dependencies so the development environment also uses MCP 2.x and `httpx2`, without the legacy `httpx`, `httpx-sse`, and `httpcore` packages. Add subprocess smoke tests for CLI startup with legacy HTTP client imports blocked.
 
 ## [0.13.1] - 2026-08-30

From 377e5f276c2ed00e77fd8ec3972b7c8746b58d22 Mon Sep 17 00:00:00 2001
From: kalil0321 
Date: Sun, 20 Sep 2026 21:14:22 +0200
Subject: [PATCH 12/13] test: retain generated clients and raw E2E output in
 reports

---
 scripts/validation/generation_e2e.py | 16 +++++++++++++++-
 1 file changed, 15 insertions(+), 1 deletion(-)

diff --git a/scripts/validation/generation_e2e.py b/scripts/validation/generation_e2e.py
index 5e4894b4..78b39599 100644
--- a/scripts/validation/generation_e2e.py
+++ b/scripts/validation/generation_e2e.py
@@ -7,6 +7,7 @@
 
 import argparse
 import asyncio
+import hashlib
 import json
 import os
 import secrets
@@ -180,6 +181,7 @@ def main():
         if not server.page_hits or not server.api_hits:
             raise RuntimeError("The fixture website and API were not visited")
         # A new value unavailable during generation rejects cached/hardcoded examples.
+        captured_nonce = server.nonce
         server.nonce = secrets.token_hex(16)
         previous_hits = server.api_hits
         replayed = invoke_cli([
@@ -189,10 +191,22 @@ def main():
             raise RuntimeError("Generated client did not return fresh, correct API data")
         if server.api_hits <= previous_hits:
             raise RuntimeError("Client replay never contacted the API")
+        generated_files = [script]
+        if args.language == "powershell":
+            generated_files.append(script.parent / "Example.ps1")
+        client_evidence = {
+            path.name: {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
+                        "source": path.read_text(encoding="utf-8-sig")}
+            for path in generated_files
+        }
         report = {"status": "ok", "language": args.language, "model": f"opencode/{model}",
                   "package_version": version("reverse-api-engineer"), "script_path": str(script),
                   "har_entries": len(entries), "page_hits": server.page_hits, "api_hits": server.api_hits,
-                  "fresh_response_verified": True}
+                  "fresh_response_verified": True,
+                  "generation_status": generated["status"], "client_returncode": replayed["returncode"],
+                  "client_stdout": replayed["stdout"], "expected_response": server.payload(),
+                  "captured_nonce": captured_nonce, "replay_api_hits": server.api_hits - previous_hits,
+                  "generated_files": client_evidence}
         (directory / "result.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
         print(json.dumps(report))
     finally:

From 6c6a0f569938a09534f59e830c0043df1da11565 Mon Sep 17 00:00:00 2001
From: kalil0321 
Date: Sun, 20 Sep 2026 21:32:41 +0200
Subject: [PATCH 13/13] test: preserve E2E evidence across Windows text
 encodings

---
 scripts/validation/generation_e2e.py | 21 ++++++++++++++++-----
 1 file changed, 16 insertions(+), 5 deletions(-)

diff --git a/scripts/validation/generation_e2e.py b/scripts/validation/generation_e2e.py
index 78b39599..f34f255f 100644
--- a/scripts/validation/generation_e2e.py
+++ b/scripts/validation/generation_e2e.py
@@ -7,6 +7,7 @@
 
 import argparse
 import asyncio
+import codecs
 import hashlib
 import json
 import os
@@ -194,11 +195,21 @@ def main():
         generated_files = [script]
         if args.language == "powershell":
             generated_files.append(script.parent / "Example.ps1")
-        client_evidence = {
-            path.name: {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
-                        "source": path.read_text(encoding="utf-8-sig")}
-            for path in generated_files
-        }
+        client_evidence = {}
+        for path in generated_files:
+            raw = path.read_bytes()
+            if raw.startswith((codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)):
+                encoding = "utf-32"
+            elif raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
+                encoding = "utf-16"
+            else:
+                encoding = "utf-8-sig"
+            source = raw.decode(encoding, errors="replace")
+            client_evidence[path.name] = {
+                "sha256": hashlib.sha256(source.encode("utf-8")).hexdigest(),
+                "file_sha256": hashlib.sha256(raw).hexdigest(),
+                "source": source,
+            }
         report = {"status": "ok", "language": args.language, "model": f"opencode/{model}",
                   "package_version": version("reverse-api-engineer"), "script_path": str(script),
                   "har_entries": len(entries), "page_hits": server.page_hits, "api_hits": server.api_hits,