Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **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.

## [0.10.0] - 2026-06-01

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, or `java`.
- **Output language**: `python`, `javascript`, `typescript`, `go`, `java`, or `csharp`.

## CLI

Expand Down
21 changes: 21 additions & 0 deletions src/reverse_api/base_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class BaseEngineer(ABC):
"typescript": ".ts",
"go": ".go",
"java": ".java",
"csharp": ".cs",
}

def __init__(
Expand Down Expand Up @@ -444,6 +445,7 @@ def _get_language_name(self) -> str:
"typescript": "TypeScript",
"go": "Go",
"java": "Java",
"csharp": "C#",
}.get(self.output_language, "Python")

def _get_existing_client_guidance(self) -> str:
Expand Down Expand Up @@ -489,6 +491,23 @@ def _get_run_command(self) -> str:
# requires ("symbolic reference class is not accessible").
pom = shlex.quote(str(self.scripts_dir.resolve() / "pom.xml"))
return f"mvn -q -f {pom} compile exec:exec"
if self.output_language == "csharp":
# 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),
# a bare `dotnet run` only looks for a project file in the
# current directory. --project points it straight at this run's
# own .csproj regardless of cwd, rather than relying on the
# agent to cd there itself first. 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.
# .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 --project at the wrong, doubly-nested location.
csproj = shlex.quote(str(self.scripts_dir.resolve() / "ApiClient.csproj"))
return f"dotnet run --project {csproj}"
return {
"python": "python api_client.py",
"javascript": "node api_client.js",
Expand Down Expand Up @@ -610,6 +629,8 @@ def _get_auto_output_files(self, language_name: str, client_filename: str) -> st
)
elif self.output_language == "java":
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"
return base

@abstractmethod
Expand Down
1 change: 1 addition & 0 deletions src/reverse_api/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,7 @@ def handle_settings(mode_color=THEME_PRIMARY):
Choice(title="typescript", value="typescript"),
Choice(title="go", value="go"),
Choice(title="java", value="java"),
Choice(title="csharp", value="csharp"),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Choice(title="back", value="back"),
]
lang = questionary.select(
Expand Down
2 changes: 1 addition & 1 deletion src/reverse_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", or "java"
"output_language": "python", # "python", "javascript", "typescript", "go", "java", or "csharp"
"real_time_sync": True, # Enable real-time file sync during engineering
"sdk": "claude", # "claude", "opencode", "copilot", or "cursor"
}
Expand Down
2 changes: 1 addition & 1 deletion src/reverse_api/engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", or "java"
output_language: Target language - "python", "javascript", "typescript", "go", "java", or "csharp"
output_mode: Output mode - "client" for API client code, "docs" for OpenAPI specification
"""
if sdk == "opencode":
Expand Down
2 changes: 1 addition & 1 deletion src/reverse_api/prompts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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".
language: One of "python", "javascript", "typescript", "go", "java", "csharp".
**kwargs: Placeholder values (scripts_dir, client_filename, run_command).
"""
return load(f"partials/_language_{language}", **kwargs)
Expand Down
22 changes: 22 additions & 0 deletions src/reverse_api/prompts/partials/_language_csharp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
**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:

- Use `System.Net.Http.HttpClient` and `System.Text.Json` — both are part of the .NET base class library (available since .NET Core 3.0), so no external NuGet package is needed for HTTP or JSON
- Create a minimal project file (`.csproj`) so the program can be run with a single command. Target `net8.0` by default, but this only works if that SDK/runtime is actually installed — if `dotnet run` reports the target framework isn't supported or the required runtime isn't installed, change `<TargetFramework>` to match what's actually there (check `dotnet --version`; a machine with only a newer SDK needs a higher target like `net10.0`, an older one a lower target like `net6.0`) and retry; both libraries above work fine on any of these versions, only the project file's stated target needs to match the installed SDK
- Create a separate method for each distinct API endpoint, with a small record or class for its response shape
- Reuse one `HttpClient` instance across requests rather than creating a new one per call
- Include a `Main` method 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, construct the `HttpClient` with an `HttpClientHandler` that has a `CookieContainer` set, so cookies persist across requests
- If the API uses Bearer tokens or API keys, hardcode them in the request headers (e.g. via `DefaultRequestHeaders`)
- 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:**
- Run: `{run_command}`
- 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 project file to: `{scripts_dir}/ApiClient.csproj`
40 changes: 40 additions & 0 deletions tests/test_base_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ def test_get_output_extension_java(self, tmp_path):
"""Java extension."""
eng = self._make_engineer(tmp_path, output_language="java")
assert eng._get_output_extension() == ".java"
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_unknown(self, tmp_path):
"""Unknown language defaults to .py."""
Expand Down Expand Up @@ -269,6 +273,37 @@ def test_get_run_command_java_resolves_relative_output_dir(self, tmp_path):
pom_arg = tokens[3]
assert Path(pom_arg).is_absolute()
assert pom_arg == str(eng.scripts_dir.resolve() / "pom.xml")
def test_get_run_command_csharp(self, tmp_path):
"""Run command for C# points --project at this run's own (resolved,
shell-quoted) .csproj, not a bare `dotnet run` — the agent's cwd is
scripts_dir.parent.parent (see analyze_and_generate), and dotnet
only looks for a project file in the current directory."""
eng = self._make_engineer(tmp_path, output_language="csharp")
expected_csproj = shlex.quote(str(eng.scripts_dir.resolve() / "ApiClient.csproj"))
assert eng._get_run_command() == f"dotnet run --project {expected_csproj}"

def test_get_run_command_csharp_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="csharp")
eng.scripts_dir = Path("/tmp/weird$(rm -rf ~) dir")
tokens = shlex.split(eng._get_run_command())
assert tokens[:2] == ["dotnet", "run"]
assert tokens[3] == str(eng.scripts_dir.resolve() / "ApiClient.csproj")

def test_get_run_command_csharp_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="csharp")
eng.scripts_dir = Path("relative_output/scripts/run123")
tokens = shlex.split(eng._get_run_command())
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_unknown(self, tmp_path):
"""Unknown language defaults to Python command."""
Expand Down Expand Up @@ -327,6 +362,11 @@ def test_java_prompt(self, tmp_path):
eng = self._make_engineer(tmp_path, output_language="java")
system_prompt, user_message = eng._build_prompts()
assert "Java program" in system_prompt
def test_csharp_prompt(self, tmp_path):
"""C# prompt includes C#-specific instructions."""
eng = self._make_engineer(tmp_path, output_language="csharp")
system_prompt, user_message = eng._build_prompts()
assert "C# program" in system_prompt
assert "HttpClient" in system_prompt

def test_docs_prompt(self, tmp_path):
Expand Down
10 changes: 10 additions & 0 deletions tests/test_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ def test_java_partial(self):
assert "Java program" in text
assert "HttpClient" in text
assert "/tmp/scripts/api_client.java" in text
def test_csharp_partial(self):
text = load_language_partial(
"csharp",
scripts_dir="/tmp/scripts",
client_filename="api_client.cs",
run_command="dotnet run",
)
assert "C# program" in text
assert "HttpClient" in text
assert "/tmp/scripts/api_client.cs" in text


class TestEngineerTemplates:
Expand Down