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 @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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.
- **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.

## [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`, `java`, `csharp`, or `php`.
- **Output language**: `python`, `javascript`, `typescript`, `go`, `java`, `csharp`, `php`, or `ruby`.

## CLI

Expand Down
16 changes: 16 additions & 0 deletions src/reverse_api/base_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class BaseEngineer(ABC):
"java": ".java",
"csharp": ".cs",
"php": ".php",
"ruby": ".rb",
}

def __init__(
Expand Down Expand Up @@ -448,6 +449,7 @@ def _get_language_name(self) -> str:
"java": "Java",
"csharp": "C#",
"php": "PHP",
"ruby": "Ruby",
}.get(self.output_language, "Python")

def _get_existing_client_guidance(self) -> str:
Expand Down Expand Up @@ -530,6 +532,20 @@ 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"php {path}"
if self.output_language == "ruby":
# Full path, not a bare relative "ruby api_client.rb": 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 — the
# same working-directory ambiguity fixed for Go/Java/C#/PHP.
# shlex.quote (not manual double-quoting) so shell metacharacters
# in the path 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 this command at the wrong, doubly-nested location.
path = shlex.quote(str(self.scripts_dir.resolve() / self._get_client_filename()))
return f"ruby {path}"
return {
"python": "python api_client.py",
"javascript": "node api_client.js",
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 @@ -1087,6 +1087,7 @@ def handle_settings(mode_color=THEME_PRIMARY):
Choice(title="java", value="java"),
Choice(title="csharp", value="csharp"),
Choice(title="php", value="php"),
Choice(title="ruby", value="ruby"),
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", "java", "csharp", or "php"
"output_language": "python", # "python", "javascript", "typescript", "go", "java", "csharp", "php", or "ruby"
"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", "java", "csharp", or "php"
output_language: Target language - "python", "javascript", "typescript", "go", "java", "csharp", "php", or "ruby"
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", "csharp", "php".
language: One of "python", "javascript", "typescript", "go", "java", "csharp", "php", "ruby".
**kwargs: Placeholder values (scripts_dir, client_filename, run_command).
"""
return load(f"partials/_language_{language}", **kwargs)
Expand Down
20 changes: 20 additions & 0 deletions src/reverse_api/prompts/partials/_language_ruby.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
**Generate a Ruby 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 `net/http` and `json` for requests and JSON — both are part of Ruby's standard library, so no gem/Bundler dependency is needed
- Reuse persistent connections (`Net::HTTP.start` held open across calls) rather than opening a new connection per request — use a separate connection per distinct scheme/host/port, since one is bound to a single origin and captured traffic may span several (e.g. separate login, API, and upload hosts)
- Create a separate method for each distinct API endpoint
- 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 `bundle install`
- `net/http` has no built-in cookie jar, unlike some other languages' HTTP clients — if the API uses cookies, store each `Set-Cookie` header by name, domain, and path; enforce its secure, `Expires`, and `Max-Age` attributes; remove expired or deleted entries; and send back only cookies matching the request's origin and path in the `Cookie` header (a multi-origin trace can otherwise leak a session cookie to the wrong host or send an invalid header)
- 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`
41 changes: 41 additions & 0 deletions tests/test_base_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ 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_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_unknown(self, tmp_path):
"""Unknown language defaults to .py."""
Expand Down Expand Up @@ -343,6 +347,37 @@ def test_get_run_command_php_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.php")
def test_get_run_command_ruby(self, tmp_path):
"""Run command for Ruby 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."""
eng = self._make_engineer(tmp_path, output_language="ruby")
expected_path = shlex.quote(str(eng.scripts_dir.resolve() / "api_client.rb"))
assert eng._get_run_command() == f"ruby {expected_path}"

def test_get_run_command_ruby_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="ruby")
eng.scripts_dir = Path("/tmp/weird$(rm -rf ~) dir")
tokens = shlex.split(eng._get_run_command())
assert tokens[0] == "ruby"
assert tokens[1] == str(eng.scripts_dir.resolve() / "api_client.rb")

def test_get_run_command_ruby_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="ruby")
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.rb")

def test_get_run_command_unknown(self, tmp_path):
"""Unknown language defaults to Python command."""
Expand Down Expand Up @@ -413,6 +448,12 @@ def test_php_prompt(self, tmp_path):
system_prompt, user_message = eng._build_prompts()
assert "PHP script" in system_prompt
assert "curl" in system_prompt
def test_ruby_prompt(self, tmp_path):
"""Ruby prompt includes Ruby-specific instructions."""
eng = self._make_engineer(tmp_path, output_language="ruby")
system_prompt, user_message = eng._build_prompts()
assert "Ruby script" in system_prompt
assert "net/http" in system_prompt

def test_docs_prompt(self, tmp_path):
"""Docs mode prompt includes OpenAPI instructions."""
Expand Down
10 changes: 10 additions & 0 deletions tests/test_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,16 @@ def test_php_partial(self):
assert "PHP script" in text
assert "curl" in text
assert "/tmp/scripts/api_client.php" in text
def test_ruby_partial(self):
text = load_language_partial(
"ruby",
scripts_dir="/tmp/scripts",
client_filename="api_client.rb",
run_command='ruby "/tmp/scripts/api_client.rb"',
)
assert "Ruby script" in text
assert "net/http" in text
assert "/tmp/scripts/api_client.rb" in text


class TestEngineerTemplates:
Expand Down