Skip to content

Commit fa454b1

Browse files
committed
feat: initial implementation
1 parent d08ca43 commit fa454b1

33 files changed

Lines changed: 388 additions & 242 deletions

.github/workflows/tests.yml

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,9 @@ jobs:
132132
run: dist\CursorChatBrowser\CursorChatBrowser.exe --help
133133

134134
# ── Typecheck: mypy ───────────────────────────────────────────────────────
135-
# Codebase already has type hints across most of the surface (~70+ typed
136-
# functions). Mypy runs with --ignore-missing-imports for untyped
137-
# third-party deps; strict-optional is enabled (mypy default). The
138-
# transitional `continue-on-error: true` was removed in #29 once mypy
139-
# reached zero errors on this repo — type failures now block merges.
135+
# strict = true in pyproject.toml (issue #100). Per-module overrides skip
136+
# scripts/export.py and tests/ until those surfaces are fully annotated.
137+
# Type failures block merges — no continue-on-error.
140138
typecheck:
141139
name: Typecheck (mypy)
142140
runs-on: ubuntu-latest
@@ -157,9 +155,7 @@ jobs:
157155
python -m pip install 'mypy>=1.10,<2'
158156
159157
- name: Run mypy
160-
# No `continue-on-error` — mypy now exits zero on this repo (closes #29),
161-
# so type errors must fail the job from here on.
162-
run: mypy --ignore-missing-imports --pretty .
158+
run: mypy .
163159

164160
# ── Secret scan: gitleaks ─────────────────────────────────────────────────
165161
# Catches accidentally committed credentials. Runs over full git history

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
## [0.1.0] - 2026-06-04
10+
### Added
11+
- **Strict mypy**`strict = true` in `pyproject.toml`; core TypedDict models
12+
(`SearchResult`, `ConversationSummary`) and full annotations on API routes and
13+
`utils/` (#100)
14+
15+
### Changed
16+
- CI typecheck job runs `mypy .` using pyproject config (strict production code;
17+
per-module overrides for `scripts/export.py` and `tests.*`)
1118

1219
### Added
1320
- **Summary disk cache (Phase 3)** — project list and tab summaries cached under

api/composers.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
import os
1010
import sqlite3
1111
from contextlib import closing
12+
from typing import Any
1213

13-
from flask import Blueprint, jsonify
14+
from flask import Blueprint, Response, jsonify
1415

1516
from utils.workspace_path import resolve_workspace_path
1617
from utils.path_helpers import to_epoch_ms
@@ -20,13 +21,13 @@
2021
_logger = logging.getLogger(__name__)
2122

2223

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

2728

2829
@bp.route("/api/composers")
29-
def list_composers():
30+
def list_composers() -> tuple[Response, int] | Response:
3031
try:
3132
workspace_path = resolve_workspace_path()
3233
composers = []
@@ -120,7 +121,7 @@ def list_composers():
120121

121122

122123
@bp.route("/api/composers/<composer_id>")
123-
def get_composer(composer_id):
124+
def get_composer(composer_id: str) -> tuple[Response, int] | Response:
124125
try:
125126
workspace_path = resolve_workspace_path()
126127

api/config_api.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import subprocess
1212
import sys
1313

14-
from flask import Blueprint, jsonify, request
14+
from flask import Blueprint, Response, jsonify, request
1515

1616
from utils.path_validation import WorkspacePathError, validate_workspace_path
1717
from utils.workspace_path import set_workspace_path_override
@@ -21,7 +21,7 @@
2121

2222

2323
@bp.route("/api/detect-environment")
24-
def detect_environment():
24+
def detect_environment() -> Response:
2525
try:
2626
is_wsl = False
2727
is_remote = bool(
@@ -56,7 +56,7 @@ def detect_environment():
5656

5757

5858
@bp.route("/api/validate-path", methods=["POST"])
59-
def validate_path():
59+
def validate_path() -> tuple[Response, int] | Response:
6060
"""Same path rules as POST /api/set-workspace: realpath, markers (issue #15)."""
6161
try:
6262
body = request.get_json(silent=True) or {}
@@ -97,7 +97,7 @@ def validate_path():
9797

9898

9999
@bp.route("/api/set-workspace", methods=["POST"])
100-
def set_workspace():
100+
def set_workspace() -> tuple[Response, int] | Response:
101101
# Reject non-dict JSON bodies (array / string / number / null). Without
102102
# this, get_json returns the value directly, the truthy fallback `or {}`
103103
# is bypassed, and `body.get("path", "")` raises AttributeError — which
@@ -126,7 +126,7 @@ def set_workspace():
126126

127127

128128
@bp.route("/api/get-username")
129-
def get_username():
129+
def get_username() -> Response:
130130
try:
131131
username = "YOUR_USERNAME"
132132

api/export_api.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import zipfile
1313
from datetime import datetime
1414
from pathlib import Path
15+
from typing import Any, cast
1516

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

@@ -38,13 +39,13 @@ def _get_state_dir() -> str:
3839
return os.path.join(str(Path.home()), ".cursor-chat-browser")
3940

4041

41-
def _get_export_state() -> dict:
42+
def _get_export_state() -> dict[str, Any]:
4243
"""Read the export state file."""
4344
state_path = os.path.join(_get_state_dir(), "export_state.json")
4445
if os.path.isfile(state_path):
4546
try:
4647
with open(state_path, "r", encoding="utf-8") as f:
47-
return json.load(f)
48+
return cast(dict[str, Any], json.load(f))
4849
except (json.JSONDecodeError, ValueError, OSError) as e:
4950
_logger.warning(
5051
"Could not read export state from %s: %s",
@@ -54,7 +55,7 @@ def _get_export_state() -> dict:
5455
return {}
5556

5657

57-
def _save_export_state(count: int):
58+
def _save_export_state(count: int) -> None:
5859
"""Save export state after an export."""
5960
state_dir = _get_state_dir()
6061
os.makedirs(state_dir, exist_ok=True)
@@ -68,14 +69,14 @@ def _save_export_state(count: int):
6869

6970

7071
@bp.route("/api/export/state")
71-
def get_export_state():
72+
def get_export_state() -> Response:
7273
"""Return the last export timestamp."""
7374
state = _get_export_state()
7475
return jsonify(state)
7576

7677

7778
@bp.route("/api/export", methods=["POST"])
78-
def export_chats():
79+
def export_chats() -> tuple[Response, int] | Response:
7980
"""Export chats as a zip archive.
8081
8182
Exclusion rules (``EXCLUSION_RULES`` app config key) are evaluated against
@@ -112,7 +113,7 @@ def export_chats():
112113
ws_id_to_slug[e["name"]] = slug(display)
113114

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

118119
# ── Database reading via service layer ────────────────────────────────

api/flask_config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
from __future__ import annotations
44

5+
from typing import Any
6+
57
from flask import current_app
68

79

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

api/logs.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
import sqlite3
1111
from contextlib import closing
1212
from datetime import datetime
13+
from typing import Any
1314

14-
from flask import Blueprint, jsonify
15+
from flask import Blueprint, Response, jsonify
1516

1617
from utils.workspace_path import resolve_workspace_path
1718
from utils.path_helpers import to_epoch_ms, warn_workspace_json_read
@@ -26,7 +27,7 @@ def _extract_chat_id_from_bubble_key(key: str) -> str | None:
2627

2728

2829
@bp.route("/api/logs")
29-
def get_logs():
30+
def get_logs() -> tuple[Response, int] | Response:
3031
try:
3132
workspace_path = resolve_workspace_path()
3233
logs = []
@@ -40,7 +41,7 @@ def get_logs():
4041
conn.row_factory = sqlite3.Row
4142
rows = conn.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'").fetchall()
4243

43-
chat_map: dict[str, list] = {}
44+
chat_map: dict[str, list[Any]] = {}
4445
for row in rows:
4546
chat_id = _extract_chat_id_from_bubble_key(row["key"])
4647
if not chat_id:

api/pdf.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io
77
import logging
88
import re
9+
from typing import Any
910

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

@@ -43,7 +44,7 @@ def _safe_text(text: str) -> str:
4344

4445

4546
@bp.route("/api/generate-pdf", methods=["POST"])
46-
def generate_pdf():
47+
def generate_pdf() -> tuple[Response, int] | Response:
4748
try:
4849
body = request.get_json(silent=True) or {}
4950
markdown_text = body.get("markdown", "")
@@ -52,10 +53,10 @@ def generate_pdf():
5253
from fpdf import FPDF
5354

5455
class PDFDoc(FPDF):
55-
def header(self):
56+
def header(self) -> None:
5657
pass
5758

58-
def footer(self):
59+
def footer(self) -> None:
5960
self.set_y(-15)
6061
self.set_font("Helvetica", "I", 8)
6162
self.cell(0, 10, f"Page {self.page_no()}/{{nb}}", align="C")
@@ -74,7 +75,7 @@ def footer(self):
7475
# Parse markdown line by line
7576
lines = markdown_text.split("\n")
7677
in_code_block = False
77-
code_lines = []
78+
code_lines: list[str] = []
7879

7980
for line in lines:
8081
try:
@@ -179,7 +180,7 @@ def footer(self):
179180
return jsonify({"error": "Failed to generate PDF"}), 500
180181

181182

182-
def _render_code_block(pdf, code_text: str):
183+
def _render_code_block(pdf: Any, code_text: str) -> None:
183184
"""Render a code block with a dark background."""
184185
pdf.ln(3)
185186
pdf.set_font("Courier", "", 8)

api/search.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
"""
55

66
import logging
7+
from typing import Any
78

8-
from flask import Blueprint, current_app, jsonify, request
9+
from flask import Blueprint, Response, current_app, jsonify, request
910

10-
from models import ParseWarningCollector
11+
from models import ParseWarningCollector, SearchResult
1112
from services.search import (
1213
rank_results,
1314
search_cli_sessions,
@@ -21,7 +22,7 @@
2122

2223

2324
@bp.route("/api/search")
24-
def search():
25+
def search() -> tuple[Response, int] | Response:
2526
try:
2627
query = request.args.get("q", "").strip()
2728
search_type = request.args.get("type", "all")
@@ -34,7 +35,7 @@ def search():
3435
parse_warnings = ParseWarningCollector()
3536
query_lower = query.lower()
3637

37-
results = []
38+
results: list[SearchResult] = []
3839
results.extend(
3940
search_global_storage(workspace_path, query, query_lower, rules, parse_warnings)
4041
)
@@ -46,7 +47,7 @@ def search():
4647
search_cli_sessions(get_cli_chats_path(), query, query_lower, rules)
4748
)
4849

49-
payload: dict = {"results": rank_results(results)}
50+
payload: dict[str, Any] = {"results": rank_results(results)}
5051
return jsonify(parse_warnings.attach_to(payload))
5152

5253
except Exception:

api/workspaces.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
import logging
1111
import os
1212
from datetime import datetime, timezone
13+
from typing import Any
1314

14-
from flask import Blueprint, jsonify, request
15+
from flask import Blueprint, Response, jsonify, request
1516

1617
from api.flask_config import exclusion_rules
1718

@@ -55,14 +56,14 @@ def _request_nocache() -> bool:
5556

5657

5758
@bp.route("/api/workspaces")
58-
def list_workspaces():
59+
def list_workspaces() -> tuple[Response, int] | Response:
5960
try:
6061
workspace_path = resolve_workspace_path()
6162
rules = exclusion_rules()
6263
projects, warnings = list_workspace_projects(
6364
workspace_path, rules, nocache=_request_nocache(),
6465
)
65-
payload: dict = {"projects": projects}
66+
payload: dict[str, Any] = {"projects": projects}
6667
if warnings:
6768
payload["warnings"] = warnings
6869
return jsonify(payload)
@@ -76,7 +77,7 @@ def list_workspaces():
7677
# ---------------------------------------------------------------------------
7778

7879
@bp.route("/api/workspaces/<workspace_id>")
79-
def get_workspace(workspace_id):
80+
def get_workspace(workspace_id: str) -> tuple[Response, int] | Response:
8081
try:
8182
if workspace_id == "global":
8283
return jsonify({
@@ -154,7 +155,7 @@ def get_workspace(workspace_id):
154155
# ---------------------------------------------------------------------------
155156

156157
@bp.route("/api/workspaces/<workspace_id>/tabs")
157-
def get_workspace_tabs(workspace_id):
158+
def get_workspace_tabs(workspace_id: str) -> tuple[Response, int] | Response:
158159
if workspace_id.startswith("cli:"):
159160
try:
160161
return get_cli_workspace_tabs(workspace_id, exclusion_rules())
@@ -182,7 +183,7 @@ def get_workspace_tabs(workspace_id):
182183
# ---------------------------------------------------------------------------
183184

184185
@bp.route("/api/workspaces/<workspace_id>/tabs/<composer_id>")
185-
def get_workspace_tab(workspace_id, composer_id):
186+
def get_workspace_tab(workspace_id: str, composer_id: str) -> tuple[Response, int] | Response:
186187
if workspace_id.startswith("cli:"):
187188
return jsonify({"error": "Per-tab lazy load is not supported for CLI workspaces"}), 400
188189
try:

0 commit comments

Comments
 (0)