From 0924e501a045739dc50ad4f862064c9c745199e7 Mon Sep 17 00:00:00 2001 From: Joshua Dunbar Date: Tue, 21 Jul 2026 15:35:48 -0400 Subject: [PATCH 1/5] Add C as a supported output language Follows the same overall pattern as python/javascript/typescript, but C is genuinely different from every language added so far in two ways, both addressed directly in the new partial (prompts/partials/_language_c.md): 1. No stdlib HTTP or JSON. Unlike Go/PHP/Ruby (all fully dependency-free via their own standard libraries), C has neither. Uses libcurl for HTTP and vendors cJSON (a small, widely-used, permissively-licensed single-file JSON library) for JSON, rather than hand-rolling either - same reasoning as the Java PR's Gson choice: a real, tested library beats ad-hoc parsing written from scratch. libcurl itself is a system library a build step can't fetch, so the partial calls out installing it via the platform's package manager if the compiler can't find curl/curl.h. 2. Compiled, not run directly - the only such language here. Every other language's {run_command} is (or now points at) a single interpreter/build-tool invocation; C needs an explicit compile step first. _get_run_command() chains `cc -lcurl -o && ` as one command, using full paths throughout for the same reason already fixed on the Go/Java/C#/PHP/Ruby PRs - the agent's cwd is scripts_dir.parent.parent, not scripts_dir where everything is actually saved. base_engineer.py: added "c" to _OUTPUT_LANGUAGE_EXTENSIONS (.c) and _get_language_name() ("C"). _get_auto_output_files() always lists the vendored cJSON.c/cJSON.h (not conditional - C always needs them, no stdlib fallback, matching Java's always-included pom.xml rather than javascript/go's conditional dependency files). cli.py: added C to the interactive `output_language` config picker. Updated the three docstrings/comments listing the language set (config.py, engineer.py, prompts/__init__.py) and README.md's output language line. Tests mirror the existing per-language pattern in both test_base_engineer.py and test_prompts.py. Checked for the literal-brace str.format_map() bug caught in the Java PR - no literal `{`/`}` anywhere in the new partial. Full test suite passes (49/49, run 3x) except the same pre-existing flaky test noted on the other language PRs. ruff check 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 | 18 +++++++++++++++ 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_c.md | 23 +++++++++++++++++++ tests/test_base_engineer.py | 21 +++++++++++++++++ tests/test_prompts.py | 10 ++++++++ 10 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 src/reverse_api/prompts/partials/_language_c.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 731f63ba..860d17c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. - **Ruby output language**: `output_language: "ruby"` is now supported alongside python/javascript/typescript, generating a script using `net/http` and `json` (both part of Ruby's standard library — no gem/Bundler dependency needed), with the same auth-hardcoding/refresh guidance as the other languages. +- **C output language**: `output_language: "c"` is now supported alongside python/javascript/typescript, generating a program using `libcurl` for HTTP and a vendored `cJSON` for JSON (C has neither in its standard library), compiled and run as a single `{run_command}` step, 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 8c576fa9..07893f51 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`, `csharp`, `php`, or `ruby`. +- **Output language**: `python`, `javascript`, `typescript`, `go`, `java`, `csharp`, `php`, `ruby`, or `c`. ## CLI diff --git a/src/reverse_api/base_engineer.py b/src/reverse_api/base_engineer.py index 77f05f9b..54368274 100644 --- a/src/reverse_api/base_engineer.py +++ b/src/reverse_api/base_engineer.py @@ -39,6 +39,7 @@ class BaseEngineer(ABC): "csharp": ".cs", "php": ".php", "ruby": ".rb", + "c": ".c", } def __init__( @@ -450,6 +451,7 @@ def _get_language_name(self) -> str: "csharp": "C#", "php": "PHP", "ruby": "Ruby", + "c": "C", }.get(self.output_language, "Python") def _get_existing_client_guidance(self) -> str: @@ -546,6 +548,17 @@ def _get_run_command(self) -> str: # pointing this command at the wrong, doubly-nested location. path = shlex.quote(str(self.scripts_dir.resolve() / self._get_client_filename())) return f"ruby {path}" + if self.output_language == "c": + # Unlike every other language here, C needs an explicit compile + # step before it can run at all — one shell command chains + # both. Full paths throughout, not bare relative filenames: the + # agent's cwd for the whole session is scripts_dir.parent.parent + # (see analyze_and_generate's ClaudeAgentOptions), not + # scripts_dir where the source/output actually live. + source = f"{self.scripts_dir}/{self._get_client_filename()}" + cjson = f"{self.scripts_dir}/cJSON.c" + binary = f"{self.scripts_dir}/api_client" + return f'cc "{source}" "{cjson}" -lcurl -o "{binary}" && "{binary}"' return { "python": "python api_client.py", "javascript": "node api_client.js", @@ -669,6 +682,11 @@ def _get_auto_output_files(self, language_name: str, client_filename: str) -> st return base + f"\n3. `{self.scripts_dir}/pom.xml` - Maven project file (Gson dependency, exec-maven-plugin)" elif self.output_language == "csharp": return base + f"\n3. `{self.scripts_dir}/ApiClient.csproj` - .NET project file" + elif self.output_language == "c": + return base + ( + f"\n3. `{self.scripts_dir}/cJSON.c` and `{self.scripts_dir}/cJSON.h` - " + "Vendored JSON library" + ) return base @abstractmethod diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py index 33876e1c..cb0ad472 100644 --- a/src/reverse_api/cli.py +++ b/src/reverse_api/cli.py @@ -1088,6 +1088,7 @@ def handle_settings(mode_color=THEME_PRIMARY): Choice(title="csharp", value="csharp"), Choice(title="php", value="php"), Choice(title="ruby", value="ruby"), + Choice(title="c", value="c"), Choice(title="back", value="back"), ] lang = questionary.select( diff --git a/src/reverse_api/config.py b/src/reverse_api/config.py index 06fa019a..676b5f32 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", "csharp", "php", or "ruby" + "output_language": "python", # "python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby", or "c" "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 4b408a87..83390ada 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", "csharp", "php", or "ruby" + output_language: Target language - "python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby", or "c" 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 dc317e01..77da219d 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", "php", "ruby". + language: One of "python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby", "c". **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_c.md b/src/reverse_api/prompts/partials/_language_c.md new file mode 100644 index 00000000..224b300b --- /dev/null +++ b/src/reverse_api/prompts/partials/_language_c.md @@ -0,0 +1,23 @@ +**Generate a C program** that replicates the API calls found in the traffic. The following are guidelines — use your judgment on what's appropriate for the specific API: + +- C has no HTTP client or JSON support in its standard library, unlike every other output language here — use `libcurl` for requests and vendor `cJSON` (a small, widely-used, permissively-licensed single-file JSON library — `cJSON.c`/`cJSON.h`) for JSON, rather than hand-rolling either from scratch +- `libcurl` itself is a system library, not something a build step can fetch — if compilation fails because `curl/curl.h` isn't found, install it with whatever package manager is available (e.g. `apt-get install -y libcurl4-openssl-dev`, `brew install 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, with a small struct for its response shape +- Check every `libcurl`/allocation return value; don't ignore errors +- Include a `main` function with example usage + +**Authentication & credentials:** +- Hardcode all cookies, tokens, session IDs, and auth headers found in the traffic directly in the program +- The user should be able to run the program immediately with zero configuration — no env vars, no config files, no manual setup beyond what's generated +- If the API uses cookies, enable `libcurl`'s cookie engine (`CURLOPT_COOKIEFILE`, e.g. pointed at an in-memory/empty string to just turn it on) so cookies persist automatically across requests on the same handle +- If the API uses Bearer tokens or API keys, hardcode them in the request headers +- Handle auth refresh so the program 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:** +- Unlike every other language here, this needs a compile step before it can run — a single `{run_command}` handles both +- You have up to 5 attempts to fix issues + +Save the program to: `{scripts_dir}/{client_filename}` +Save documentation to: `{scripts_dir}/README.md` +Save the vendored JSON library to: `{scripts_dir}/cJSON.c` and `{scripts_dir}/cJSON.h` diff --git a/tests/test_base_engineer.py b/tests/test_base_engineer.py index 2493b2cc..80a0d289 100644 --- a/tests/test_base_engineer.py +++ b/tests/test_base_engineer.py @@ -214,6 +214,10 @@ def test_get_output_extension_ruby(self, tmp_path): """Ruby extension.""" eng = self._make_engineer(tmp_path, output_language="ruby") assert eng._get_output_extension() == ".rb" + 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_unknown(self, tmp_path): """Unknown language defaults to .py.""" @@ -378,6 +382,17 @@ def test_get_run_command_ruby_resolves_relative_output_dir(self, tmp_path): script_arg = tokens[1] assert Path(script_arg).is_absolute() assert script_arg == str(eng.scripts_dir.resolve() / "api_client.rb") + def test_get_run_command_c(self, tmp_path): + """Run command for C compiles and runs as one step, using full + paths throughout — the agent's cwd is scripts_dir.parent.parent + (see analyze_and_generate), not scripts_dir where the source, + vendored cJSON, and compiled binary all actually live.""" + eng = self._make_engineer(tmp_path, output_language="c") + expected = ( + f'cc "{eng.scripts_dir}/api_client.c" "{eng.scripts_dir}/cJSON.c" ' + f'-lcurl -o "{eng.scripts_dir}/api_client" && "{eng.scripts_dir}/api_client"' + ) + assert eng._get_run_command() == expected def test_get_run_command_unknown(self, tmp_path): """Unknown language defaults to Python command.""" @@ -454,6 +469,12 @@ def test_ruby_prompt(self, tmp_path): system_prompt, user_message = eng._build_prompts() assert "Ruby script" in system_prompt assert "net/http" in system_prompt + def test_c_prompt(self, tmp_path): + """C prompt includes C-specific instructions.""" + eng = self._make_engineer(tmp_path, output_language="c") + system_prompt, user_message = eng._build_prompts() + assert "C program" in system_prompt + assert "libcurl" 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 6d2521e2..8f2f7453 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -121,6 +121,16 @@ def test_ruby_partial(self): assert "Ruby script" in text assert "net/http" in text assert "/tmp/scripts/api_client.rb" in text + def test_c_partial(self): + text = load_language_partial( + "c", + scripts_dir="/tmp/scripts", + client_filename="api_client.c", + run_command='cc "/tmp/scripts/api_client.c" "/tmp/scripts/cJSON.c" -lcurl -o "/tmp/scripts/api_client" && "/tmp/scripts/api_client"', + ) + assert "C program" in text + assert "libcurl" in text + assert "/tmp/scripts/api_client.c" in text class TestEngineerTemplates: From 3f687e156213dd0e2a6a106b19fdea23ca2b55de Mon Sep 17 00:00:00 2001 From: Joshua Dunbar Date: Tue, 21 Jul 2026 15:55:48 -0400 Subject: [PATCH 2/5] Fix shell-quoting in the C compile-and-run command Manually double-quoting each path (f'cc "{source}" "{cjson}" ... "{binary}"') does not stop $()/backtick command substitution inside a POSIX shell double-quoted string. Use shlex.quote() on all three paths (source, vendored cJSON, compiled binary) instead, matching the fix already applied to the Go/Java/C#/PHP/Ruby run commands after bot review flagged the same issue on the PHP PR. --- src/reverse_api/base_engineer.py | 11 +++++++---- tests/test_base_engineer.py | 31 +++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/reverse_api/base_engineer.py b/src/reverse_api/base_engineer.py index 54368274..d70eb81e 100644 --- a/src/reverse_api/base_engineer.py +++ b/src/reverse_api/base_engineer.py @@ -555,10 +555,13 @@ def _get_run_command(self) -> str: # agent's cwd for the whole session is scripts_dir.parent.parent # (see analyze_and_generate's ClaudeAgentOptions), not # scripts_dir where the source/output actually live. - source = f"{self.scripts_dir}/{self._get_client_filename()}" - cjson = f"{self.scripts_dir}/cJSON.c" - binary = f"{self.scripts_dir}/api_client" - return f'cc "{source}" "{cjson}" -lcurl -o "{binary}" && "{binary}"' + # shlex.quote (not manual double-quoting) so shell metacharacters + # in any of these three paths can't be interpreted as command + # substitution. + source = shlex.quote(f"{self.scripts_dir}/{self._get_client_filename()}") + cjson = shlex.quote(f"{self.scripts_dir}/cJSON.c") + binary = shlex.quote(f"{self.scripts_dir}/api_client") + return f"cc {source} {cjson} -lcurl -o {binary} && {binary}" return { "python": "python api_client.py", "javascript": "node api_client.js", diff --git a/tests/test_base_engineer.py b/tests/test_base_engineer.py index 80a0d289..bdc50377 100644 --- a/tests/test_base_engineer.py +++ b/tests/test_base_engineer.py @@ -383,17 +383,32 @@ def test_get_run_command_ruby_resolves_relative_output_dir(self, tmp_path): assert Path(script_arg).is_absolute() assert script_arg == str(eng.scripts_dir.resolve() / "api_client.rb") def test_get_run_command_c(self, tmp_path): - """Run command for C compiles and runs as one step, using full - paths throughout — the agent's cwd is scripts_dir.parent.parent - (see analyze_and_generate), not scripts_dir where the source, - vendored cJSON, and compiled binary all actually live.""" + """Run command for C compiles and runs as one step, using full, + shell-quoted paths throughout — the agent's cwd is + scripts_dir.parent.parent (see analyze_and_generate), not + scripts_dir where the source, vendored cJSON, and compiled binary + all actually live.""" eng = self._make_engineer(tmp_path, output_language="c") - expected = ( - f'cc "{eng.scripts_dir}/api_client.c" "{eng.scripts_dir}/cJSON.c" ' - f'-lcurl -o "{eng.scripts_dir}/api_client" && "{eng.scripts_dir}/api_client"' - ) + source = shlex.quote(f"{eng.scripts_dir}/api_client.c") + cjson = shlex.quote(f"{eng.scripts_dir}/cJSON.c") + binary = shlex.quote(f"{eng.scripts_dir}/api_client") + expected = f"cc {source} {cjson} -lcurl -o {binary} && {binary}" assert eng._get_run_command() == expected + def test_get_run_command_c_quotes_metacharacters(self, tmp_path): + """A scripts_dir containing shell metacharacters must round-trip + back to the literal path for all three paths (source, cJSON, + binary), not be left open to $()/backtick expansion — what the + naive f'"{path}"' approach got wrong.""" + eng = self._make_engineer(tmp_path, output_language="c") + eng.scripts_dir = Path("/tmp/weird$(rm -rf ~) dir") + tokens = shlex.split(eng._get_run_command()) + assert tokens[:2] == ["cc", f"{eng.scripts_dir}/api_client.c"] + assert tokens[2] == f"{eng.scripts_dir}/cJSON.c" + assert tokens[3:6] == ["-lcurl", "-o", f"{eng.scripts_dir}/api_client"] + assert tokens[6] == "&&" + assert tokens[7] == f"{eng.scripts_dir}/api_client" + def test_get_run_command_unknown(self, tmp_path): """Unknown language defaults to Python command.""" eng = self._make_engineer(tmp_path, output_language="rust") From 8d5e98d2f5c41fac97df40baf9693e7440e75788 Mon Sep 17 00:00:00 2001 From: Joshua Dunbar Date: Tue, 21 Jul 2026 16:05:43 -0400 Subject: [PATCH 3/5] Pin the vendored cJSON source and stop auto-installing libcurl on the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both review bots independently flagged two real issues in the C partial: - The instruction to "vendor cJSON" gave no deterministic retrieval step, so the agent had to reconstruct the library from memory, risking an incomplete or subtly wrong JSON parser. Now fetches the exact pinned upstream source (cJSON v1.7.18) via curl instead. - Telling the agent to run apt-get/brew itself when libcurl headers are missing lets an auto-authorized session modify the host without a separate confirmation. Now it stops and reports a clear prerequisite error instead, leaving the actual install to the user. Also investigated a third finding from both bots: `reverse-api-engineer run ` only discovers `.py` files (utils.py's discover_scripts), so a C-only run can't be executed via that subcommand. Confirmed this predates this PR — JS/TS output has the same gap already, and the run subcommand's execution path (venv creation, pip install, ModuleNotFoundError- driven dependency retry) is Python-specific throughout, not just a filename filter. Fixing it means designing a per-language execution/dependency- retry story, which is a cross-cutting feature beyond a single-language addition. Not fixed here, same reasoning as the Java/C# PRs. --- src/reverse_api/prompts/partials/_language_c.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/reverse_api/prompts/partials/_language_c.md b/src/reverse_api/prompts/partials/_language_c.md index 224b300b..a903eedd 100644 --- a/src/reverse_api/prompts/partials/_language_c.md +++ b/src/reverse_api/prompts/partials/_language_c.md @@ -1,7 +1,10 @@ **Generate a C program** that replicates the API calls found in the traffic. The following are guidelines — use your judgment on what's appropriate for the specific API: -- C has no HTTP client or JSON support in its standard library, unlike every other output language here — use `libcurl` for requests and vendor `cJSON` (a small, widely-used, permissively-licensed single-file JSON library — `cJSON.c`/`cJSON.h`) for JSON, rather than hand-rolling either from scratch -- `libcurl` itself is a system library, not something a build step can fetch — if compilation fails because `curl/curl.h` isn't found, install it with whatever package manager is available (e.g. `apt-get install -y libcurl4-openssl-dev`, `brew install curl`) before retrying +- C has no HTTP client or JSON support in its standard library, unlike every other output language here — use `libcurl` for requests and vendor `cJSON` (a small, widely-used, permissively-licensed single-file JSON library) for JSON, rather than hand-rolling either from scratch +- Don't hand-write or reconstruct `cJSON.c`/`cJSON.h` from memory — fetch the exact, pinned upstream source instead, so the vendored copy is the real, complete library rather than a possibly incomplete or subtly wrong approximation: + - `curl -fsSL -o {scripts_dir}/cJSON.c https://raw.githubusercontent.com/DaveGamble/cJSON/v1.7.18/cJSON.c` + - `curl -fsSL -o {scripts_dir}/cJSON.h https://raw.githubusercontent.com/DaveGamble/cJSON/v1.7.18/cJSON.h` +- `libcurl` itself is a system library this project can't fetch or vendor — if compilation fails because `curl/curl.h` isn't found, stop and report a clear error naming the missing prerequisite (e.g. "libcurl development headers not found — install libcurl4-openssl-dev (Debian/Ubuntu) or curl (Homebrew) and retry") rather than running a package manager yourself; installing system packages is a host change the user should make and confirm, not something to do silently inside an auto-authorized session - Reuse one `CURL` handle across requests rather than creating a new one per call - Create a separate function for each distinct API endpoint, with a small struct for its response shape - Check every `libcurl`/allocation return value; don't ignore errors From 7dc804a081c18960cba45ac1aed01f739c0fe59a Mon Sep 17 00:00:00 2001 From: Joshua Dunbar Date: Tue, 21 Jul 2026 17:26:47 -0400 Subject: [PATCH 4/5] Resolve scripts_dir to an absolute path in the C compile-and-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 all three paths (source, vendored cJSON, compiled binary) at the wrong, doubly-nested location. Resolve scripts_dir once before deriving all three paths, proactively applying the same fix here before it gets flagged. --- src/reverse_api/base_engineer.py | 12 +++++++---- tests/test_base_engineer.py | 34 ++++++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/reverse_api/base_engineer.py b/src/reverse_api/base_engineer.py index d70eb81e..e6258cdd 100644 --- a/src/reverse_api/base_engineer.py +++ b/src/reverse_api/base_engineer.py @@ -557,10 +557,14 @@ def _get_run_command(self) -> str: # scripts_dir where the source/output actually live. # shlex.quote (not manual double-quoting) so shell metacharacters # in any of these three paths can't be interpreted as command - # substitution. - source = shlex.quote(f"{self.scripts_dir}/{self._get_client_filename()}") - cjson = shlex.quote(f"{self.scripts_dir}/cJSON.c") - binary = shlex.quote(f"{self.scripts_dir}/api_client") + # substitution. .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 all three at the wrong, doubly-nested location. + resolved = self.scripts_dir.resolve() + source = shlex.quote(str(resolved / self._get_client_filename())) + cjson = shlex.quote(str(resolved / "cJSON.c")) + binary = shlex.quote(str(resolved / "api_client")) return f"cc {source} {cjson} -lcurl -o {binary} && {binary}" return { "python": "python api_client.py", diff --git a/tests/test_base_engineer.py b/tests/test_base_engineer.py index bdc50377..3962eb64 100644 --- a/tests/test_base_engineer.py +++ b/tests/test_base_engineer.py @@ -384,14 +384,15 @@ def test_get_run_command_ruby_resolves_relative_output_dir(self, tmp_path): assert script_arg == str(eng.scripts_dir.resolve() / "api_client.rb") def test_get_run_command_c(self, tmp_path): """Run command for C compiles and runs as one step, using full, - shell-quoted paths throughout — the agent's cwd is + resolved, shell-quoted paths throughout — the agent's cwd is scripts_dir.parent.parent (see analyze_and_generate), not scripts_dir where the source, vendored cJSON, and compiled binary all actually live.""" eng = self._make_engineer(tmp_path, output_language="c") - source = shlex.quote(f"{eng.scripts_dir}/api_client.c") - cjson = shlex.quote(f"{eng.scripts_dir}/cJSON.c") - binary = shlex.quote(f"{eng.scripts_dir}/api_client") + resolved = eng.scripts_dir.resolve() + source = shlex.quote(str(resolved / "api_client.c")) + cjson = shlex.quote(str(resolved / "cJSON.c")) + binary = shlex.quote(str(resolved / "api_client")) expected = f"cc {source} {cjson} -lcurl -o {binary} && {binary}" assert eng._get_run_command() == expected @@ -402,12 +403,29 @@ def test_get_run_command_c_quotes_metacharacters(self, tmp_path): naive f'"{path}"' approach got wrong.""" eng = self._make_engineer(tmp_path, output_language="c") eng.scripts_dir = Path("/tmp/weird$(rm -rf ~) dir") + resolved = eng.scripts_dir.resolve() tokens = shlex.split(eng._get_run_command()) - assert tokens[:2] == ["cc", f"{eng.scripts_dir}/api_client.c"] - assert tokens[2] == f"{eng.scripts_dir}/cJSON.c" - assert tokens[3:6] == ["-lcurl", "-o", f"{eng.scripts_dir}/api_client"] + assert tokens[:2] == ["cc", str(resolved / "api_client.c")] + assert tokens[2] == str(resolved / "cJSON.c") + assert tokens[3:6] == ["-lcurl", "-o", str(resolved / "api_client")] assert tokens[6] == "&&" - assert tokens[7] == f"{eng.scripts_dir}/api_client" + assert tokens[7] == str(resolved / "api_client") + + def test_get_run_command_c_resolves_relative_output_dir(self, tmp_path): + """A relative scripts_dir must be resolved to an absolute path for + all three paths (source, cJSON, binary) 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="c") + eng.scripts_dir = Path("relative_output/scripts/run123") + resolved = eng.scripts_dir.resolve() + tokens = shlex.split(eng._get_run_command()) + assert Path(tokens[1]).is_absolute() + assert tokens[1] == str(resolved / "api_client.c") + assert tokens[2] == str(resolved / "cJSON.c") + assert tokens[5] == str(resolved / "api_client") def test_get_run_command_unknown(self, tmp_path): """Unknown language defaults to Python command.""" From 6f5796317743636b7591b195ab643ae61362281e Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 22 Jul 2026 13:26:08 +0200 Subject: [PATCH 5/5] Note C output's POSIX toolchain prerequisite in the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 07893f51..af822b31 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`, `csharp`, `php`, `ruby`, or `c`. +- **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. ## CLI