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..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`, or `ruby`. +- **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 diff --git a/src/reverse_api/base_engineer.py b/src/reverse_api/base_engineer.py index 77f05f9b..e6258cdd 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,24 @@ 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. + # shlex.quote (not manual double-quoting) so shell metacharacters + # in any of these three paths can't be interpreted as command + # 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", "javascript": "node api_client.js", @@ -669,6 +689,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..a903eedd --- /dev/null +++ b/src/reverse_api/prompts/partials/_language_c.md @@ -0,0 +1,26 @@ +**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) 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 +- 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..3962eb64 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,50 @@ 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, + 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") + 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 + + 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") + resolved = eng.scripts_dir.resolve() + tokens = shlex.split(eng._get_run_command()) + 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] == 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.""" @@ -454,6 +502,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: