Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ python app.py --port 8080 --host 0.0.0.0
python app.py --base-dir /path/to/claude/projects
```

> **Security warning:** Do not use `--host 0.0.0.0` together with `--debug` on untrusted networks.
Comment thread
wpak-ai marked this conversation as resolved.
> That combination exposes [Werkzeug's interactive debugger](https://werkzeug.palletsprojects.com/en/stable/debug/),
> which allows arbitrary code execution from any browser that can reach the server.
> For typical local browsing, keep the default `--host 127.0.0.1` and omit `--debug`.

### CLI Export

```bash
Expand Down
20 changes: 16 additions & 4 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,19 @@ def index():
return app


if __name__ == "__main__":
def build_cli_parser():
"""CLI argument parser for ``python app.py`` (stdlib only; safe to import in tests)."""
import argparse

parser = argparse.ArgumentParser(description="Claude Code Chat Browser")
parser.add_argument("--port", type=int, default=5000)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument(
"--debug",
action="store_true",
default=False,
help="Enable Flask/Werkzeug debug mode (never use with --host 0.0.0.0 on untrusted networks).",
)
parser.add_argument("--base-dir", default=None, help="Override Claude projects dir")
parser.add_argument(
"--exclude-rules", "-e",
Expand All @@ -49,13 +56,18 @@ def index():
help="Path to exclusion rules file (sensitive sessions are omitted). "
"If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present.",
)
args = parser.parse_args()
return parser


if __name__ == "__main__":
args = build_cli_parser().parse_args()

app = create_app(base_dir=args.base_dir, exclusion_rules_path=args.exclude_rules)
print(f"Claude Code Chat Browser running at http://{args.host}:{args.port}")
# Reloader follows --debug on Unix only (Werkzeug file watcher, not the interactive debugger).
app.run(
host=args.host,
port=args.port,
debug=True,
use_reloader=(sys.platform != "win32"),
debug=args.debug,
use_reloader=args.debug and (sys.platform != "win32"),
)
118 changes: 95 additions & 23 deletions tests/test_cli_args.py
Comment thread
wpak-ai marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
pytest tests/test_cli_args.py -v
"""

import ast
import sys
import os
import importlib
Expand All @@ -20,9 +21,66 @@
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO_ROOT)

from app import build_cli_parser
from scripts.export import build_parser


def _is_app_run_call(node: ast.AST) -> bool:
if not isinstance(node, ast.Call):
return False
func = node.func
return (
isinstance(func, ast.Attribute)
and func.attr == "run"
and isinstance(func.value, ast.Name)
and func.value.id == "app"
)


def _call_passes_hardcoded_debug_true(call: ast.Call) -> bool:
"""True if this Call passes literal True for debug (kwarg or positional)."""
for kw in call.keywords:
if kw.arg == "debug" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
return True
for arg in call.args:
if isinstance(arg, ast.Constant) and arg.value is True:
return True
return False


def _debug_kwarg_uses_args(call: ast.Call) -> bool:
for kw in call.keywords:
if kw.arg != "debug":
continue
val = kw.value
return (
isinstance(val, ast.Attribute)
and isinstance(val.value, ast.Name)
and val.value.id == "args"
and val.attr == "debug"
)
return False


def _ast_mentions_args_debug(node: ast.AST) -> bool:
for child in ast.walk(node):
if (
isinstance(child, ast.Attribute)
and child.attr == "debug"
and isinstance(child.value, ast.Name)
and child.value.id == "args"
):
return True
return False


def _use_reloader_kwarg_tied_to_debug(call: ast.Call) -> bool:
for kw in call.keywords:
if kw.arg == "use_reloader":
return _ast_mentions_args_debug(kw.value)
return False

Comment thread
coderabbitai[bot] marked this conversation as resolved.

# ---------------------------------------------------------------------------
# export.py argument tests
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -214,62 +272,72 @@ def test_help_exits_zero(self):
# ---------------------------------------------------------------------------

class TestAppArgparse:
"""app.py __main__ block must expose the same flags as cursor's app.py."""

def _build_parser(self) -> argparse.ArgumentParser:
"""Re-create the argparse parser from app.py without importing Flask."""
parser = argparse.ArgumentParser(description="Claude Code Chat Browser")
parser.add_argument("--port", type=int, default=5000)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--base-dir", default=None)
parser.add_argument("--exclude-rules", "-e", default=None,
metavar="PATH", dest="exclude_rules")
return parser
"""app.py CLI must expose the same flags as cursor's app.py."""

def test_host_default_is_localhost(self):
"""Default host must be 127.0.0.1 to match cursor which binds to localhost only."""
parser = self._build_parser()
parser = build_cli_parser()
args = parser.parse_args([])
assert args.host == "127.0.0.1"

def test_host_override(self):
parser = self._build_parser()
parser = build_cli_parser()
args = parser.parse_args(["--host", "127.0.0.1"])
assert args.host == "127.0.0.1"

def test_debug_default_is_false(self):
parser = build_cli_parser()
args = parser.parse_args([])
assert args.debug is False

def test_debug_explicit_true(self):
parser = build_cli_parser()
args = parser.parse_args(["--debug"])
assert args.debug is True

def test_app_py_debug_not_hardcoded_true(self):
"""app.run() must wire debug from args, not a literal True."""
app_path = os.path.join(REPO_ROOT, "app.py")
with open(app_path, encoding="utf-8") as f:
tree = ast.parse(f.read(), filename=app_path)
app_run_calls = [n for n in ast.walk(tree) if _is_app_run_call(n)]
assert app_run_calls, "expected at least one app.run() call in app.py"
assert not any(_call_passes_hardcoded_debug_true(c) for c in app_run_calls)
assert any(_debug_kwarg_uses_args(c) for c in app_run_calls)

def test_port_default(self):
parser = self._build_parser()
parser = build_cli_parser()
args = parser.parse_args([])
assert args.port == 5000

def test_port_override(self):
parser = self._build_parser()
parser = build_cli_parser()
args = parser.parse_args(["--port", "8080"])
assert args.port == 8080

def test_base_dir_default_none(self):
parser = self._build_parser()
parser = build_cli_parser()
args = parser.parse_args([])
assert args.base_dir is None

def test_base_dir_override(self):
parser = self._build_parser()
parser = build_cli_parser()
args = parser.parse_args(["--base-dir", "/tmp/projects"])
assert args.base_dir == "/tmp/projects"

def test_exclude_rules_default_none(self):
parser = self._build_parser()
parser = build_cli_parser()
args = parser.parse_args([])
assert args.exclude_rules is None

def test_exclude_rules_long_form(self):
parser = self._build_parser()
parser = build_cli_parser()
args = parser.parse_args(["--exclude-rules", "/tmp/rules.txt"])
assert args.exclude_rules == "/tmp/rules.txt"

def test_exclude_rules_short_form(self):
"""Cursor's app.py uses -e as the short form; claude must too."""
parser = self._build_parser()
parser = build_cli_parser()
args = parser.parse_args(["-e", "/tmp/rules.txt"])
assert args.exclude_rules == "/tmp/rules.txt"

Expand All @@ -294,11 +362,15 @@ def test_app_py_host_default_is_localhost(self):
assert '"127.0.0.1"' in src

def test_app_py_use_reloader_is_platform_aware(self):
"""use_reloader must depend on sys.platform, not be hardcoded False."""
"""use_reloader must be opt-in via --debug and gated on non-Windows platforms."""
app_path = os.path.join(REPO_ROOT, "app.py")
with open(app_path, "r", encoding="utf-8") as f:
with open(app_path, encoding="utf-8") as f:
tree = ast.parse(f.read(), filename=app_path)
app_run_calls = [n for n in ast.walk(tree) if _is_app_run_call(n)]
assert app_run_calls
assert all(_use_reloader_kwarg_tied_to_debug(c) for c in app_run_calls)
with open(app_path, encoding="utf-8") as f:
src = f.read()
assert "sys.platform" in src
assert "win32" in src
# Must NOT have unconditional use_reloader=False
assert "use_reloader=False" not in src
Loading