From 61f135c7ad8c810bca45d64cf4d941b97d81790b Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 1 Jul 2026 05:38:48 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(dx):=20codelens=20sessions=20command?= =?UTF-8?q?=20=E2=80=94=20view=20install=20session=20log,=20rotation,=20--?= =?UTF-8?q?json/--raw=20(closes=20#64=20phase-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +- SKILL-QUICK.md | 10 +- SKILL.md | 4 +- pyproject.toml | 2 +- scripts/codelens.py | 21 +- scripts/commands/sessions.py | 407 ++++++++++++++++++++++++++++++++++ scripts/graph_model.py | 2 +- setup.sh | 149 ++++++++++++- skill.json | 2 +- tests/test_sessions.py | 412 +++++++++++++++++++++++++++++++++++ 10 files changed, 989 insertions(+), 30 deletions(-) create mode 100644 scripts/commands/sessions.py create mode 100644 tests/test_sessions.py diff --git a/README.md b/README.md index b90f8c93..7ebed3bc 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ > **Before an AI writes a new class/id/function, CodeLens must be checked. This is not optional.** -CodeLens is an AI-native code intelligence platform that gives AI agents **full visibility** into a codebase before they write any code. It prevents collision, overwrite of existing logic, security vulnerabilities, and dead code through 68 CLI commands, an MCP server with 66 tools (54 static + 12 dynamic), AST-based taint analysis, live CVE/OSV scanning, a plugin system with OWASP Top 10 + Compliance rule packs, a true graph data model (nodes + edges) for structural code queries, and token-efficient `--format compact` output for high-volume agent workflows (issue #17). +CodeLens is an AI-native code intelligence platform that gives AI agents **full visibility** into a codebase before they write any code. It prevents collision, overwrite of existing logic, security vulnerabilities, and dead code through 70 CLI commands, an MCP server with 68 tools (54 static + 14 dynamic), AST-based taint analysis, live CVE/OSV scanning, a plugin system with OWASP Top 10 + Compliance rule packs, a true graph data model (nodes + edges) for structural code queries, and token-efficient `--format compact` output for high-volume agent workflows (issue #17). ## Features -- **68 CLI Commands** — From basic scan/query to AST taint analysis, CVE scanning, plugin management, auto-fix, dashboards, CI/CD quality gates, and `graph-schema` for cheap graph-shape introspection -- **MCP Server (66 Tools)** — Native AI agent integration via Model Context Protocol (JSON-RPC over stdio), 54 statically-defined tools + 12 dynamically discovered, every tool accepts a `format` parameter (`json`/`markdown`/`ai`/`sarif`/`compact`) +- **70 CLI Commands** — From basic scan/query to AST taint analysis, CVE scanning, plugin management, auto-fix, dashboards, CI/CD quality gates, and `graph-schema` for cheap graph-shape introspection +- **MCP Server (68 Tools)** — Native AI agent integration via Model Context Protocol (JSON-RPC over stdio), 54 statically-defined tools + 14 dynamically discovered, every tool accepts a `format` parameter (`json`/`markdown`/`ai`/`sarif`/`compact`) - **Token-Efficient Compact Output (v8.2, issue #17)** — `--format compact` produces single-char-key JSON with abbreviated types, omitted null fields, and relative paths — ~50% smaller than `json` on real trace output. Combined with `--limit`/`--offset` pagination, 5 structural queries now cost <5k tokens (down from 30-80k) - **AST Taint Engine** — Tree-sitter based taint analysis with return-value propagation, scope hierarchy, and branch condition refinement - **Live CVE/OSV Scanning** — Real-time vulnerability data from OSV.dev API with SQLite cache, 9 ecosystems (PyPI, npm, crates.io, Go, Maven, NuGet, RubyGems, Pub, Hex) @@ -225,8 +225,8 @@ codelens/ │ ├── changelog.md # Older changelog (per-version highlights) │ └── agent-integration.md # AI agent integration guide ├── scripts/ -│ ├── codelens.py # CLI entry point (68 commands registered) -│ ├── mcp_server.py # MCP JSON-RPC server (66 tools) +│ ├── codelens.py # CLI entry point (70 commands registered) +│ ├── mcp_server.py # MCP JSON-RPC server (68 tools) │ ├── registry.py # Registry read/write/build │ ├── persistent_registry.py # SQLite persistent storage (WAL mode) │ ├── base_parser.py # Base tree-sitter parser diff --git a/SKILL-QUICK.md b/SKILL-QUICK.md index 5764ffd7..f7944813 100755 --- a/SKILL-QUICK.md +++ b/SKILL-QUICK.md @@ -114,7 +114,7 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co | "Cross-file taint" | `dataflow` | `taint` (taint is single-file, AST-deep) | | "Auto-fix issues" | `fix` | `check` (check just gates, doesn't fix) | -## All 68 Commands +## All 70 Commands ### Setup & Lifecycle (8+) `init` · `scan [--incremental] [--max-files N] [--full]` · `registry-validate` · `detect` · `watch [--debounce SECS] [--git-mode] [--interval SECS]` · `git-status` · `migrate` · `serve` · `lsp-status` (issue #33: `codelens --lsp-status` top-level flag is an alias of `codelens lsp-status` — both delegate to `hybrid_engine.get_lsp_status()` and return the identical payload) @@ -146,9 +146,9 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co ### Tooling (1) `plugin ` -**Total: 68 commands** (auto-registered via `commands/__init__.py`; rerun `python3 scripts/sync_command_count.py --apply` after adding/removing a command) +**Total: 70 commands** (auto-registered via `commands/__init__.py`; rerun `python3 scripts/sync_command_count.py --apply` after adding/removing a command) -## MCP Server (66 Tools) +## MCP Server (68 Tools) Start the MCP server for AI agent integration: @@ -156,9 +156,9 @@ Start the MCP server for AI agent integration: python3 scripts/codelens.py serve ``` -Exposes 66 tools as `codelens_` (e.g., `codelens_query`, `codelens_taint`, `codelens_graph_schema`, `codelens_architecture`, `codelens_resolve_types`, `codelens_git_status`): +Exposes 68 tools as `codelens_` (e.g., `codelens_query`, `codelens_taint`, `codelens_graph_schema`, `codelens_architecture`, `codelens_resolve_types`, `codelens_git_status`): - 50 statically-defined tools (full JSON schemas in `mcp_server.py`) -- 12 dynamically-discovered tools (auto-discovered from `COMMAND_REGISTRY`; long-running `watch` and `serve` are excluded) +- 14 dynamically-discovered tools (auto-discovered from `COMMAND_REGISTRY`; long-running `watch` and `serve` are excluded) - Every tool accepts a `format` parameter (`json`/`markdown`/`ai`/`sarif`/`compact`). Use `format: "compact"` for token-efficient responses (~50% smaller than `json`). - `watch` and `serve` itself are excluded (long-running) diff --git a/SKILL.md b/SKILL.md index bd91f902..77b8be13 100755 --- a/SKILL.md +++ b/SKILL.md @@ -1,10 +1,10 @@ --- name: codelens description: > - CodeLens — AI-Native Code Intelligence. 68 commands for AI-powered code analysis, + CodeLens — AI-Native Code Intelligence. 70 commands for AI-powered code analysis, security auditing, quality scoring, AST-based taint analysis, live CVE scanning, and pre-write safety checks. Supports 28+ languages with tree-sitter + regex - fallback parsing. MCP server exposes 66 tools for AI agent integration. + fallback parsing. MCP server exposes 68 tools for AI agent integration. For quick command reference with validated output schemas, see SKILL-QUICK.md. For version history, see CHANGELOG.md. --- diff --git a/pyproject.toml b/pyproject.toml index 51b37456..cfb46efa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "codelens" version = "8.2.0" -description = "Live Codebase Reference Intelligence — 68 commands for AI-powered code analysis, security auditing, and quality scoring" +description = "Live Codebase Reference Intelligence — 70 commands for AI-powered code analysis, security auditing, and quality scoring" readme = "README.md" license = {text = "MIT"} requires-python = ">=3.8" diff --git a/scripts/codelens.py b/scripts/codelens.py index 652aaddf..437d3f79 100755 --- a/scripts/codelens.py +++ b/scripts/codelens.py @@ -46,7 +46,6 @@ python3 codelens.py ask [workspace] # Ask a natural language question about the codebase python3 codelens.py migrate # Migrate JSON registry to SQLite python3 codelens.py lsp-status # Check LSP server availability - python3 codelens.py lsp [--rule-file x.yaml] [--tcp --port 2087] # Run as native LSP 3.17 server (issue #48) python3 codelens.py taint # Semantic taint analysis for vulnerability detection python3 codelens.py dashboard # Generate HTML visualization dashboard python3 codelens.py history # Show historical trend data @@ -1278,14 +1277,18 @@ def main(): logger.warning(f"Suppression processing failed: {e}", exc_info=True) # ─── Format and print output ── - # doctor (issue #64) prints its own human-readable text table when - # ``--format text`` is used, and signals via ``_doctor_printed_text`` - # so the dispatcher skips the generic JSON formatter (which would - # otherwise dump the result dict a second time). In ``--format json`` - # mode doctor does NOT print itself, and the normal formatter handles - # the JSON serialization. - if not (args.command == "doctor" and isinstance(result, dict) - and result.get("_doctor_printed_text")): + # Some commands (doctor issue #64 Phase 1, sessions issue #64 + # Phase 2) print their own human-readable output directly and + # signal via ``_doctor_printed_text`` / ``_sessions_printed_text`` + # so the dispatcher skips the generic JSON formatter (which + # would otherwise dump the result dict a second time). In + # ``--format json`` mode these commands do NOT print themselves, + # and the normal formatter handles the JSON serialization. + _already_printed = ( + isinstance(result, dict) + and (result.get("_doctor_printed_text") or result.get("_sessions_printed_text")) + ) + if not (args.command in ("doctor", "sessions") and _already_printed): print(format_output(result, args.format, format_command, workspace)) # ─── Exit codes for CI-quality-gate commands ── diff --git a/scripts/commands/sessions.py b/scripts/commands/sessions.py new file mode 100644 index 00000000..b695d1cc --- /dev/null +++ b/scripts/commands/sessions.py @@ -0,0 +1,407 @@ +"""CodeLens ``sessions`` command — installer session log viewer (issue #64, Phase 2). + +What this command does +----------------------- +``codelens sessions`` reads the install-session log that ``setup.sh`` +appends to on every run, and displays the last N sessions in a +human-readable format (or JSON for programmatic access). + +The session log lives at ``~/.codelens/session.md`` (Markdown) with a +JSON sidecar at ``~/.codelens/session.json`` for structured access. +Each session entry records: + +* Timestamp (ISO 8601, UTC) +* Duration (seconds) +* Python / OS / arch +* Detected agents (Claude Code, Cursor, etc.) with ✓/✗ +* Configured integrations +* Dependencies installed +* Warnings and errors + +This is the "what happened last time I ran setup?" debugging tool — +useful when an install partially fails and the user wants to see +what changed. + +Why a separate command (not just ``cat ~/.codelens/session.md``)? +----------------------------------------------------------------- +* **Filtering** — ``--entries N`` shows the last N sessions without + dumping the whole file. +* **Rotation** — when the log exceeds 1 MB, the oldest sessions are + trimmed automatically (keep last 50). +* **JSON output** — ``--json`` parses the sidecar and emits a + structured array for CI / programmatic consumption. +* **Custom config dir** — ``--config-dir`` lets users inspect a + non-default ``~/.codelens/`` location (useful for debugging + containerized installs). +* **Doctor integration** — ``doctor`` can call ``sessions --json`` + to surface "last install had 2 warnings" in its diagnostic. + +What Phase 2 deliberately does NOT do +------------------------------------- +* It does not parse legacy install logs from before this feature + shipped (those entries simply don't exist). +* It does not sync sessions across machines (that's a future + cloud-sync feature, out of scope). +* It does not write sessions — only ``setup.sh`` writes them. This + command is read-only. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from commands import register_command + +# ─── Constants ───────────────────────────────────────────────── + +# Default location of the session log. Override per-call with +# ``--config-dir``. +DEFAULT_CONFIG_DIR = os.path.expanduser("~/.codelens") +SESSION_MD_FILENAME = "session.md" +SESSION_JSON_FILENAME = "session.json" + +# Rotation thresholds — when the JSON sidecar exceeds this size, +# trim to the most recent N entries. +MAX_LOG_SIZE_BYTES = 1 * 1024 * 1024 # 1 MB +MAX_SESSIONS_AFTER_ROTATION = 50 + +# Default number of sessions to display. +DEFAULT_ENTRIES = 5 + + +# ─── Session log paths ───────────────────────────────────────── + + +def _session_paths(config_dir: Optional[str]) -> tuple[str, str]: + """Return ``(md_path, json_path)`` for the given config dir. + + Falls back to :data:`DEFAULT_CONFIG_DIR` when ``config_dir`` is + None or empty. + """ + base = config_dir or DEFAULT_CONFIG_DIR + return ( + os.path.join(base, SESSION_MD_FILENAME), + os.path.join(base, SESSION_JSON_FILENAME), + ) + + +# ─── Session log reading ─────────────────────────────────────── + + +def _load_json_sessions(json_path: str) -> List[Dict[str, Any]]: + """Load the JSON sidecar and return the sessions list. + + Returns an empty list if the file doesn't exist, is empty, or + fails to parse. A corrupted sidecar must NOT crash the command — + we degrade gracefully to "no sessions found" and let the user + inspect the raw Markdown file with ``--raw``. + """ + if not os.path.exists(json_path): + return [] + try: + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return data + if isinstance(data, dict) and "sessions" in data: + # Future-proof: support a wrapped schema where the + # top-level object has a ``sessions`` key. + sessions = data["sessions"] + return sessions if isinstance(sessions, list) else [] + return [] + except (OSError, json.JSONDecodeError): + return [] + + +def _parse_md_sessions(md_path: str) -> List[Dict[str, Any]]: + """Parse the Markdown log into a list of session dicts. + + Each session in the Markdown log is delimited by a level-2 + heading (``##``). The heading line contains the ISO 8601 + timestamp. Subsequent lines until the next ``##`` are the + session body (key-value pairs in ``- **key**: value`` format). + + This is a best-effort parser — the JSON sidecar is the source of + truth; the Markdown is for human reading. We only fall back to + parsing the Markdown when the sidecar is missing or empty. + """ + if not os.path.exists(md_path): + return [] + try: + with open(md_path, "r", encoding="utf-8") as f: + content = f.read() + except OSError: + return [] + + sessions: List[Dict[str, Any]] = [] + current: Optional[Dict[str, Any]] = None + current_body: List[str] = [] + + for line in content.splitlines(): + # A level-2 heading starts a new session. + if line.startswith("## "): + # Flush the previous session. + if current is not None: + current["body"] = "\n".join(current_body).strip() + sessions.append(current) + # Parse the heading: ``## 2026-06-28T09:14:31Z — setup`` + heading = line[3:].strip() + # Split on em-dash if present (the convention from setup.sh). + parts = re.split(r"\s+[—-]\s+", heading, maxsplit=1) + timestamp_str = parts[0].strip() + title = parts[1].strip() if len(parts) > 1 else "session" + current = { + "timestamp": timestamp_str, + "title": title, + "raw_heading": heading, + } + current_body = [] + elif current is not None: + current_body.append(line) + + # Flush the last session. + if current is not None: + current["body"] = "\n".join(current_body).strip() + sessions.append(current) + + return sessions + + +def _load_sessions(config_dir: Optional[str]) -> List[Dict[str, Any]]: + """Load sessions, preferring the JSON sidecar. + + Falls back to parsing the Markdown log if the sidecar is missing + or empty. Returns an empty list if neither exists. + """ + md_path, json_path = _session_paths(config_dir) + sessions = _load_json_sessions(json_path) + if sessions: + return sessions + return _parse_md_sessions(md_path) + + +# ─── Rotation ────────────────────────────────────────────────── + + +def _maybe_rotate(config_dir: Optional[str]) -> bool: + """Trim the session log if it exceeds :data:`MAX_LOG_SIZE_BYTES`. + + Returns ``True`` if rotation happened, ``False`` otherwise. + Rotation keeps the most recent :data:`MAX_SESSIONS_AFTER_ROTATION` + sessions in both the JSON sidecar and the Markdown log. + + This is called automatically on every ``sessions`` invocation — + no need for a separate cron job. + """ + md_path, json_path = _session_paths(config_dir) + # Check the JSON sidecar size (the smaller of the two; if it's + # over the threshold, the Markdown is definitely over too). + try: + json_size = os.path.getsize(json_path) if os.path.exists(json_path) else 0 + except OSError: + json_size = 0 + if json_size < MAX_LOG_SIZE_BYTES: + return False + + sessions = _load_json_sessions(json_path) + if len(sessions) <= MAX_SESSIONS_AFTER_ROTATION: + return False + + # Keep the most recent N sessions. Sessions are appended in + # chronological order, so the most recent are at the end. + trimmed = sessions[-MAX_SESSIONS_AFTER_ROTATION:] + try: + with open(json_path, "w", encoding="utf-8") as f: + json.dump(trimmed, f, indent=2, ensure_ascii=False) + except OSError: + return False + + # Rewrite the Markdown log with only the trimmed sessions. + # We don't try to reconstruct the original Markdown formatting + # exactly — we just emit a fresh, valid log from the JSON data. + try: + with open(md_path, "w", encoding="utf-8") as f: + f.write("# CodeLens install sessions\n\n") + for s in trimmed: + ts = s.get("timestamp", "unknown") + title = s.get("title", "session") + f.write(f"## {ts} — {title}\n\n") + body = s.get("body") + if body: + f.write(body + "\n\n") + else: + # Emit structured fields if no body. + for k, v in s.items(): + if k in ("timestamp", "title", "raw_heading", "body"): + continue + f.write(f"- **{k}**: {v}\n") + f.write("\n") + except OSError: + pass + return True + + +# ─── Output formatting ───────────────────────────────────────── + + +def _format_text(sessions: List[Dict[str, Any]], entries: int) -> str: + """Format the last N sessions as a human-readable text report.""" + if not sessions: + return "No install sessions found. Run `bash setup.sh` to record one." + # Take the last N sessions (most recent first for display). + recent = sessions[-entries:] if entries > 0 else sessions + recent = list(reversed(recent)) # most recent first + + lines: List[str] = [] + lines.append(f"CodeLens sessions — showing {len(recent)} of {len(sessions)} total") + lines.append("=" * 60) + for i, s in enumerate(recent, 1): + ts = s.get("timestamp", "?") + title = s.get("title", "session") + lines.append(f"\n[{i}] {ts} — {title}") + # Show structured fields if present. + for key in ("duration_sec", "python", "os", "arch", "agents_detected", + "integrations_configured", "deps_installed", "warnings", "errors"): + if key in s: + val = s[key] + if isinstance(val, (list, dict)): + val = json.dumps(val, ensure_ascii=False) + lines.append(f" {key}: {val}") + # If there's a body (from MD parsing), show a truncated version. + body = s.get("body") + if body and not any(k in s for k in ("duration_sec", "python")): + # No structured fields — show first 5 lines of body. + body_lines = body.splitlines()[:5] + for bl in body_lines: + if bl.strip(): + lines.append(f" {bl}") + if len(body.splitlines()) > 5: + lines.append(f" ... ({len(body.splitlines()) - 5} more lines)") + lines.append("\n" + "=" * 60) + lines.append(f"Total sessions logged: {len(sessions)}") + return "\n".join(lines) + + +def _format_raw(md_path: str) -> str: + """Return the raw Markdown log content verbatim.""" + if not os.path.exists(md_path): + return f"Session log not found at {md_path}" + try: + with open(md_path, "r", encoding="utf-8") as f: + return f.read() + except OSError as exc: + return f"Failed to read {md_path}: {exc}" + + +# ─── CLI plumbing ────────────────────────────────────────────── + + +def add_args(parser): + """Register sessions-specific arguments.""" + parser.add_argument( + "workspace", + nargs="?", + default=None, + help="Ignored (sessions is global). Accepted for CLI consistency.", + ) + parser.add_argument( + "--entries", + type=int, + default=DEFAULT_ENTRIES, + help=f"Number of recent sessions to display (default: {DEFAULT_ENTRIES}). Use 0 for all.", + ) + parser.add_argument( + "--raw", + action="store_true", + default=False, + help="Print the raw Markdown log verbatim (no formatting).", + ) + parser.add_argument( + "--config-dir", + default=None, + help=f"Custom config dir (default: {DEFAULT_CONFIG_DIR}).", + ) + parser.add_argument( + "--json", + dest="json_output", + action="store_true", + default=False, + help="Output as JSON array (for programmatic access).", + ) + + +def execute(args, workspace): + """Read the session log, optionally rotate, return result dict. + + The result always includes: + + * ``status`` — "ok" | "error" + * ``config_dir`` — resolved config dir + * ``total_sessions`` — count of sessions in the log + * ``returned_sessions`` — count actually returned (after ``--entries``) + * ``sessions`` — list of session dicts (the data) + * ``rotated`` — bool, whether rotation happened during this call + * ``raw`` — the raw Markdown content (only if ``--raw``) + """ + config_dir = getattr(args, "config_dir", None) or DEFAULT_CONFIG_DIR + entries = getattr(args, "entries", DEFAULT_ENTRIES) + raw_mode = bool(getattr(args, "raw", False)) + json_mode = bool(getattr(args, "json_output", False)) + + md_path, json_path = _session_paths(config_dir) + + # Rotate if needed (side effect — but safe and idempotent). + rotated = _maybe_rotate(config_dir) + + sessions = _load_sessions(config_dir) + + # Apply --entries limit (0 = all). + if entries > 0: + display = sessions[-entries:] + else: + display = sessions + + result: Dict[str, Any] = { + "status": "ok", + "config_dir": config_dir, + "md_path": md_path, + "json_path": json_path, + "total_sessions": len(sessions), + "returned_sessions": len(display), + "sessions": display, + "rotated": rotated, + } + + if raw_mode: + result["raw"] = _format_raw(md_path) + # In raw mode, print the raw content directly and signal to + # the dispatcher that we've already printed. + print(result["raw"]) + result["_sessions_printed_text"] = True + elif json_mode: + # In JSON mode, print just the sessions array (so ``jq`` etc. + # can pipe it cleanly). The full result dict is still + # returned for the dispatcher, but we override the printed + # output here. + print(json.dumps(display, indent=2, ensure_ascii=False)) + result["_sessions_printed_text"] = True + else: + # Default text mode — print the human-readable report. + print(_format_text(sessions, entries)) + result["_sessions_printed_text"] = True + + return result + + +register_command( + "sessions", + "View recent install sessions (from setup.sh session log)", + add_args, + execute, +) diff --git a/scripts/graph_model.py b/scripts/graph_model.py index 1790fb7a..a3cc6a08 100644 --- a/scripts/graph_model.py +++ b/scripts/graph_model.py @@ -13,7 +13,7 @@ - New tables `graph_nodes` and `graph_edges` are additive (prefixed `graph_` to avoid colliding with any existing table name). - The flat registry tables and JSON files are untouched. -- All 68 existing CLI commands continue to work unchanged. +- All 70 existing CLI commands continue to work unchanged. Schema: graph_nodes( diff --git a/setup.sh b/setup.sh index d10498b9..c8c5ad41 100755 --- a/setup.sh +++ b/setup.sh @@ -1,14 +1,54 @@ #!/usr/bin/env bash # CodeLens v2 Setup Script — Tree-sitter Edition # Installs required Python dependencies including tree-sitter grammars +# +# Issue #64 Phase 2: appends a Markdown + JSON session log entry to +# ``~/.codelens/session.md`` and ``~/.codelens/session.json`` on every +# run. View with ``codelens sessions``. Rotation is handled by the +# ``sessions`` command (keeps last 50 when log exceeds 1 MB). set -e +# ─── Session log setup (issue #64 Phase 2) ──────────────────── +# Capture start time and metadata BEFORE any work, so we can record +# duration and partial-failure state at the end. +SESSION_START_EPOCH=$(date +%s) +SESSION_START_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +SESSION_PYTHON_VERSION="" +SESSION_OS="" +SESSION_ARCH="" +SESSION_WARNINGS="" +SESSION_ERRORS="" +SESSION_DEPS_INSTALLED="" + +# Detect Python version early (we need it for the session record). +if command -v python3 &> /dev/null; then + SESSION_PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')" 2>/dev/null || echo "unknown") +fi +SESSION_OS=$(uname -s 2>/dev/null || echo "unknown") +SESSION_ARCH=$(uname -m 2>/dev/null || echo "unknown") + +# CodeLens config dir — matches DEFAULT_CONFIG_DIR in +# scripts/commands/sessions.py. Override via CODELENS_CONFIG_DIR env +# var (useful for testing). +CODELENS_CONFIG_DIR="${CODELENS_CONFIG_DIR:-$HOME/.codelens}" +mkdir -p "$CODELENS_CONFIG_DIR" +SESSION_MD="$CODELENS_CONFIG_DIR/session.md" +SESSION_JSON="$CODELENS_CONFIG_DIR/session.json" + +# Initialize the Markdown log with a header if it doesn't exist yet. +if [ ! -f "$SESSION_MD" ]; then + echo "# CodeLens install sessions" > "$SESSION_MD" + echo "" >> "$SESSION_MD" +fi + echo "[CodeLens] Setting up dependencies (Tree-sitter Edition)..." # Check Python 3 if ! command -v python3 &> /dev/null; then echo "[CodeLens] ERROR: python3 not found. Please install Python 3.8+." + SESSION_ERRORS="python3 not found" + _codelens_write_session_log 1 exit 1 fi @@ -17,7 +57,11 @@ echo "[CodeLens] Python version: $PYTHON_VERSION" # Install core tree-sitter echo "[CodeLens] Installing tree-sitter core..." -pip3 install tree-sitter --quiet 2>/dev/null || pip install tree-sitter --quiet 2>/dev/null +if pip3 install tree-sitter --quiet 2>/dev/null || pip install tree-sitter --quiet 2>/dev/null; then + SESSION_DEPS_INSTALLED="${SESSION_DEPS_INSTALLED}tree-sitter " +else + SESSION_WARNINGS="${SESSION_WARNINGS}tree-sitter install failed; " +fi # Install grammar packages echo "[CodeLens] Installing tree-sitter grammars..." @@ -32,29 +76,48 @@ GRAMMARS=( for grammar in "${GRAMMARS[@]}"; do echo "[CodeLens] Installing $grammar..." - pip3 install "$grammar" --quiet 2>/dev/null || pip install "$grammar" --quiet 2>/dev/null || echo "[CodeLens] Warning: $grammar install failed." + if pip3 install "$grammar" --quiet 2>/dev/null || pip install "$grammar" --quiet 2>/dev/null; then + SESSION_DEPS_INSTALLED="${SESSION_DEPS_INSTALLED}${grammar} " + else + echo "[CodeLens] Warning: $grammar install failed." + SESSION_WARNINGS="${SESSION_WARNINGS}${grammar} install failed; " + fi done # Install watchdog for file watching (optional but recommended) echo "[CodeLens] Installing watchdog (for real-time file watching)..." -pip3 install watchdog --quiet 2>/dev/null || pip install watchdog --quiet 2>/dev/null || echo "[CodeLens] Warning: watchdog install failed. File watcher will use polling mode." +if pip3 install watchdog --quiet 2>/dev/null || pip install watchdog --quiet 2>/dev/null; then + SESSION_DEPS_INSTALLED="${SESSION_DEPS_INSTALLED}watchdog " +else + echo "[CodeLens] Warning: watchdog install failed. File watcher will use polling mode." + SESSION_WARNINGS="${SESSION_WARNINGS}watchdog install failed; " +fi # Verify core modules work echo "[CodeLens] Verifying codelens CLI..." SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -python3 "$SCRIPT_DIR/scripts/codelens.py" --help +if ! python3 "$SCRIPT_DIR/scripts/codelens.py" --help > /dev/null 2>&1; then + SESSION_ERRORS="${SESSION_ERRORS}codelens CLI verification failed; " +fi # Test tree-sitter grammars echo "[CodeLens] Testing tree-sitter grammars..." SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PYTHONPATH="$SCRIPT_DIR/scripts:$PYTHONPATH" python3 -c " +GRAMMAR_TEST_OUTPUT=$(PYTHONPATH="$SCRIPT_DIR/scripts:$PYTHONPATH" python3 -c " from grammar_loader import GrammarLoader loader = GrammarLoader() available = loader.available_languages() print(f' Available grammars: {available}') if len(available) < 3: print(' WARNING: Some grammars failed to load.') -" +" 2>&1) || SESSION_WARNINGS="${SESSION_WARNINGS}grammar test failed; " +echo "$GRAMMAR_TEST_OUTPUT" + +# Detect configured AI agent integrations (best-effort, non-fatal). +AGENTS_DETECTED="" +[ -d "$HOME/.claude" ] && AGENTS_DETECTED="${AGENTS_DETECTED}claude-code " +[ -d "$HOME/.cursor" ] && AGENTS_DETECTED="${AGENTS_DETECTED}cursor " +[ -d "$HOME/.continue" ] && AGENTS_DETECTED="${AGENTS_DETECTED}continue " echo "" echo "[CodeLens] Setup complete!" @@ -66,3 +129,77 @@ echo " python3 $SCRIPT_DIR/scripts/codelens.py init /path/to/workspace" echo " python3 $SCRIPT_DIR/scripts/codelens.py scan /path/to/workspace" echo " python3 $SCRIPT_DIR/scripts/codelens.py query 'btn-primary' /path/to/workspace" echo " python3 $SCRIPT_DIR/scripts/codelens.py list /path/to/workspace --filter dead" + +# ─── Write session log entry (issue #64 Phase 2) ────────────── +# Define a helper function so we can call it from the error path +# above (python3-not-found) AND from the normal-success path here. +# The function uses ``date`` to compute duration and writes to both +# the Markdown log and the JSON sidecar. +_codelens_write_session_log() { + local exit_code="$1" + local end_epoch=$(date +%s) + local duration=$((end_epoch - SESSION_START_EPOCH)) + + # Trim trailing whitespace from accumulated strings. + local deps=$(echo "$SESSION_DEPS_INSTALLED" | xargs) + local warns=$(echo "$SESSION_WARNINGS" | xargs) + local errs=$(echo "$SESSION_ERRORS" | xargs) + local agents=$(echo "$AGENTS_DETECTED" | xargs) + + # Append to Markdown log. + { + echo "## ${SESSION_START_ISO} — setup" + echo "" + echo "- **duration_sec**: ${duration}" + echo "- **exit_code**: ${exit_code}" + echo "- **python**: ${SESSION_PYTHON_VERSION}" + echo "- **os**: ${SESSION_OS}" + echo "- **arch**: ${SESSION_ARCH}" + echo "- **agents_detected**: ${agents}" + echo "- **deps_installed**: ${deps}" + [ -n "$warns" ] && echo "- **warnings**: ${warns}" + [ -n "$errs" ] && echo "- **errors**: ${errs}" + echo "" + } >> "$SESSION_MD" + + # Append to JSON sidecar. We use Python here because bash doesn't + # have native JSON support, and we want the sidecar to be a valid + # JSON array (not JSONL). Python is guaranteed to be available + # at this point — we checked at the top of the script. + if [ -n "$SESSION_PYTHON_VERSION" ] && [ "$SESSION_PYTHON_VERSION" != "unknown" ]; then + python3 - "$SESSION_JSON" "$SESSION_START_ISO" "$duration" "$exit_code" \ + "$SESSION_PYTHON_VERSION" "$SESSION_OS" "$SESSION_ARCH" \ + "$agents" "$deps" "$warns" "$errs" <<'PYEOF' 2>/dev/null || true +import json, os, sys +path = sys.argv[1] +entry = { + "timestamp": sys.argv[2], + "duration_sec": int(sys.argv[3]), + "exit_code": int(sys.argv[4]), + "python": sys.argv[5], + "os": sys.argv[6], + "arch": sys.argv[7], + "agents_detected": sys.argv[8].split() if sys.argv[8] else [], + "deps_installed": sys.argv[9].split() if sys.argv[9] else [], + "warnings": sys.argv[10] if sys.argv[10] else None, + "errors": sys.argv[11] if sys.argv[11] else None, + "title": "setup", +} +# Load existing sessions (if any), append, write back. +sessions = [] +if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + sessions = data + except (OSError, json.JSONDecodeError): + pass +sessions.append(entry) +with open(path, "w", encoding="utf-8") as f: + json.dump(sessions, f, indent=2, ensure_ascii=False) +PYEOF + fi +} + +_codelens_write_session_log 0 diff --git a/skill.json b/skill.json index 20d1a64d..76595063 100755 --- a/skill.json +++ b/skill.json @@ -1,7 +1,7 @@ { "name": "codelens", "version": "8.2.0", - "description": "Live Codebase Reference Intelligence. 68 commands for AI-powered code analysis, security auditing, quality scoring, and pre-write safety checks. Supports 28+ languages with regex+AST hybrid parsing. Must activate before writing/editing/deleting any class, id, or function.", + "description": "Live Codebase Reference Intelligence. 70 commands for AI-powered code analysis, security auditing, quality scoring, and pre-write safety checks. Supports 28+ languages with regex+AST hybrid parsing. Must activate before writing/editing/deleting any class, id, or function.", "author": "codelens", "command_categories": { "setup": [ diff --git a/tests/test_sessions.py b/tests/test_sessions.py new file mode 100644 index 00000000..e089df89 --- /dev/null +++ b/tests/test_sessions.py @@ -0,0 +1,412 @@ +"""Tests for the ``codelens sessions`` command (issue #64, Phase 2). + +Covers: + +* Session log reading from both JSON sidecar and Markdown fallback. +* ``--entries N`` filtering (last N sessions, most-recent-first). +* ``--json`` machine-readable output. +* ``--raw`` verbatim Markdown output. +* ``--config-dir`` custom location (for test isolation). +* Rotation: when the JSON sidecar exceeds 1 MB, trim to last 50. +* ``setup.sh`` integration: running setup.sh appends a session entry + to both ``session.md`` and ``session.json``. + +The tests use ``tmp_path`` + ``--config-dir`` for isolation — they +do NOT touch the user's real ``~/.codelens/`` directory. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from datetime import datetime, timezone +from typing import Dict, List + +import pytest + +SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "scripts", +) +if SCRIPTS_DIR not in sys.path: + sys.path.insert(0, SCRIPTS_DIR) + +from commands import COMMAND_REGISTRY # noqa: E402 +from commands import sessions as sessions_module # noqa: E402 + + +# ─── Registration ────────────────────────────────────────────── + + +def test_sessions_is_registered(): + """sessions must be in the runtime COMMAND_REGISTRY.""" + assert "sessions" in COMMAND_REGISTRY + info = COMMAND_REGISTRY["sessions"] + assert "session" in info["help"].lower() + + +# ─── Helpers ─────────────────────────────────────────────────── + + +def _make_session(idx: int, **overrides) -> Dict: + """Build a synthetic session dict for test fixtures.""" + base = { + "timestamp": f"2026-06-{28 + idx:02d}T09:14:3{idx}Z", + "duration_sec": 10 + idx, + "exit_code": 0, + "python": "3.12.0", + "os": "Linux", + "arch": "x86_64", + "agents_detected": ["claude-code"] if idx % 2 == 0 else [], + "deps_installed": ["tree-sitter", "tree-sitter-python"], + "warnings": None if idx % 3 != 0 else f"warning on session {idx}", + "errors": None, + "title": "setup", + } + base.update(overrides) + return base + + +def _write_sessions(config_dir: str, sessions: List[Dict]) -> None: + """Write a list of sessions to the JSON sidecar (and MD log).""" + os.makedirs(config_dir, exist_ok=True) + json_path = os.path.join(config_dir, sessions_module.SESSION_JSON_FILENAME) + md_path = os.path.join(config_dir, sessions_module.SESSION_MD_FILENAME) + with open(json_path, "w", encoding="utf-8") as f: + json.dump(sessions, f, indent=2, ensure_ascii=False) + # Write a minimal MD log too so --raw has something to show. + with open(md_path, "w", encoding="utf-8") as f: + f.write("# CodeLens install sessions\n\n") + for s in sessions: + f.write(f"## {s['timestamp']} — {s.get('title', 'setup')}\n\n") + for k, v in s.items(): + if k in ("timestamp", "title"): + continue + f.write(f"- **{k}**: {v}\n") + f.write("\n") + + +def _run_sessions_cmd(config_dir: str, *extra_args) -> Dict: + """Invoke sessions.execute() with a synthetic args namespace.""" + args = type("Args", (), {})() + args.workspace = None + args.entries = sessions_module.DEFAULT_ENTRIES + args.raw = False + args.json_output = False + args.config_dir = config_dir + for i, a in enumerate(extra_args): + if a == "--entries": + args.entries = int(extra_args[i + 1]) + elif a == "--raw": + args.raw = True + elif a == "--json": + args.json_output = True + elif a == "--config-dir": + args.config_dir = extra_args[i + 1] + return sessions_module.execute(args, "") + + +# ─── Reading sessions ────────────────────────────────────────── + + +class TestReadSessions: + """Verify the JSON sidecar is the preferred read source.""" + + def test_empty_config_dir_returns_empty_list(self, tmp_path): + result = _run_sessions_cmd(str(tmp_path)) + assert result["status"] == "ok" + assert result["total_sessions"] == 0 + assert result["returned_sessions"] == 0 + assert result["sessions"] == [] + + def test_reads_from_json_sidecar(self, tmp_path): + sessions = [_make_session(0), _make_session(1)] + _write_sessions(str(tmp_path), sessions) + result = _run_sessions_cmd(str(tmp_path)) + assert result["total_sessions"] == 2 + assert result["returned_sessions"] == 2 + + def test_falls_back_to_md_when_json_missing(self, tmp_path): + """If the JSON sidecar is missing/empty, parse the Markdown log.""" + md_path = os.path.join(str(tmp_path), sessions_module.SESSION_MD_FILENAME) + with open(md_path, "w", encoding="utf-8") as f: + f.write("# CodeLens install sessions\n\n") + f.write("## 2026-06-28T09:14:31Z — setup\n\n") + f.write("- **duration_sec**: 42\n") + f.write("- **python**: 3.12.0\n\n") + result = _run_sessions_cmd(str(tmp_path)) + assert result["status"] == "ok" + # MD parsing is best-effort — should at least find 1 session. + assert result["total_sessions"] >= 1 + + def test_falls_back_to_md_when_json_corrupt(self, tmp_path): + """A corrupt JSON sidecar should not crash — fall back to MD.""" + json_path = os.path.join(str(tmp_path), sessions_module.SESSION_JSON_FILENAME) + with open(json_path, "w", encoding="utf-8") as f: + f.write("{not valid json") + md_path = os.path.join(str(tmp_path), sessions_module.SESSION_MD_FILENAME) + with open(md_path, "w", encoding="utf-8") as f: + f.write("# CodeLens install sessions\n\n") + f.write("## 2026-06-28T09:14:31Z — setup\n\n") + result = _run_sessions_cmd(str(tmp_path)) + assert result["status"] == "ok" + + def test_md_only_no_sessions_returns_empty(self, tmp_path): + """If neither file exists, return empty — don't crash.""" + result = _run_sessions_cmd(str(tmp_path)) + assert result["total_sessions"] == 0 + + +# ─── --entries filtering ─────────────────────────────────────── + + +class TestEntriesFilter: + """``--entries N`` shows only the last N sessions.""" + + def test_entries_limits_to_last_n(self, tmp_path): + sessions = [_make_session(i) for i in range(10)] + _write_sessions(str(tmp_path), sessions) + result = _run_sessions_cmd(str(tmp_path), "--entries", "3") + # total still 10, but only 3 returned + assert result["total_sessions"] == 10 + assert result["returned_sessions"] == 3 + # The 3 returned should be the LAST 3 (most recent). + returned_timestamps = [s["timestamp"] for s in result["sessions"]] + assert returned_timestamps == [sessions[7]["timestamp"], + sessions[8]["timestamp"], + sessions[9]["timestamp"]] + + def test_entries_zero_returns_all(self, tmp_path): + """``--entries 0`` means "all sessions".""" + sessions = [_make_session(i) for i in range(7)] + _write_sessions(str(tmp_path), sessions) + result = _run_sessions_cmd(str(tmp_path), "--entries", "0") + assert result["returned_sessions"] == 7 + + def test_entries_larger_than_total_returns_all(self, tmp_path): + sessions = [_make_session(0), _make_session(1)] + _write_sessions(str(tmp_path), sessions) + result = _run_sessions_cmd(str(tmp_path), "--entries", "100") + assert result["returned_sessions"] == 2 + + def test_default_entries_is_5(self, tmp_path): + """Without --entries, the default is 5.""" + sessions = [_make_session(i) for i in range(10)] + _write_sessions(str(tmp_path), sessions) + result = _run_sessions_cmd(str(tmp_path)) + assert result["returned_sessions"] == 5 + + +# ─── --json output ───────────────────────────────────────────── + + +class TestJsonOutput: + """``--json`` produces a valid JSON array on stdout.""" + + def test_json_output_is_valid_array(self, tmp_path, capsys): + sessions = [_make_session(0), _make_session(1)] + _write_sessions(str(tmp_path), sessions) + _run_sessions_cmd(str(tmp_path), "--json") + captured = capsys.readouterr() + # The printed output should be a valid JSON array. + # Strip any stderr noise from stdout. + out = captured.out.strip() + data = json.loads(out) + assert isinstance(data, list) + assert len(data) == 2 + assert data[0]["timestamp"] == sessions[0]["timestamp"] + + def test_json_output_respects_entries(self, tmp_path, capsys): + sessions = [_make_session(i) for i in range(8)] + _write_sessions(str(tmp_path), sessions) + _run_sessions_cmd(str(tmp_path), "--json", "--entries", "2") + captured = capsys.readouterr() + data = json.loads(captured.out.strip()) + assert len(data) == 2 + + +# ─── --raw output ────────────────────────────────────────────── + + +class TestRawOutput: + """``--raw`` prints the Markdown log verbatim.""" + + def test_raw_prints_md_content(self, tmp_path, capsys): + sessions = [_make_session(0)] + _write_sessions(str(tmp_path), sessions) + _run_sessions_cmd(str(tmp_path), "--raw") + captured = capsys.readouterr() + # The raw MD should contain the heading we wrote. + assert "# CodeLens install sessions" in captured.out + assert sessions[0]["timestamp"] in captured.out + + def test_raw_when_no_log_prints_not_found_message(self, tmp_path, capsys): + _run_sessions_cmd(str(tmp_path), "--raw") + captured = capsys.readouterr() + assert "not found" in captured.out.lower() or "no install sessions" in captured.out.lower() + + +# ─── Rotation ────────────────────────────────────────────────── + + +class TestRotation: + """When the JSON sidecar exceeds 1 MB, trim to last 50 sessions.""" + + def test_no_rotation_under_threshold(self, tmp_path): + sessions = [_make_session(i) for i in range(10)] + _write_sessions(str(tmp_path), sessions) + result = _run_sessions_cmd(str(tmp_path)) + assert result["rotated"] is False + assert result["total_sessions"] == 10 + + def test_rotation_when_over_threshold(self, tmp_path): + """Force the JSON sidecar over 1 MB by making sessions large.""" + # Build 100 sessions with big bodies to exceed 1 MB. + big_body = "x" * 20_000 # 20 KB per session → 100 sessions = ~2 MB + sessions = [] + for i in range(100): + s = _make_session(i) + s["body"] = big_body + sessions.append(s) + _write_sessions(str(tmp_path), sessions) + # Call with --entries 0 so all (post-rotation) sessions are + # returned — otherwise the default --entries 5 would limit + # the result and we couldn't verify the rotation count. + result = _run_sessions_cmd(str(tmp_path), "--entries", "0") + assert result["rotated"] is True + # After rotation, only 50 sessions remain. + assert result["total_sessions"] == 50 + assert result["returned_sessions"] == 50 + # The 50 kept should be the most recent (indices 50-99). + kept_timestamps = [s["timestamp"] for s in result["sessions"]] + # Take the last 50 of the original (indices 50-99). + expected_last_50 = sessions[-50:] + expected_timestamps = [s["timestamp"] for s in expected_last_50] + assert sorted(kept_timestamps) == sorted(expected_timestamps) + + def test_rotation_is_idempotent(self, tmp_path): + """A second call after rotation should not rotate again.""" + big_body = "x" * 20_000 + sessions = [_make_session(i) for i in range(100)] + for s in sessions: + s["body"] = big_body + _write_sessions(str(tmp_path), sessions) + first = _run_sessions_cmd(str(tmp_path)) + assert first["rotated"] is True + # Second call — already trimmed, under threshold. + second = _run_sessions_cmd(str(tmp_path)) + assert second["rotated"] is False + + +# ─── setup.sh integration ────────────────────────────────────── + + +class TestSetupShIntegration: + """End-to-end: running setup.sh appends a session entry.""" + + def test_setup_sh_appends_to_session_md(self, tmp_path): + """After running setup.sh, ``session.md`` should have a new entry.""" + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + env = {**os.environ, "CODELENS_CONFIG_DIR": str(tmp_path)} + result = subprocess.run( + ["bash", os.path.join(repo_root, "setup.sh")], + capture_output=True, text=True, env=env, timeout=120, + ) + # setup.sh may fail on missing tree-sitter (test env), but it + # should still have written a session entry. The exit code + # reflects whether the install succeeded, not whether the log + # was written. + md_path = os.path.join(str(tmp_path), sessions_module.SESSION_MD_FILENAME) + assert os.path.exists(md_path), ( + f"session.md not created.\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + content = open(md_path, encoding="utf-8").read() + assert "## " in content # at least one session heading + assert "duration_sec" in content + + def test_setup_sh_appends_to_session_json(self, tmp_path): + """After running setup.sh, ``session.json`` should be a valid JSON array.""" + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + env = {**os.environ, "CODELENS_CONFIG_DIR": str(tmp_path)} + subprocess.run( + ["bash", os.path.join(repo_root, "setup.sh")], + capture_output=True, text=True, env=env, timeout=120, + ) + json_path = os.path.join(str(tmp_path), sessions_module.SESSION_JSON_FILENAME) + assert os.path.exists(json_path) + with open(json_path, encoding="utf-8") as f: + data = json.load(f) + assert isinstance(data, list) + assert len(data) >= 1 + entry = data[-1] + # Every entry should have these required fields. + for key in ("timestamp", "duration_sec", "exit_code", "python", "os", "arch"): + assert key in entry, f"missing key in session entry: {key}" + + def test_multiple_setup_runs_append_multiple_sessions(self, tmp_path): + """Running setup.sh twice should produce 2 session entries.""" + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + env = {**os.environ, "CODELENS_CONFIG_DIR": str(tmp_path)} + for _ in range(2): + subprocess.run( + ["bash", os.path.join(repo_root, "setup.sh")], + capture_output=True, text=True, env=env, timeout=120, + ) + json_path = os.path.join(str(tmp_path), sessions_module.SESSION_JSON_FILENAME) + with open(json_path, encoding="utf-8") as f: + data = json.load(f) + assert len(data) == 2 + + +# ─── CLI smoke test ──────────────────────────────────────────── + + +class TestCLISmoke: + """End-to-end: invoke ``codelens sessions`` as a real subprocess.""" + + def _run_cli(self, *extra_args): + env = os.environ.copy() + env["PYTHONPATH"] = SCRIPTS_DIR + return subprocess.run( + [sys.executable, os.path.join(SCRIPTS_DIR, "codelens.py"), "sessions", *extra_args], + capture_output=True, text=True, env=env, timeout=30, + ) + + def test_sessions_cli_runs_without_crash(self, tmp_path): + result = self._run_cli("--config-dir", str(tmp_path)) + assert result.returncode == 0 + assert "sessions" in result.stdout.lower() + + def test_sessions_cli_with_existing_log(self, tmp_path): + sessions = [_make_session(0)] + _write_sessions(str(tmp_path), sessions) + result = self._run_cli("--config-dir", str(tmp_path)) + assert result.returncode == 0 + assert "showing 1 of 1" in result.stdout + + def test_sessions_cli_json_mode(self, tmp_path): + sessions = [_make_session(0)] + _write_sessions(str(tmp_path), sessions) + result = self._run_cli("--config-dir", str(tmp_path), "--json") + assert result.returncode == 0 + data = json.loads(result.stdout.strip()) + assert isinstance(data, list) + assert len(data) == 1 + + +# ─── Default config dir ──────────────────────────────────────── + + +class TestDefaultConfigDir: + """When --config-dir is not passed, defaults to ~/.codelens.""" + + def test_default_config_dir_used_when_not_specified(self): + """The result dict should report the default config dir.""" + # Don't actually run the command against the real ~/.codelens — + # just verify the default is picked up correctly by setting + # an env override (sessions.py reads DEFAULT_CONFIG_DIR at + # module import, so we test the constant directly). + assert sessions_module.DEFAULT_CONFIG_DIR.endswith(".codelens") From 34d0d31ee9aa7ec494d4ac0c6444382d8aa815a0 Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Thu, 2 Jul 2026 18:36:36 +0700 Subject: [PATCH 2/2] fix(ci): add lsprotocol to test suite install step --- .github/workflows/codelens-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codelens-ci.yml b/.github/workflows/codelens-ci.yml index 31b1da04..500d5141 100644 --- a/.github/workflows/codelens-ci.yml +++ b/.github/workflows/codelens-ci.yml @@ -33,7 +33,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install tree-sitter pyyaml pytest pytest-cov + pip install tree-sitter pyyaml pytest pytest-cov pygls lsprotocol # Install optional grammar packages for full parser coverage pip install tree-sitter-python tree-sitter-javascript tree-sitter-html tree-sitter-css || true pip install tree-sitter-typescript tree-sitter-rust || true