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
13 changes: 5 additions & 8 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,9 @@ jobs:
run: dist\CursorChatBrowser\CursorChatBrowser.exe --help

# ── Typecheck: mypy ───────────────────────────────────────────────────────
# Codebase already has type hints across most of the surface (~70+ typed
# functions). Mypy runs with --ignore-missing-imports for untyped
# third-party deps; strict-optional is enabled (mypy default). The
# transitional `continue-on-error: true` was removed in #29 once mypy
# reached zero errors on this repo — type failures now block merges.
# strict = true in pyproject.toml (issue #100). Per-module overrides skip
# scripts/export.py and tests/ until those surfaces are fully annotated.
# Type failures block merges — no continue-on-error.
typecheck:
name: Typecheck (mypy)
runs-on: ubuntu-latest
Expand All @@ -151,15 +149,14 @@ jobs:
- name: Install runtime deps + mypy
# Install from the pinned lock file for deterministic resolution,
# then add mypy (dev-only; not in requirements-lock.txt).
# Flask 3.1+ ships inline types — do not install types-Flask (conflicts).
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements-lock.txt
python -m pip install 'mypy>=1.10,<2'

- name: Run mypy
# No `continue-on-error` — mypy now exits zero on this repo (closes #29),
# so type errors must fail the job from here on.
run: mypy --ignore-missing-imports --pretty .
run: mypy .

# ── Secret scan: gitleaks ─────────────────────────────────────────────────
# Catches accidentally committed credentials. Runs over full git history
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ __pycache__/
venv/
.venv/
env/
.mypy-ci-test/

# Packaging
*.egg-info/
Expand Down
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.0] - 2026-06-04

### Added
- **Strict mypy** — `strict = true` in `pyproject.toml`; core TypedDict models
(`SearchResult`, `ConversationSummary`) and full annotations on API routes and
`utils/` (#100)
- **Summary disk cache (Phase 3)** — project list and tab summaries cached under
`~/.cache/cursor-chat-browser/`, invalidated when global or per-workspace DB
mtimes change; bypass with `?nocache=1` or `CURSOR_CHAT_BROWSER_NOCACHE=1` (#84)
Expand Down Expand Up @@ -40,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Unit tests for `determine_project_for_conversation` fallback chain (#87, #89)

### Changed
- CI typecheck job runs `mypy .` using pyproject config (strict production code;
per-module overrides for `scripts/export.py` and `tests.*`)
- **List-path performance** — skip full `messageRequestContext` scan unless
invalid workspace aliases are needed; filter `composerData` in SQL; skip
`Composer.from_dict` on list/summary paths; cache `composer_id_to_ws` mapping (#84)
Expand Down
28 changes: 14 additions & 14 deletions api/composers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
import os
import sqlite3
from contextlib import closing
from typing import Any

from flask import Blueprint, jsonify
from flask import Blueprint, Response

from api.flask_config import json_response

from utils.workspace_path import resolve_workspace_path
from utils.path_helpers import to_epoch_ms
Expand All @@ -20,13 +23,13 @@
_logger = logging.getLogger(__name__)


def _read_json_file(path: str):
def _read_json_file(path: str) -> Any:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)


@bp.route("/api/composers")
def list_composers():
def list_composers() -> tuple[Response, int] | Response:
try:
workspace_path = resolve_workspace_path()
composers = []
Expand Down Expand Up @@ -112,15 +115,13 @@ def list_composers():
)

composers.sort(key=lambda pair: to_epoch_ms(pair[0].last_updated_at), reverse=True)
return jsonify([c for _, c in composers])
return json_response([c for _, c in composers])

except Exception:
_logger.exception("Failed to get composers")
return jsonify({"error": "Failed to get composers"}), 500


return json_response({"error": "Failed to get composers"}, 500)
@bp.route("/api/composers/<composer_id>")
def get_composer(composer_id):
def get_composer(composer_id: str) -> tuple[Response, int] | Response:
try:
workspace_path = resolve_workspace_path()

Expand Down Expand Up @@ -182,7 +183,7 @@ def get_composer(composer_id):
# the composer (CodeRabbit on PR #30).
payload = dict(local.raw)
payload["conversation"] = payload.get("conversation") or []
return jsonify(payload)
return json_response(payload)
except SchemaError as e:
_logger.warning(
"Schema drift in %s: %s (%s)",
Expand Down Expand Up @@ -218,15 +219,14 @@ def get_composer(composer_id):
e,
type(e).__name__,
)
return jsonify({"error": "Composer schema drift"}), 404
return json_response({"error": "Composer schema drift"}, 404)
payload = dict(composer.raw)
payload["conversation"] = payload.get("conversation") or []
return jsonify(payload)
return json_response(payload)
except (OSError, sqlite3.Error, json.JSONDecodeError, ValueError):
pass

return jsonify({"error": "Composer not found"}), 404

return json_response({"error": "Composer not found"}, 404)
except Exception:
_logger.exception("Failed to get composer")
return jsonify({"error": "Failed to get composer"}), 500
return json_response({"error": "Failed to get composer"}, 500)
40 changes: 20 additions & 20 deletions api/config_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
import subprocess
import sys

from flask import Blueprint, jsonify, request
from flask import Blueprint, Response, request

from api.flask_config import json_response

from utils.path_validation import WorkspacePathError, validate_workspace_path
from utils.workspace_path import set_workspace_path_override
Expand All @@ -21,7 +23,7 @@


@bp.route("/api/detect-environment")
def detect_environment():
def detect_environment() -> Response:
try:
is_wsl = False
is_remote = bool(
Expand All @@ -39,7 +41,7 @@ def detect_environment():
except Exception:
pass

return jsonify({
return json_response({
"os": sys.platform,
"isWSL": is_wsl,
"isRemote": is_remote,
Expand All @@ -52,23 +54,23 @@ def detect_environment():
type(e).__name__,
exc_info=True,
)
return jsonify({"os": "unknown", "isWSL": False, "isRemote": False})
return json_response({"os": "unknown", "isWSL": False, "isRemote": False})


@bp.route("/api/validate-path", methods=["POST"])
def validate_path():
def validate_path() -> tuple[Response, int] | Response:
"""Same path rules as POST /api/set-workspace: realpath, markers (issue #15)."""
try:
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return jsonify(
return json_response(
{"valid": False, "error": "invalid JSON body", "workspaceCount": 0}
)
raw = body.get("path", "")
try:
canonical = validate_workspace_path(raw)
except WorkspacePathError as e:
return jsonify({"valid": False, "error": str(e), "workspaceCount": 0})
return json_response({"valid": False, "error": str(e), "workspaceCount": 0})

workspace_count = 0
for name in os.listdir(canonical):
Expand All @@ -78,7 +80,7 @@ def validate_path():
if os.path.isfile(db):
workspace_count += 1

return jsonify(
return json_response(
{
"valid": workspace_count > 0,
"workspaceCount": workspace_count,
Expand All @@ -93,19 +95,17 @@ def validate_path():
type(e).__name__,
exc_info=True,
)
return jsonify({"valid": False, "error": "Failed to validate path"}), 500


return json_response({"valid": False, "error": "Failed to validate path"}, 500)
@bp.route("/api/set-workspace", methods=["POST"])
def set_workspace():
def set_workspace() -> tuple[Response, int] | Response:
# Reject non-dict JSON bodies (array / string / number / null). Without
# this, get_json returns the value directly, the truthy fallback `or {}`
# is bypassed, and `body.get("path", "")` raises AttributeError — which
# the outer Exception handler then mis-reports as a 500 server error
# instead of a 400 client error. (CodeRabbit on PR #16.)
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify({"error": "request body must be a JSON object"}), 400
return json_response({"error": "request body must be a JSON object"}, 400)
raw = body.get("path", "")
# Validate the supplied path BEFORE storing the override (issue #15).
# validate_workspace_path collapses `..` traversal AND resolves symlinks
Expand All @@ -115,18 +115,18 @@ def set_workspace():
try:
canonical = validate_workspace_path(raw)
except WorkspacePathError as e:
return jsonify({"error": str(e)}), 400
return json_response({"error": str(e)}, 400)
except Exception: # noqa: BLE001 — only here as a fallback
return jsonify({"error": "Failed to validate workspace path"}), 500
return json_response({"error": "Failed to validate workspace path"}, 500)
try:
set_workspace_path_override(canonical)
except Exception: # noqa: BLE001 — keep the response shape structured JSON
return jsonify({"error": "Failed to set workspace path"}), 500
return jsonify({"success": True, "path": canonical})
return json_response({"error": "Failed to set workspace path"}, 500)
return json_response({"success": True, "path": canonical})


@bp.route("/api/get-username")
def get_username():
def get_username() -> Response:
try:
username = "YOUR_USERNAME"

Expand All @@ -144,7 +144,7 @@ def get_username():
import getpass
username = getpass.getuser()

return jsonify({"username": username})
return json_response({"username": username})

except Exception as e:
_logger.warning(
Expand All @@ -153,4 +153,4 @@ def get_username():
type(e).__name__,
exc_info=True,
)
return jsonify({"username": "YOUR_USERNAME"})
return json_response({"username": "YOUR_USERNAME"})
29 changes: 14 additions & 15 deletions api/export_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any, cast

from flask import Blueprint, Response, jsonify, request
from flask import Blueprint, Response, request

from api.flask_config import exclusion_rules
from api.flask_config import exclusion_rules, json_response

from utils.workspace_path import resolve_workspace_path
from utils.path_helpers import to_epoch_ms
Expand All @@ -38,13 +39,13 @@ def _get_state_dir() -> str:
return os.path.join(str(Path.home()), ".cursor-chat-browser")


def _get_export_state() -> dict:
def _get_export_state() -> dict[str, Any]:
"""Read the export state file."""
state_path = os.path.join(_get_state_dir(), "export_state.json")
if os.path.isfile(state_path):
try:
with open(state_path, "r", encoding="utf-8") as f:
return json.load(f)
return cast(dict[str, Any], json.load(f))
except (json.JSONDecodeError, ValueError, OSError) as e:
_logger.warning(
"Could not read export state from %s: %s",
Expand All @@ -54,7 +55,7 @@ def _get_export_state() -> dict:
return {}


def _save_export_state(count: int):
def _save_export_state(count: int) -> None:
"""Save export state after an export."""
state_dir = _get_state_dir()
os.makedirs(state_dir, exist_ok=True)
Expand All @@ -68,14 +69,14 @@ def _save_export_state(count: int):


@bp.route("/api/export/state")
def get_export_state():
def get_export_state() -> Response:
"""Return the last export timestamp."""
state = _get_export_state()
return jsonify(state)
return json_response(state)


@bp.route("/api/export", methods=["POST"])
def export_chats():
def export_chats() -> tuple[Response, int] | Response:
"""Export chats as a zip archive.

Exclusion rules (``EXCLUSION_RULES`` app config key) are evaluated against
Expand Down Expand Up @@ -112,14 +113,13 @@ def export_chats():
ws_id_to_slug[e["name"]] = slug(display)

today = datetime.now().strftime("%Y-%m-%d")
exported = []
exported: list[dict[str, Any]] = []
rules = exclusion_rules()

# ── Database reading via service layer ────────────────────────────────
with open_global_db(workspace_path) as (global_db, _):
if global_db is None:
return jsonify({"error": "Cursor global storage not found"}), 404

return json_response({"error": "Cursor global storage not found"}, 404)
bubble_map = load_bubble_map(global_db)
code_block_diff_map = load_code_block_diff_map(global_db)

Expand Down Expand Up @@ -199,10 +199,9 @@ def export_chats():

count = len(exported)
if count == 0:
return jsonify({"error": "No conversations to export" + (
return json_response({"error": "No conversations to export" + (
" since last export" if since == "last" else ""
)}), 404

)}, 404)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for entry in exported:
Expand All @@ -228,4 +227,4 @@ def export_chats():
type(e).__name__,
exc_info=True,
)
return jsonify({"error": "Export failed"}), 500
return json_response({"error": "Export failed"}, 500)
26 changes: 24 additions & 2 deletions api/flask_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,31 @@

from __future__ import annotations

from flask import current_app
from typing import Any, overload

from flask import Response, current_app, jsonify

def exclusion_rules() -> list:

def exclusion_rules() -> list[list[Any]]:
"""Return loaded exclusion rules from app config (empty list when unset)."""
return current_app.config.get("EXCLUSION_RULES") or []


@overload
def json_response(data: Any) -> Response: ...


@overload
def json_response(data: Any, status: int) -> tuple[Response, int]: ...


def json_response(
data: Any,
status: int | None = None,
) -> Response | tuple[Response, int]:
"""Typed wrapper around :func:`flask.jsonify` for strict mypy."""
response = jsonify(data)
assert isinstance(response, Response)
if status is None:
return response
return response, status
Loading
Loading