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