diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 87c8be2f..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,11 +52,15 @@ 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 }}
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 +74,60 @@ 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
+
+ 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
+ }
diff --git a/.gitignore b/.gitignore
index 9772cd93..e41171fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,6 +81,7 @@ cython_debug/
har/
scripts/*
!scripts/clean_build.sh
+!scripts/validation/
scripts_built/
src/reverse_api/scripts/
.reverse-api/
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
diff --git a/README.md b/README.md
index f414caf9..d8c614df 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,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.
@@ -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`, 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). 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/scripts/validation/README.md b/scripts/validation/README.md
new file mode 100644
index 00000000..3c8fc020
--- /dev/null
+++ b/scripts/validation/README.md
@@ -0,0 +1,40 @@
+# 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.
+
+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.
diff --git a/scripts/validation/generation_e2e.py b/scripts/validation/generation_e2e.py
new file mode 100644
index 00000000..f34f255f
--- /dev/null
+++ b/scripts/validation/generation_e2e.py
@@ -0,0 +1,231 @@
+"""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 codecs
+import hashlib
+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.
+ captured_nonce = server.nonce
+ 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")
+ generated_files = [script]
+ if args.language == "powershell":
+ generated_files.append(script.parent / "Example.ps1")
+ 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,
+ "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:
+ server.shutdown()
+ server.server_close()
+ worker.join(timeout=5)
+ stop_managed_opencode_server()
+
+
+if __name__ == "__main__":
+ main()
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 cae77fc5..195cfb38 100644
--- a/src/reverse_api/cli.py
+++ b/src/reverse_api/cli.py
@@ -1272,6 +1272,7 @@ def _handle_settings_action(mode_color=THEME_PRIMARY) -> bool: # noqa: C901 —
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..96f07775
--- /dev/null
+++ b/src/reverse_api/prompts/partials/_language_powershell.md
@@ -0,0 +1,39 @@
+**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, 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
+- 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`
+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
+$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 43931095..b53eb644 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,19 @@ 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":
+ # 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"
+ 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 99128e72..4e134c48 100644
--- a/tests/test_base_engineer.py
+++ b/tests/test_base_engineer.py
@@ -233,6 +233,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."""
@@ -244,6 +248,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")
@@ -445,6 +454,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 = 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):
+ """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 = 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")
+
+ 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 = _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")
+
def test_get_run_command_unknown(self, tmp_path):
"""Unknown language defaults to Python command."""
eng = self._make_engineer(tmp_path, output_language="rust")
@@ -460,7 +501,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
@@ -570,6 +611,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 791d5097..6670180c 100644
--- a/tests/test_engineer.py
+++ b/tests/test_engineer.py
@@ -971,7 +971,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_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 1b38f293..5b4ed626 100644
--- a/tests/test_run_command.py
+++ b/tests/test_run_command.py
@@ -252,12 +252,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("")
@@ -814,6 +814,15 @@ 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"
+ (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]]
+ 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