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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ deepseek-cursor-proxy

When ngrok is enabled, `deepseek-cursor-proxy` will print the ngrok public URL on start. If it differs from the one in Cursor, update it in Cursor's Base URL field.

If you use a **reserved ngrok endpoint or your own domain** (instead of a URL assigned by ngrok), pass it through to the ngrok agent as `--url=…`. Set `ngrok_url` in `~/.deepseek-cursor-proxy/config.yaml` or use `--ngrok-url` on the command line (see `ngrok http --help`). Example:

```yaml
ngrok: true
ngrok_url: https://your-subdomain.ngrok.dev
```

```bash
deepseek-cursor-proxy --ngrok-url https://your-subdomain.ngrok.dev
```

On the first run, `deepseek-cursor-proxy` will create:

- `~/.deepseek-cursor-proxy/config.yaml`: the configuration file
Expand All @@ -99,6 +110,9 @@ deepseek-cursor-proxy --verbose
# Run without ngrok (run on localhost directly)
deepseek-cursor-proxy --no-ngrok

# Use a fixed ngrok public URL (reserved endpoint / custom domain)
deepseek-cursor-proxy --ngrok-url https://your-subdomain.ngrok.dev

# Use a different local port
deepseek-cursor-proxy --port 9000
```
Expand Down
9 changes: 9 additions & 0 deletions src/deepseek_cursor_proxy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ def as_str(value: Any, default: str) -> str:
return str(value)


def as_optional_str(value: Any) -> str | None:
if value is MISSING or value is None:
return None
stripped = str(value).strip()
return stripped if stripped else None


def as_bool(value: Any, default: bool) -> bool:
if value is MISSING or value is None:
return default
Expand Down Expand Up @@ -203,6 +210,7 @@ class ProxyConfig:
cors: bool = DEFAULT_CORS
verbose: bool = DEFAULT_VERBOSE
ngrok: bool = DEFAULT_NGROK
ngrok_url: str | None = None
trace_dir: Path | None = None

@classmethod
Expand Down Expand Up @@ -283,4 +291,5 @@ def from_file(
setting_value(settings, "ngrok"),
DEFAULT_NGROK,
),
ngrok_url=as_optional_str(setting_value(settings, "ngrok_url")),
)
13 changes: 12 additions & 1 deletion src/deepseek_cursor_proxy/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,14 @@ def build_arg_parser() -> argparse.ArgumentParser:
default=None,
help="Start an ngrok tunnel and print the Cursor base URL",
)
parser.add_argument(
"--ngrok-url",
metavar="URL",
help=(
"Pass --url=URL to ngrok (reserved endpoint / custom domain); "
"see `ngrok http --help`"
),
)
parser.add_argument(
"--verbose",
action=argparse.BooleanOptionalAction,
Expand Down Expand Up @@ -1260,6 +1268,9 @@ def main(argv: list[str] | None = None) -> int:
updates["reasoning_content_path"] = args.reasoning_content_path
if args.ngrok is not None:
updates["ngrok"] = args.ngrok
if args.ngrok_url is not None:
stripped = str(args.ngrok_url).strip()
updates["ngrok_url"] = stripped if stripped else None
if args.verbose is not None:
updates["verbose"] = args.verbose
if args.trace_dir is not None:
Expand Down Expand Up @@ -1314,7 +1325,7 @@ def main(argv: list[str] | None = None) -> int:
public_url: str | None = None
if config.ngrok:
target_url = local_tunnel_target(config.host, config.port)
tunnel = NgrokTunnel(target_url)
tunnel = NgrokTunnel(target_url, ngrok_url=config.ngrok_url)
try:
public_url = tunnel.start()
except RuntimeError as exc:
Expand Down
7 changes: 6 additions & 1 deletion src/deepseek_cursor_proxy/tunnel.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def ngrok_agent_urls(api_url: str) -> list[str]:
@dataclass
class NgrokTunnel:
target_url: str
ngrok_url: str | None = None
command: str = "ngrok"
api_url: str = DEFAULT_NGROK_API_URL
startup_timeout: float = 15.0
Expand All @@ -70,8 +71,12 @@ def start(self) -> str:
"`ngrok config add-authtoken <token>` once."
)

argv = [self.command, "http", self.target_url]
if self.ngrok_url:
argv.append(f"--url={self.ngrok_url}")

self.process = subprocess.Popen(
[self.command, "http", self.target_url],
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
Expand Down
10 changes: 10 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def test_default_paths_live_in_visible_user_app_directory(self) -> None:
home / ".deepseek-cursor-proxy" / "reasoning_content.sqlite3",
)
self.assertEqual(ProxyConfig().ngrok, DEFAULT_NGROK)
self.assertIsNone(ProxyConfig().ngrok_url)
self.assertEqual(
ProxyConfig().collapsible_reasoning,
DEFAULT_COLLAPSIBLE_REASONING,
Expand Down Expand Up @@ -136,6 +137,7 @@ def test_loads_config_from_user_yaml_file(self) -> None:
"missing_reasoning_strategy: reject",
"reasoning_cache_max_age_seconds: 60",
"reasoning_cache_max_rows: 50",
"ngrok_url: https://example.ngrok.dev",
]
),
encoding="utf-8",
Expand All @@ -160,6 +162,7 @@ def test_loads_config_from_user_yaml_file(self) -> None:
self.assertEqual(config.missing_reasoning_strategy, "reject")
self.assertEqual(config.reasoning_cache_max_age_seconds, 60)
self.assertEqual(config.reasoning_cache_max_rows, 50)
self.assertEqual(config.ngrok_url, "https://example.ngrok.dev")

def test_invalid_config_values_fall_back_to_defaults(self) -> None:
with TemporaryDirectory() as temp_dir:
Expand Down Expand Up @@ -191,6 +194,13 @@ def test_invalid_config_values_fall_back_to_defaults(self) -> None:
DEFAULT_COLLAPSIBLE_REASONING,
)

def test_ngrok_url_empty_or_whitespace_is_none(self) -> None:
with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "config.yaml"
config_path.write_text('ngrok_url: " "\n', encoding="utf-8")
config = ProxyConfig.from_file(config_path=config_path)
self.assertIsNone(config.ngrok_url)

def test_relative_reasoning_content_path_in_config_is_relative_to_config_file(
self,
) -> None:
Expand Down
6 changes: 6 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ def test_cli_boolean_flags_have_on_and_off_forms(self) -> None:
self.assertTrue(args.cors)
self.assertEqual(args.trace_dir, Path("/tmp/dcp-traces"))

def test_cli_accepts_ngrok_url(self) -> None:
args = build_arg_parser().parse_args(
["--ngrok-url", "https://example.ngrok.app"]
)
self.assertEqual(args.ngrok_url, "https://example.ngrok.app")

def test_default_console_logging_hides_info_prefix_and_timestamp(self) -> None:
formatter = ConsoleLogFormatter(verbose=False)
info_record = logging.LogRecord(
Expand Down
30 changes: 30 additions & 0 deletions tests/test_tunnel.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import unittest
from unittest.mock import MagicMock, patch

from deepseek_cursor_proxy.tunnel import (
NgrokTunnel,
local_tunnel_target,
ngrok_agent_urls,
parse_ngrok_public_url,
Expand Down Expand Up @@ -49,6 +51,34 @@ def test_ngrok_agent_urls_use_current_api_then_legacy_fallback(self) -> None:
],
)

def test_ngrok_tunnel_appends_url_flag_when_configured(self) -> None:
with patch(
"deepseek_cursor_proxy.tunnel.shutil.which", return_value="/x/ngrok"
):
with patch("deepseek_cursor_proxy.tunnel.subprocess.Popen") as popen:
popen.return_value = MagicMock(poll=lambda: None)
with patch.object(
NgrokTunnel,
"wait_for_public_url",
return_value="https://example.ngrok-free.app",
):
tunnel = NgrokTunnel(
"http://127.0.0.1:9000",
ngrok_url="https://my.ngrok.dev",
)
tunnel.start()
popen.assert_called_once()
argv, _kwargs = popen.call_args
self.assertEqual(
argv[0],
[
"ngrok",
"http",
"http://127.0.0.1:9000",
"--url=https://my.ngrok.dev",
],
)


if __name__ == "__main__":
unittest.main()