Skip to content

Commit 178a33a

Browse files
wpak-aicursoragent
andcommitted
fix: CLI parity with cursor-chat-browser-python, exclusion rules, state format (#11)
Closes #11 - Add --exclude-rules/-e to app.py and scripts/export.py (missing vs reference) - Add utils/exclusion_rules.py ported from reference with claude-specific defaults - Apply exclusion rules in api/export_api.py bulk export endpoint - Fix use_reloader=False → platform-aware (sys.platform != "win32") in app.py - Fix export_state.json format: add lastExportTime, exportedCount, exportDir alongside sessions sub-key; migrate legacy flat format transparently - Add tests: test_cli_args.py, test_export_state.py, test_export_exclusion_filtering.py Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5029d8e commit 178a33a

8 files changed

Lines changed: 1116 additions & 17 deletions

File tree

api/export_api.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,26 @@
1212
from utils.session_stats import compute_stats
1313
from utils.md_exporter import session_to_markdown
1414
from utils.json_exporter import session_to_json
15+
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
1516

1617
export_bp = Blueprint("export", __name__)
1718

1819

20+
def _session_text_for_exclusion(session: dict) -> str:
21+
"""Extract a plain-text snippet from session messages for exclusion matching."""
22+
parts = []
23+
for msg in session.get("messages", []):
24+
text = msg.get("text") or ""
25+
if isinstance(text, str) and text.strip():
26+
parts.append(text)
27+
return "\n\n".join(parts)
28+
29+
1930
@export_bp.route("/api/export", methods=["POST"])
2031
def bulk_export():
2132
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
2233
projects = list_projects(base)
34+
rules = current_app.config.get("EXCLUSION_RULES") or []
2335

2436
buf = io.BytesIO()
2537
count = 0
@@ -32,6 +44,18 @@ def bulk_export():
3244
session = parse_session(sess_info["path"])
3345
if session["title"] == "Untitled Session":
3446
continue
47+
48+
if rules:
49+
meta = session["metadata"]
50+
searchable = build_searchable_text(
51+
project_name=project.get("display_name") or project["name"],
52+
session_title=session["title"],
53+
model_names=list(meta.get("models_used") or []),
54+
content_snippet=_session_text_for_exclusion(session),
55+
)
56+
if is_excluded_by_rules(rules, searchable):
57+
continue
58+
3559
stats = compute_stats(session)
3660
md = session_to_markdown(session, stats)
3761
title_slug = _slugify(session["title"])[:60] or "session"

app.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
11
"""Flask app that serves the web GUI for browsing sessions."""
22

33
import os
4+
import sys
45

56
from flask import Flask
67

78
from api.projects import projects_bp
89
from api.sessions import sessions_bp
910
from api.search import search_bp
1011
from api.export_api import export_bp
12+
from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules
1113

1214

13-
def create_app(base_dir: str | None = None) -> Flask:
15+
def create_app(
16+
base_dir: str | None = None,
17+
exclusion_rules_path: str | None = None,
18+
) -> Flask:
1419
app = Flask(__name__)
1520
app.config["CLAUDE_PROJECTS_DIR"] = base_dir
1621

22+
resolved = resolve_exclusion_rules_path(exclusion_rules_path)
23+
app.config["EXCLUSION_RULES_PATH"] = resolved
24+
app.config["EXCLUSION_RULES"] = load_rules(resolved)
25+
1726
app.register_blueprint(projects_bp)
1827
app.register_blueprint(sessions_bp)
1928
app.register_blueprint(search_bp)
@@ -33,8 +42,20 @@ def index():
3342
parser.add_argument("--port", type=int, default=5000)
3443
parser.add_argument("--host", default="127.0.0.1")
3544
parser.add_argument("--base-dir", default=None, help="Override Claude projects dir")
45+
parser.add_argument(
46+
"--exclude-rules", "-e",
47+
default=None,
48+
metavar="PATH",
49+
help="Path to exclusion rules file (sensitive sessions are omitted). "
50+
"If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present.",
51+
)
3652
args = parser.parse_args()
3753

38-
app = create_app(base_dir=args.base_dir)
54+
app = create_app(base_dir=args.base_dir, exclusion_rules_path=args.exclude_rules)
3955
print(f"Claude Code Chat Browser running at http://{args.host}:{args.port}")
40-
app.run(host=args.host, port=args.port, debug=True, use_reloader=False)
56+
app.run(
57+
host=args.host,
58+
port=args.port,
59+
debug=True,
60+
use_reloader=(sys.platform != "win32"),
61+
)

requirements.txt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,16 @@
1-
flask>=3.0
1+
blinker==1.9.0
2+
click==8.3.1
3+
colorama==0.4.6
4+
exceptiongroup==1.3.1
5+
Flask==3.1.3
6+
iniconfig==2.3.0
7+
itsdangerous==2.2.0
8+
Jinja2==3.1.6
9+
MarkupSafe==3.0.3
10+
packaging==26.0
11+
pluggy==1.6.0
12+
Pygments==2.19.2
13+
pytest==9.0.2
14+
tomli==2.4.0
15+
typing_extensions==4.15.0
16+
Werkzeug==3.1.6

scripts/export.py

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
from utils.session_stats import compute_stats, _format_duration
2929
from utils.md_exporter import session_to_markdown
3030
from utils.json_exporter import session_to_json
31+
from utils.exclusion_rules import (
32+
resolve_exclusion_rules_path,
33+
load_rules,
34+
build_searchable_text,
35+
is_excluded_by_rules,
36+
)
3137

3238

3339
STATE_DIR = os.path.join(os.path.expanduser("~"), ".claude-code-chat-browser")
@@ -285,11 +291,15 @@ def cmd_export(args):
285291
project_filter = getattr(args, "project", None)
286292
fmt = getattr(args, "format", None) or "md"
287293
session_filter = getattr(args, "session", None)
294+
exclusion_rules_path = getattr(args, "exclude_rules", None)
288295

289296
if not os.path.isdir(base_dir):
290297
_die(f"Claude Code projects directory not found: {base_dir}")
291298

292-
last_export = _load_state() if since == "last" else {}
299+
rules = load_rules(resolve_exclusion_rules_path(exclusion_rules_path))
300+
301+
state = _load_state() if since == "last" else {}
302+
last_export = state.get("sessions", {})
293303

294304
# Single session export
295305
if session_filter:
@@ -338,6 +348,18 @@ def cmd_export(args):
338348
skipped += 1
339349
continue
340350

351+
if rules:
352+
meta = session["metadata"]
353+
searchable = build_searchable_text(
354+
project_name=project.get("display_name") or project["name"],
355+
session_title=session["title"],
356+
model_names=list(meta.get("models_used") or []),
357+
content_snippet=_session_text_for_exclusion(session),
358+
)
359+
if is_excluded_by_rules(rules, searchable):
360+
skipped += 1
361+
continue
362+
341363
stats = compute_stats(session)
342364
meta = session["metadata"]
343365
ts = meta.get("first_timestamp", "")
@@ -420,8 +442,8 @@ def cmd_export(args):
420442
zf.writestr("manifest.jsonl", manifest_str)
421443
print(f"Exported {exported} file(s) to {zip_path}")
422444

423-
_save_state(last_export)
424-
print("Export state saved.")
445+
_save_state(last_export, count=len(manifest), out_dir=out_dir)
446+
print(f"State saved to {STATE_FILE}")
425447

426448

427449
def _export_single(session: dict, stats: dict, fmt: str, out_dir: str):
@@ -445,6 +467,19 @@ def _export_single(session: dict, stats: dict, fmt: str, out_dir: str):
445467
print(f"Exported: {fpath}")
446468

447469

470+
# ==================== Helpers ====================
471+
472+
473+
def _session_text_for_exclusion(session: dict) -> str:
474+
"""Extract plain text from all session messages for exclusion rule matching."""
475+
parts = []
476+
for msg in session.get("messages", []):
477+
text = msg.get("text") or ""
478+
if isinstance(text, str) and text.strip():
479+
parts.append(text)
480+
return "\n\n".join(parts)
481+
482+
448483
# ==================== Argument Parser ====================
449484

450485

@@ -469,6 +504,14 @@ def build_parser() -> argparse.ArgumentParser:
469504
default=None, help="Export format (default: md)")
470505
parser.add_argument("--session", default=None,
471506
help="Export/stats for single session (UUID prefix)")
507+
parser.add_argument(
508+
"--exclude-rules", "-e",
509+
default=None,
510+
metavar="PATH",
511+
dest="exclude_rules",
512+
help="Path to exclusion rules file (sensitive sessions are omitted). "
513+
"If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present.",
514+
)
472515

473516
subparsers = parser.add_subparsers(dest="command")
474517

@@ -506,13 +549,18 @@ def build_parser() -> argparse.ArgumentParser:
506549
help="Filter by project name")
507550
export_p.add_argument("--base-dir", default=None,
508551
help="Override Claude Code projects directory")
552+
export_p.add_argument(
553+
"--exclude-rules", "-e",
554+
default=None,
555+
metavar="PATH",
556+
dest="exclude_rules",
557+
help="Path to exclusion rules file (sensitive sessions are omitted). "
558+
"If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present.",
559+
)
509560

510561
return parser
511562

512563

513-
# ==================== Helpers ====================
514-
515-
516564
def _find_session(session_id: str, base_dir: str) -> str | None:
517565
"""Scan all projects for a session matching this UUID (or prefix).
518566
Fails if the prefix matches more than one session."""
@@ -534,14 +582,41 @@ def _find_session(session_id: str, base_dir: str) -> str | None:
534582

535583

536584
def _load_state() -> dict:
537-
if os.path.isfile(STATE_FILE):
538-
with open(STATE_FILE, "r") as f:
539-
return json.load(f)
540-
return {}
541-
542-
543-
def _save_state(state: dict):
585+
"""Load export state, migrating legacy flat format to the current schema.
586+
587+
Current schema::
588+
589+
{
590+
"lastExportTime": "2026-02-25T12:00:00",
591+
"exportedCount": 42,
592+
"exportDir": "/path/to/out",
593+
"sessions": {"<session-uuid>": <mtime-float>, ...}
594+
}
595+
596+
Legacy schema (written by older versions)::
597+
598+
{"<session-uuid>": <mtime-float>, ...}
599+
"""
600+
if not os.path.isfile(STATE_FILE):
601+
return {}
602+
with open(STATE_FILE, "r") as f:
603+
data = json.load(f)
604+
# Migrate: if the file has neither "sessions" nor "lastExportTime" it is
605+
# the old flat dict of session_id → mtime.
606+
if "sessions" not in data and "lastExportTime" not in data:
607+
return {"sessions": data}
608+
return data
609+
610+
611+
def _save_state(sessions: dict, count: int, out_dir: str):
612+
"""Persist export state with standardised fields matching cursor-chat-browser."""
544613
os.makedirs(STATE_DIR, exist_ok=True)
614+
state = {
615+
"lastExportTime": datetime.now().isoformat(),
616+
"exportedCount": count,
617+
"exportDir": out_dir,
618+
"sessions": sessions,
619+
}
545620
with open(STATE_FILE, "w") as f:
546621
json.dump(state, f, indent=2)
547622

0 commit comments

Comments
 (0)