Skip to content

Commit 242d81c

Browse files
wpak-aicursoragent
andcommitted
fix: switch app.py and export.py to argparse, add --port/--host/--base-dir, fix default host, validate --since (#4)
Closes #4 - app.py: replace manual sys.argv loop with argparse - app.py: fix default host 0.0.0.0 → 127.0.0.1 (localhost only) - app.py: add --port (default 3000), --host, --base-dir flags - app.py: wire --base-dir to set_workspace_path_override - app.py: startup message now dynamic (http://{host}:{port}) - scripts/export.py: replace manual parse_args() with argparse - scripts/export.py: add --base-dir flag (overrides WORKSPACE_PATH env) - scripts/export.py: --since now uses choices=["all","last"] for validation - tests/test_cli_args.py: 34 unittest tests covering all three bugs Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 725b2ad commit 242d81c

3 files changed

Lines changed: 313 additions & 58 deletions

File tree

app.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,24 +62,33 @@ def favicon():
6262

6363

6464
if __name__ == "__main__":
65+
import argparse
6566
import sys
6667

67-
exclusion_path = None
68-
argv = sys.argv[1:]
69-
i = 0
70-
while i < len(argv):
71-
if argv[i] in ("--exclude-rules", "-e") and i + 1 < len(argv):
72-
exclusion_path = argv[i + 1]
73-
i += 2
74-
continue
75-
i += 1
76-
77-
app = create_app(exclusion_rules_path=exclusion_path)
78-
print("Cursor Chat Browser (Python) running at http://localhost:3000")
79-
# use_reloader=False avoids a Windows socket issue with Flask's stat reloader
68+
parser = argparse.ArgumentParser(description="Cursor Chat Browser (Python)")
69+
parser.add_argument("--port", type=int, default=3000)
70+
parser.add_argument("--host", default="127.0.0.1")
71+
parser.add_argument("--base-dir", default=None,
72+
help="Override Cursor workspaceStorage path")
73+
parser.add_argument(
74+
"--exclude-rules", "-e",
75+
default=None,
76+
metavar="PATH",
77+
help="Path to exclusion rules file (sensitive projects/chats are omitted). "
78+
"If omitted, uses ~/.cursor-chat-browser/exclusion-rules.txt if present.",
79+
)
80+
args = parser.parse_args()
81+
82+
if args.base_dir:
83+
from utils.workspace_path import set_workspace_path_override
84+
set_workspace_path_override(args.base_dir)
85+
86+
app = create_app(exclusion_rules_path=args.exclude_rules)
87+
print(f"Cursor Chat Browser (Python) running at http://{args.host}:{args.port}")
88+
# Disable reloader on Windows to avoid a socket conflict with Flask's stat reloader.
8089
app.run(
81-
host="0.0.0.0",
82-
port=3000,
90+
host=args.host,
91+
port=args.port,
8392
debug=True,
8493
use_reloader=(sys.platform != "win32"),
8594
)

scripts/export.py

Lines changed: 39 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -211,50 +211,44 @@ def get_workspace_folder_paths(wd) -> list:
211211
return _shared_get_workspace_folder_paths(wd)
212212

213213

214-
HELP_TEXT = """\
215-
Export Cursor chat history to Markdown files.
216-
217-
By default exports ALL chats (including composer logs) as a zip archive
218-
into the current directory. Use the flags below to narrow the export.
219-
220-
Usage:
221-
python scripts/export.py [OPTIONS]
222-
223-
Options:
224-
--since all|last Export all chats or only those updated since last export.
225-
Default: all
226-
--out DIR Output directory. Default: current working directory (.)
227-
--no-zip Write individual Markdown files instead of a zip archive.
228-
--no-composer Exclude composer logs (export only chat logs).
229-
--exclude-rules P Path to exclusion rules file (sensitive projects/chats are omitted).
230-
If omitted, uses ~/.cursor-chat-browser/exclusion-rules.txt if present.
231-
--help Show this help message and exit.
232-
"""
233-
234-
235214
def parse_args():
236-
args = sys.argv[1:]
237-
out = {"since": "all", "out_dir": ".", "include_composer": True, "zip": True, "exclusion_rules_path": None}
238-
i = 0
239-
while i < len(args):
240-
if args[i] in ("--help", "-h"):
241-
print(HELP_TEXT)
242-
sys.exit(0)
243-
elif args[i] == "--since" and i + 1 < len(args):
244-
i += 1
245-
out["since"] = args[i]
246-
elif args[i] == "--out" and i + 1 < len(args):
247-
i += 1
248-
out["out_dir"] = args[i]
249-
elif args[i] in ("--exclude-rules", "-e") and i + 1 < len(args):
250-
i += 1
251-
out["exclusion_rules_path"] = args[i]
252-
elif args[i] == "--no-composer":
253-
out["include_composer"] = False
254-
elif args[i] == "--no-zip":
255-
out["zip"] = False
256-
i += 1
257-
return out
215+
import argparse
216+
parser = argparse.ArgumentParser(
217+
description="Export Cursor chat history to Markdown files.",
218+
epilog=(
219+
"By default exports ALL chats (including composer logs) as a zip archive\n"
220+
"into the current directory. Use the flags below to narrow the export.\n\n"
221+
"Env: WORKSPACE_PATH overrides the Cursor workspaceStorage path."
222+
),
223+
formatter_class=argparse.RawDescriptionHelpFormatter,
224+
)
225+
parser.add_argument("--since", choices=["all", "last"], default="all",
226+
help="Export all chats or only those updated since last export. Default: all")
227+
parser.add_argument("--out", default=".",
228+
help="Output directory. Default: current working directory (.)")
229+
parser.add_argument("--no-zip", action="store_true", default=False,
230+
help="Write individual Markdown files instead of a zip archive.")
231+
parser.add_argument("--no-composer", action="store_true", default=False,
232+
help="Exclude composer logs (export only chat logs).")
233+
parser.add_argument("--base-dir", default=None,
234+
help="Override Cursor workspaceStorage path (also settable via WORKSPACE_PATH env var).")
235+
parser.add_argument(
236+
"--exclude-rules", "-e",
237+
default=None,
238+
metavar="PATH",
239+
dest="exclude_rules",
240+
help="Path to exclusion rules file (sensitive projects/chats are omitted). "
241+
"If omitted, uses ~/.cursor-chat-browser/exclusion-rules.txt if present.",
242+
)
243+
args = parser.parse_args()
244+
return {
245+
"since": args.since,
246+
"out_dir": args.out,
247+
"include_composer": not args.no_composer,
248+
"zip": not args.no_zip,
249+
"exclusion_rules_path": args.exclude_rules,
250+
"base_dir": args.base_dir,
251+
}
258252

259253

260254
def main():
@@ -263,6 +257,8 @@ def main():
263257
out_dir = os.path.abspath(opts["out_dir"])
264258
use_zip = opts["zip"]
265259
exclusion_rules = load_rules(resolve_exclusion_rules_path(opts.get("exclusion_rules_path")))
260+
if opts.get("base_dir"):
261+
os.environ["WORKSPACE_PATH"] = opts["base_dir"]
266262
workspace_path = resolve_workspace_path()
267263
global_path = os.path.normpath(os.path.join(workspace_path, "..", "globalStorage", "state.vscdb"))
268264

tests/test_cli_args.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""
2+
Regression tests for CLI argument parity between cursor-chat-browser-python and
3+
claude-code-chat-browser.
4+
5+
Every flag/default documented here must stay in sync with claude-code-chat-browser
6+
so that users switching between the two tools experience zero CLI friction.
7+
8+
Run:
9+
python -m unittest tests.test_cli_args -v
10+
"""
11+
12+
import sys
13+
import os
14+
import unittest
15+
16+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17+
sys.path.insert(0, REPO_ROOT)
18+
19+
# Import the argparse-based parse_args from the export script
20+
from scripts.export import parse_args as _raw_parse_args
21+
22+
23+
# ---------------------------------------------------------------------------
24+
# Helpers
25+
# ---------------------------------------------------------------------------
26+
27+
def _parse_export(argv):
28+
"""Call scripts/export.py parse_args() with a custom sys.argv."""
29+
original = sys.argv
30+
sys.argv = ["export.py"] + list(argv)
31+
try:
32+
return _raw_parse_args()
33+
finally:
34+
sys.argv = original
35+
36+
37+
def _build_app_parser():
38+
"""Reconstruct the argparse parser from app.py without importing Flask."""
39+
import argparse
40+
parser = argparse.ArgumentParser(description="Cursor Chat Browser (Python)")
41+
parser.add_argument("--port", type=int, default=3000)
42+
parser.add_argument("--host", default="127.0.0.1")
43+
parser.add_argument("--base-dir", default=None)
44+
parser.add_argument("--exclude-rules", "-e", default=None,
45+
metavar="PATH", dest="exclude_rules")
46+
return parser
47+
48+
49+
# ---------------------------------------------------------------------------
50+
# export.py argument tests
51+
# ---------------------------------------------------------------------------
52+
53+
class TestExportArgs(unittest.TestCase):
54+
55+
# -- --since ----------------------------------------------------------------
56+
57+
def test_since_default_is_all(self):
58+
opts = _parse_export([])
59+
self.assertEqual(opts["since"], "all")
60+
61+
def test_since_all(self):
62+
opts = _parse_export(["--since", "all"])
63+
self.assertEqual(opts["since"], "all")
64+
65+
def test_since_last(self):
66+
opts = _parse_export(["--since", "last"])
67+
self.assertEqual(opts["since"], "last")
68+
69+
def test_since_invalid_raises(self):
70+
with self.assertRaises(SystemExit):
71+
_parse_export(["--since", "yesterday"])
72+
73+
# -- --out ------------------------------------------------------------------
74+
75+
def test_out_default_is_dot(self):
76+
opts = _parse_export([])
77+
self.assertEqual(opts["out_dir"], ".")
78+
79+
def test_out_explicit(self):
80+
opts = _parse_export(["--out", "/tmp/exports"])
81+
self.assertEqual(opts["out_dir"], "/tmp/exports")
82+
83+
# -- --no-zip ---------------------------------------------------------------
84+
85+
def test_no_zip_default_false(self):
86+
opts = _parse_export([])
87+
self.assertTrue(opts["zip"])
88+
89+
def test_no_zip_flag(self):
90+
opts = _parse_export(["--no-zip"])
91+
self.assertFalse(opts["zip"])
92+
93+
# -- --no-composer ----------------------------------------------------------
94+
95+
def test_no_composer_default_true(self):
96+
opts = _parse_export([])
97+
self.assertTrue(opts["include_composer"])
98+
99+
def test_no_composer_flag(self):
100+
opts = _parse_export(["--no-composer"])
101+
self.assertFalse(opts["include_composer"])
102+
103+
# -- --exclude-rules / -e --------------------------------------------------
104+
105+
def test_exclude_rules_default_none(self):
106+
opts = _parse_export([])
107+
self.assertIsNone(opts["exclusion_rules_path"])
108+
109+
def test_exclude_rules_long_form(self):
110+
opts = _parse_export(["--exclude-rules", "/path/to/rules.txt"])
111+
self.assertEqual(opts["exclusion_rules_path"], "/path/to/rules.txt")
112+
113+
def test_exclude_rules_short_form(self):
114+
opts = _parse_export(["-e", "/path/to/rules.txt"])
115+
self.assertEqual(opts["exclusion_rules_path"], "/path/to/rules.txt")
116+
117+
# -- --base-dir -------------------------------------------------------------
118+
119+
def test_base_dir_default_none(self):
120+
opts = _parse_export([])
121+
self.assertIsNone(opts["base_dir"])
122+
123+
def test_base_dir_explicit(self):
124+
opts = _parse_export(["--base-dir", "/custom/workspace"])
125+
self.assertEqual(opts["base_dir"], "/custom/workspace")
126+
127+
# -- --help / -h ------------------------------------------------------------
128+
129+
def test_help_exits_zero(self):
130+
with self.assertRaises(SystemExit) as ctx:
131+
_parse_export(["--help"])
132+
self.assertEqual(ctx.exception.code, 0)
133+
134+
def test_help_short_exits_zero(self):
135+
with self.assertRaises(SystemExit) as ctx:
136+
_parse_export(["-h"])
137+
self.assertEqual(ctx.exception.code, 0)
138+
139+
140+
# ---------------------------------------------------------------------------
141+
# app.py argument tests
142+
# ---------------------------------------------------------------------------
143+
144+
class TestAppArgs(unittest.TestCase):
145+
146+
def setUp(self):
147+
self.parser = _build_app_parser()
148+
149+
# -- --host / --port --------------------------------------------------------
150+
151+
def test_host_default_is_localhost(self):
152+
"""Default host must be 127.0.0.1, matching claude."""
153+
args = self.parser.parse_args([])
154+
self.assertEqual(args.host, "127.0.0.1")
155+
156+
def test_host_override(self):
157+
args = self.parser.parse_args(["--host", "0.0.0.0"])
158+
self.assertEqual(args.host, "0.0.0.0")
159+
160+
def test_port_default(self):
161+
args = self.parser.parse_args([])
162+
self.assertEqual(args.port, 3000)
163+
164+
def test_port_override(self):
165+
args = self.parser.parse_args(["--port", "8080"])
166+
self.assertEqual(args.port, 8080)
167+
168+
# -- --base-dir -------------------------------------------------------------
169+
170+
def test_base_dir_default_none(self):
171+
args = self.parser.parse_args([])
172+
self.assertIsNone(args.base_dir)
173+
174+
def test_base_dir_override(self):
175+
args = self.parser.parse_args(["--base-dir", "/custom/workspace"])
176+
self.assertEqual(args.base_dir, "/custom/workspace")
177+
178+
# -- --exclude-rules / -e ---------------------------------------------------
179+
180+
def test_exclude_rules_default_none(self):
181+
args = self.parser.parse_args([])
182+
self.assertIsNone(args.exclude_rules)
183+
184+
def test_exclude_rules_long_form(self):
185+
args = self.parser.parse_args(["--exclude-rules", "/tmp/rules.txt"])
186+
self.assertEqual(args.exclude_rules, "/tmp/rules.txt")
187+
188+
def test_exclude_rules_short_form(self):
189+
args = self.parser.parse_args(["-e", "/tmp/rules.txt"])
190+
self.assertEqual(args.exclude_rules, "/tmp/rules.txt")
191+
192+
# -- source assertions ------------------------------------------------------
193+
194+
def test_app_py_uses_argparse(self):
195+
app_path = os.path.join(REPO_ROOT, "app.py")
196+
with open(app_path, "r", encoding="utf-8") as f:
197+
src = f.read()
198+
self.assertIn("argparse", src)
199+
self.assertIn("add_argument", src)
200+
201+
def test_app_py_has_port_flag(self):
202+
app_path = os.path.join(REPO_ROOT, "app.py")
203+
with open(app_path, "r", encoding="utf-8") as f:
204+
src = f.read()
205+
self.assertIn('"--port"', src)
206+
207+
def test_app_py_has_host_flag(self):
208+
app_path = os.path.join(REPO_ROOT, "app.py")
209+
with open(app_path, "r", encoding="utf-8") as f:
210+
src = f.read()
211+
self.assertIn('"--host"', src)
212+
213+
def test_app_py_has_base_dir_flag(self):
214+
app_path = os.path.join(REPO_ROOT, "app.py")
215+
with open(app_path, "r", encoding="utf-8") as f:
216+
src = f.read()
217+
self.assertIn('"--base-dir"', src)
218+
219+
def test_app_py_startup_message_is_dynamic(self):
220+
"""Startup message must use {args.host}/{args.port}, not be hardcoded."""
221+
app_path = os.path.join(REPO_ROOT, "app.py")
222+
with open(app_path, "r", encoding="utf-8") as f:
223+
src = f.read()
224+
self.assertIn("args.host", src)
225+
self.assertIn("args.port", src)
226+
227+
def test_app_py_use_reloader_is_platform_aware(self):
228+
app_path = os.path.join(REPO_ROOT, "app.py")
229+
with open(app_path, "r", encoding="utf-8") as f:
230+
src = f.read()
231+
self.assertIn("sys.platform", src)
232+
self.assertIn("win32", src)
233+
self.assertNotIn("use_reloader=False", src)
234+
235+
def test_export_py_has_base_dir_flag(self):
236+
export_path = os.path.join(REPO_ROOT, "scripts", "export.py")
237+
with open(export_path, "r", encoding="utf-8") as f:
238+
src = f.read()
239+
self.assertIn('"--base-dir"', src)
240+
241+
def test_export_py_has_since_choices(self):
242+
"""--since must use choices=["all","last"] for validated parity with claude."""
243+
export_path = os.path.join(REPO_ROOT, "scripts", "export.py")
244+
with open(export_path, "r", encoding="utf-8") as f:
245+
src = f.read()
246+
self.assertIn('choices=["all", "last"]', src)
247+
248+
249+
if __name__ == "__main__":
250+
unittest.main()

0 commit comments

Comments
 (0)