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