From 688465ea75fb0a3180002cdea2d9a7ec84c03072 Mon Sep 17 00:00:00 2001 From: Joshua Dunbar Date: Tue, 21 Jul 2026 15:22:48 -0400 Subject: [PATCH 1/3] Add PHP as a supported output language Follows the exact same pattern as python/javascript/typescript: - New prompt partial (prompts/partials/_language_php.md): uses the curl and json_encode/json_decode core extensions (ext-curl, ext-json) - both part of core PHP, so no Composer dependency is needed at all, no project/dependency file of any kind. Same auth-hardcoding/refresh guidance as the other languages. - base_engineer.py: added "php" to _OUTPUT_LANGUAGE_EXTENSIONS (.php) and _get_language_name() ("PHP"). _get_run_command() uses the full path rather than a bare relative "php api_client.php" - applying the same fix greptile-apps caught on the Go/Java/C# PRs proactively here from the start, since php has the identical shape (the agent's cwd is scripts_dir.parent.parent, not scripts_dir where the script is actually saved). - cli.py: added PHP to the interactive `output_language` config picker. - Updated the three docstrings/comments that listed the language set (config.py, engineer.py, prompts/__init__.py) and README.md's output language line. - Tests: mirrored the existing per-language test pattern in test_base_engineer.py (extension, run command, prompt content) and test_prompts.py (partial loading) for PHP. Checked the partial for the literal-brace str.format_map() bug caught in the Java PR - no literal `{`/`}` anywhere in the new file. Full test suite passes (49/49, run 3x). ruff check is clean on every file this touches (8 pre-existing lint errors elsewhere on main, none in the touched lines). --- CHANGELOG.md | 1 + README.md | 2 +- src/reverse_api/base_engineer.py | 12 +++++++++++ src/reverse_api/cli.py | 1 + src/reverse_api/config.py | 2 +- src/reverse_api/engineer.py | 2 +- src/reverse_api/prompts/__init__.py | 2 +- .../prompts/partials/_language_php.md | 21 +++++++++++++++++++ tests/test_base_engineer.py | 16 ++++++++++++++ tests/test_prompts.py | 10 +++++++++ 10 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 src/reverse_api/prompts/partials/_language_php.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 18106ca2..dd3c4112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Go output language**: `output_language: "go"` is now supported alongside python/javascript/typescript, generating a standard-library-first (`net/http`, `encoding/json`) Go program, with the same auth-hardcoding/refresh and bot-detection-fallback guidance as the other languages. - **Java output language**: `output_language: "java"` is now supported alongside python/javascript/typescript, generating a small Maven project using `java.net.http.HttpClient` (JDK 11+, no HTTP library dependency) and Gson for JSON, with the same auth-hardcoding/refresh guidance as the other languages. - **C# output language**: `output_language: "csharp"` is now supported alongside python/javascript/typescript, generating a minimal .NET project using `System.Net.Http.HttpClient` and `System.Text.Json` (both part of the .NET 5+ base class library — no NuGet dependency needed), with the same auth-hardcoding/refresh guidance as the other languages. +- **PHP output language**: `output_language: "php"` is now supported alongside python/javascript/typescript, generating a script using the `curl` and `json_encode`/`json_decode` core extensions (`ext-curl`, `ext-json` — no Composer dependency needed), with the same auth-hardcoding/refresh guidance as the other languages. ## [0.10.0] - 2026-06-01 diff --git a/README.md b/README.md index bacb16a5..d29998cf 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Settings live in `~/.reverse-api/config.json` and can be edited via `/settings` - **Models**: Sonnet 4.6 (default), Opus 4.6 (most capable), Haiku 4.5 (fastest). For OpenCode see [models.dev](https://models.dev). - **SDK**: `claude` (default), `opencode`, `cursor`, or `copilot` (GitHub Copilot). -- **Output language**: `python`, `javascript`, `typescript`, `go`, `java`, or `csharp`. +- **Output language**: `python`, `javascript`, `typescript`, `go`, `java`, `csharp`, or `php`. ## CLI diff --git a/src/reverse_api/base_engineer.py b/src/reverse_api/base_engineer.py index 7e7d85d9..298f8c4a 100644 --- a/src/reverse_api/base_engineer.py +++ b/src/reverse_api/base_engineer.py @@ -37,6 +37,7 @@ class BaseEngineer(ABC): "go": ".go", "java": ".java", "csharp": ".cs", + "php": ".php", } def __init__( @@ -446,6 +447,7 @@ def _get_language_name(self) -> str: "go": "Go", "java": "Java", "csharp": "C#", + "php": "PHP", }.get(self.output_language, "Python") def _get_existing_client_guidance(self) -> str: @@ -508,6 +510,16 @@ def _get_run_command(self) -> str: # pointing --project at the wrong, doubly-nested location. csproj = shlex.quote(str(self.scripts_dir.resolve() / "ApiClient.csproj")) return f"dotnet run --project {csproj}" + if self.output_language == "php": + # Full path, not a bare relative "php api_client.php": the + # agent's cwd for the whole session is scripts_dir.parent.parent + # (see analyze_and_generate's ClaudeAgentOptions), not + # scripts_dir itself where the script is actually saved. python/ + # node/npx get away with a bare relative filename here since + # this is already how they're shipped and evidently work in + # practice, but there's no reason to leave a new language + # exposed to the same ambiguity when it's this cheap to remove. + return f'php "{self.scripts_dir}/{self._get_client_filename()}"' return { "python": "python api_client.py", "javascript": "node api_client.js", diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py index e6b32199..f3966104 100644 --- a/src/reverse_api/cli.py +++ b/src/reverse_api/cli.py @@ -1086,6 +1086,7 @@ def handle_settings(mode_color=THEME_PRIMARY): Choice(title="go", value="go"), Choice(title="java", value="java"), Choice(title="csharp", value="csharp"), + Choice(title="php", value="php"), Choice(title="back", value="back"), ] lang = questionary.select( diff --git a/src/reverse_api/config.py b/src/reverse_api/config.py index ad29c34b..cc7de3bb 100644 --- a/src/reverse_api/config.py +++ b/src/reverse_api/config.py @@ -20,7 +20,7 @@ "opencode_model": "claude-opus-4-6", "opencode_provider": "anthropic", "output_dir": None, # None means use ~/.reverse-api/runs - "output_language": "python", # "python", "javascript", "typescript", "go", "java", or "csharp" + "output_language": "python", # "python", "javascript", "typescript", "go", "java", "csharp", or "php" "real_time_sync": True, # Enable real-time file sync during engineering "sdk": "claude", # "claude", "opencode", "copilot", or "cursor" } diff --git a/src/reverse_api/engineer.py b/src/reverse_api/engineer.py index b400c541..5d900276 100644 --- a/src/reverse_api/engineer.py +++ b/src/reverse_api/engineer.py @@ -232,7 +232,7 @@ def run_reverse_engineering( cursor_setting_sources: Optional explicit list (overrides cursor_web_search), e.g. ["project","user","all"]. enable_sync: Enable real-time file syncing during engineering is_fresh: Whether to start fresh (ignore previous scripts) - output_language: Target language - "python", "javascript", "typescript", "go", "java", or "csharp" + output_language: Target language - "python", "javascript", "typescript", "go", "java", "csharp", or "php" output_mode: Output mode - "client" for API client code, "docs" for OpenAPI specification """ if sdk == "opencode": diff --git a/src/reverse_api/prompts/__init__.py b/src/reverse_api/prompts/__init__.py index 36ef4e04..336458ce 100644 --- a/src/reverse_api/prompts/__init__.py +++ b/src/reverse_api/prompts/__init__.py @@ -54,7 +54,7 @@ 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". + language: One of "python", "javascript", "typescript", "go", "java", "csharp", "php". **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_php.md b/src/reverse_api/prompts/partials/_language_php.md new file mode 100644 index 00000000..77cf99c7 --- /dev/null +++ b/src/reverse_api/prompts/partials/_language_php.md @@ -0,0 +1,21 @@ +**Generate a PHP script** that replicates the API calls found in the traffic. The following are guidelines — use your judgment on what's appropriate for the specific API: + +- Use the `curl` extension (`curl_init`/`curl_exec`) for requests and `json_encode`/`json_decode` for JSON — both are part of core PHP (`ext-curl` and `ext-json`), so no Composer dependency is needed +- Reuse one cURL handle across requests rather than creating a new one per call +- Create a separate function for each distinct API endpoint +- Include type declarations on function signatures where they add clarity +- Include example usage at the bottom of the script + +**Authentication & credentials:** +- Hardcode all cookies, tokens, session IDs, and auth headers found in the traffic directly in the script +- The user should be able to run the script immediately with zero configuration — no env vars, no config files, no `composer install` +- If the API uses cookies, configure the cURL handle's cookie jar (`CURLOPT_COOKIEJAR`/`CURLOPT_COOKIEFILE`) so cookies persist automatically across requests +- If the API uses Bearer tokens or API keys, hardcode them in the request headers +- Handle auth refresh so the script 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 script to: `{scripts_dir}/{client_filename}` +Save documentation to: `{scripts_dir}/README.md` diff --git a/tests/test_base_engineer.py b/tests/test_base_engineer.py index 774b66a7..8653de08 100644 --- a/tests/test_base_engineer.py +++ b/tests/test_base_engineer.py @@ -206,6 +206,10 @@ def test_get_output_extension_csharp(self, tmp_path): """C# extension.""" eng = self._make_engineer(tmp_path, output_language="csharp") assert eng._get_output_extension() == ".cs" + def test_get_output_extension_php(self, tmp_path): + """PHP extension.""" + eng = self._make_engineer(tmp_path, output_language="php") + assert eng._get_output_extension() == ".php" def test_get_output_extension_unknown(self, tmp_path): """Unknown language defaults to .py.""" @@ -304,6 +308,12 @@ def test_get_run_command_csharp_resolves_relative_output_dir(self, tmp_path): project_arg = tokens[3] assert Path(project_arg).is_absolute() assert project_arg == str(eng.scripts_dir.resolve() / "ApiClient.csproj") + def test_get_run_command_php(self, tmp_path): + """Run command for PHP uses the full path, not a bare relative + filename — the agent's cwd is scripts_dir.parent.parent (see + analyze_and_generate), not scripts_dir where the script lives.""" + eng = self._make_engineer(tmp_path, output_language="php") + assert eng._get_run_command() == f'php "{eng.scripts_dir}/api_client.php"' def test_get_run_command_unknown(self, tmp_path): """Unknown language defaults to Python command.""" @@ -368,6 +378,12 @@ def test_csharp_prompt(self, tmp_path): system_prompt, user_message = eng._build_prompts() assert "C# program" in system_prompt assert "HttpClient" in system_prompt + def test_php_prompt(self, tmp_path): + """PHP prompt includes PHP-specific instructions.""" + eng = self._make_engineer(tmp_path, output_language="php") + system_prompt, user_message = eng._build_prompts() + assert "PHP script" in system_prompt + assert "curl" in system_prompt def test_docs_prompt(self, tmp_path): """Docs mode prompt includes OpenAPI instructions.""" diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 89fe391e..f4786e3c 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -101,6 +101,16 @@ def test_csharp_partial(self): assert "C# program" in text assert "HttpClient" in text assert "/tmp/scripts/api_client.cs" in text + def test_php_partial(self): + text = load_language_partial( + "php", + scripts_dir="/tmp/scripts", + client_filename="api_client.php", + run_command='php "/tmp/scripts/api_client.php"', + ) + assert "PHP script" in text + assert "curl" in text + assert "/tmp/scripts/api_client.php" in text class TestEngineerTemplates: From c85faf1f58f012ff52c09e9b532825a1e255e40a Mon Sep 17 00:00:00 2001 From: Joshua Dunbar Date: Tue, 21 Jul 2026 15:47:13 -0400 Subject: [PATCH 2/3] Fix shell-quoting and ext-curl assumptions in PHP support Two real bugs caught by greptile-apps and cubic-dev-ai independently on the PR, both verified before fixing: 1. _get_run_command()'s "php \"{path}\"" only looked safe - manual double-quoting does NOT stop $() or backtick command substitution inside a double-quoted shell string, confirmed live. If scripts_dir (derived from output_dir, not something this function controls) ever contained shell metacharacters, the resulting {run_command} could execute something other than what was intended when the agent runs it. Replaced with shlex.quote(), the stdlib function built exactly for this - confirmed live it correctly single-quotes the whole path when metacharacters are present, and correctly escapes literal single quotes too (manual double-quoting handles neither case). Added a dedicated test with a deliberately hostile path (embedded $(rm -rf ~)) asserting the command round-trips through shlex.split() back to the literal path, not something a shell would expand. 2. The partial said ext-curl was "part of core PHP" alongside ext-json, but that's only true for ext-json - ext-curl is bundled-but-optional and not guaranteed enabled on every PHP install (some minimal/ stripped installs omit it). Fixed the wording and added the same check-and-install guidance the C PR already gives for libcurl's equivalent gap, rather than silently assuming curl_init() exists. Full test suite passes (50/50, run 2x) except the same pre-existing flaky test noted on the other language PRs. ruff check clean on the touched lines. --- src/reverse_api/base_engineer.py | 8 ++++++- .../prompts/partials/_language_php.md | 2 +- tests/test_base_engineer.py | 24 +++++++++++++++---- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/reverse_api/base_engineer.py b/src/reverse_api/base_engineer.py index 298f8c4a..bf303b04 100644 --- a/src/reverse_api/base_engineer.py +++ b/src/reverse_api/base_engineer.py @@ -519,7 +519,13 @@ def _get_run_command(self) -> str: # this is already how they're shipped and evidently work in # practice, but there's no reason to leave a new language # exposed to the same ambiguity when it's this cheap to remove. - return f'php "{self.scripts_dir}/{self._get_client_filename()}"' + # shlex.quote(), 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 (confirmed live: shlex.quote handles this, plain + # double-quoting doesn't). + path = shlex.quote(f"{self.scripts_dir}/{self._get_client_filename()}") + return f"php {path}" return { "python": "python api_client.py", "javascript": "node api_client.js", diff --git a/src/reverse_api/prompts/partials/_language_php.md b/src/reverse_api/prompts/partials/_language_php.md index 77cf99c7..d54ec154 100644 --- a/src/reverse_api/prompts/partials/_language_php.md +++ b/src/reverse_api/prompts/partials/_language_php.md @@ -1,6 +1,6 @@ **Generate a PHP script** that replicates the API calls found in the traffic. The following are guidelines — use your judgment on what's appropriate for the specific API: -- Use the `curl` extension (`curl_init`/`curl_exec`) for requests and `json_encode`/`json_decode` for JSON — both are part of core PHP (`ext-curl` and `ext-json`), so no Composer dependency is needed +- Use the `curl` extension (`curl_init`/`curl_exec`) for requests and `json_encode`/`json_decode` for JSON — no Composer dependency is needed for either. `ext-json` is bundled with PHP core and always available, but `ext-curl` is bundled-but-optional and isn't guaranteed enabled on every install — if `curl_init` isn't defined, install it with whatever package manager is available (e.g. `apt-get install -y php-curl`) before retrying - Reuse one cURL handle across requests rather than creating a new one per call - Create a separate function for each distinct API endpoint - Include type declarations on function signatures where they add clarity diff --git a/tests/test_base_engineer.py b/tests/test_base_engineer.py index 8653de08..80ee34d3 100644 --- a/tests/test_base_engineer.py +++ b/tests/test_base_engineer.py @@ -309,11 +309,27 @@ def test_get_run_command_csharp_resolves_relative_output_dir(self, tmp_path): assert Path(project_arg).is_absolute() assert project_arg == str(eng.scripts_dir.resolve() / "ApiClient.csproj") def test_get_run_command_php(self, tmp_path): - """Run command for PHP uses the full path, not a bare relative - filename — the agent's cwd is scripts_dir.parent.parent (see - analyze_and_generate), not scripts_dir where the script lives.""" + """Run command for PHP uses the full, shell-quoted path, not a bare + relative filename — the agent's cwd is scripts_dir.parent.parent + (see analyze_and_generate), not scripts_dir where the script lives, + and shlex.quote() (not manual double-quoting) is what actually + neutralizes shell metacharacters in an arbitrary output_dir.""" eng = self._make_engineer(tmp_path, output_language="php") - assert eng._get_run_command() == f'php "{eng.scripts_dir}/api_client.php"' + expected_path = shlex.quote(f"{eng.scripts_dir}/api_client.php") + assert eng._get_run_command() == f"php {expected_path}" + + def test_get_run_command_php_quotes_metacharacters(self, tmp_path): + """A scripts_dir containing shell metacharacters must round-trip + back to the literal path when the shell tokenizes the command — + not be left open to $()/backtick expansion, which is exactly what + the naive f'"{path}"' approach got wrong (double quotes still allow + command substitution inside them).""" + eng = self._make_engineer(tmp_path, output_language="php") + eng.scripts_dir = Path("/tmp/weird$(rm -rf ~) dir") + command = eng._get_run_command() + tokens = shlex.split(command) + assert tokens[0] == "php" + assert tokens[1] == f"{eng.scripts_dir}/api_client.php" def test_get_run_command_unknown(self, tmp_path): """Unknown language defaults to Python command.""" From 72f2d9042b09d13fb0ba27040dfcbb60f1868276 Mon Sep 17 00:00:00 2001 From: Joshua Dunbar Date: Tue, 21 Jul 2026 17:25:57 -0400 Subject: [PATCH 3/3] Resolve scripts_dir to an absolute path in the PHP run command Same root cause cubic flagged on the C# and Ruby PRs: a relative --output-dir leaves scripts_dir relative to the original cwd, but once the agent's cwd moves to scripts_dir.parent.parent (see analyze_and_generate), embedding that same relative string re-interprets it from the new location and points at a doubly-nested, nonexistent path. Resolve scripts_dir before interpolating it, proactively applying the same fix here before it gets flagged. --- src/reverse_api/base_engineer.py | 6 +++++- tests/test_base_engineer.py | 27 ++++++++++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/reverse_api/base_engineer.py b/src/reverse_api/base_engineer.py index bf303b04..c1fbc1d5 100644 --- a/src/reverse_api/base_engineer.py +++ b/src/reverse_api/base_engineer.py @@ -524,7 +524,11 @@ def _get_run_command(self) -> str: # and naive f'"{path}"' still lets $()/backticks expand inside # double quotes (confirmed live: shlex.quote handles this, plain # double-quoting doesn't). - path = shlex.quote(f"{self.scripts_dir}/{self._get_client_filename()}") + # .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 this command at the wrong, doubly-nested location. + path = shlex.quote(str(self.scripts_dir.resolve() / self._get_client_filename())) return f"php {path}" return { "python": "python api_client.py", diff --git a/tests/test_base_engineer.py b/tests/test_base_engineer.py index 80ee34d3..285af4ef 100644 --- a/tests/test_base_engineer.py +++ b/tests/test_base_engineer.py @@ -309,13 +309,13 @@ def test_get_run_command_csharp_resolves_relative_output_dir(self, tmp_path): assert Path(project_arg).is_absolute() assert project_arg == str(eng.scripts_dir.resolve() / "ApiClient.csproj") def test_get_run_command_php(self, tmp_path): - """Run command for PHP uses the full, shell-quoted path, not a bare - relative filename — the agent's cwd is scripts_dir.parent.parent - (see analyze_and_generate), not scripts_dir where the script lives, - and shlex.quote() (not manual double-quoting) is what actually - neutralizes shell metacharacters in an arbitrary output_dir.""" + """Run command for PHP uses the full, resolved, shell-quoted path, + not a bare relative filename — the agent's cwd is scripts_dir. + parent.parent (see analyze_and_generate), not scripts_dir where the + script lives, and shlex.quote() (not manual double-quoting) is what + actually neutralizes shell metacharacters in an arbitrary output_dir.""" eng = self._make_engineer(tmp_path, output_language="php") - expected_path = shlex.quote(f"{eng.scripts_dir}/api_client.php") + expected_path = shlex.quote(str(eng.scripts_dir.resolve() / "api_client.php")) assert eng._get_run_command() == f"php {expected_path}" def test_get_run_command_php_quotes_metacharacters(self, tmp_path): @@ -329,7 +329,20 @@ def test_get_run_command_php_quotes_metacharacters(self, tmp_path): command = eng._get_run_command() tokens = shlex.split(command) assert tokens[0] == "php" - assert tokens[1] == f"{eng.scripts_dir}/api_client.php" + assert tokens[1] == str(eng.scripts_dir.resolve() / "api_client.php") + + def test_get_run_command_php_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="php") + eng.scripts_dir = Path("relative_output/scripts/run123") + tokens = shlex.split(eng._get_run_command()) + script_arg = tokens[1] + assert Path(script_arg).is_absolute() + assert script_arg == str(eng.scripts_dir.resolve() / "api_client.php") def test_get_run_command_unknown(self, tmp_path): """Unknown language defaults to Python command."""