From a600dc127b65251a4cbc7d12780561c2b5c3d346 Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Wed, 31 Dec 2025 04:14:03 +0000 Subject: [PATCH] Add Phase 1: Project setup and FastMCP server skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pyproject.toml with FastMCP, uvicorn, and dev dependencies - Makefile with check, fmt, lint, test, install, uninstall targets - LaunchAgent plist and install/uninstall scripts for auto-start - dev.sh script for development mode with auto-reload - Basic FastMCP server with placeholder tools: - get_status: Returns server status - ingest_logs: Placeholder for log ingestion - query_tool_frequency: Placeholder for frequency queries - Usage guide as MCP resource at session-analytics://guide - Tests for the placeholder tools - README with installation and usage instructions Server runs on port 8081 (to not conflict with event-bus on 8080). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 18 +++ Makefile | 73 ++++++++++ README.md | 59 ++++++++ pyproject.toml | 54 ++++++++ ....evansenter.claude-session-analytics.plist | 41 ++++++ scripts/dev.sh | 37 +++++ scripts/install-launchagent.sh | 55 ++++++++ scripts/uninstall-launchagent.sh | 24 ++++ src/session_analytics/__init__.py | 3 + src/session_analytics/guide.md | 68 ++++++++++ src/session_analytics/server.py | 126 ++++++++++++++++++ tests/__init__.py | 1 + tests/conftest.py | 25 ++++ tests/test_server.py | 27 ++++ 14 files changed, 611 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 Makefile create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 scripts/com.evansenter.claude-session-analytics.plist create mode 100755 scripts/dev.sh create mode 100755 scripts/install-launchagent.sh create mode 100755 scripts/uninstall-launchagent.sh create mode 100644 src/session_analytics/guide.md create mode 100644 src/session_analytics/server.py create mode 100644 tests/conftest.py create mode 100644 tests/test_server.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..65a0653 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(chmod:*)", + "Bash(python3 -m venv:*)", + "Bash(.venv/bin/pip install:*)", + "Bash(brew list:*)", + "Bash(/opt/homebrew/bin/python3.12:*)", + "Bash(.venv/bin/ruff format:*)", + "Bash(.venv/bin/ruff check .)", + "Bash(.venv/bin/pytest tests/ -v)", + "Bash(./scripts/install-launchagent.sh:*)", + "Bash(claude mcp add:*)", + "Bash(curl:*)", + "Bash(cat:*)" + ] + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a191f7c --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +.PHONY: check fmt lint test clean install uninstall dev venv + +# Run all quality gates (format check, lint, tests) +check: fmt lint test + +# Check/fix formatting with ruff +fmt: + ruff format --check . + +# Run linter with ruff +lint: + ruff check . + +# Run tests +test: + pytest tests/ -v + +# Clean build artifacts +clean: + rm -rf build/ dist/ *.egg-info .pytest_cache .ruff_cache + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + +# Create virtual environment (requires Python 3.10+) +venv: + @if [ ! -d .venv ]; then \ + echo "Creating virtual environment..."; \ + PYTHON=$$(command -v python3.12 || command -v python3.11 || command -v python3.10 || echo "python3"); \ + $$PYTHON -m venv .venv && .venv/bin/pip install --upgrade pip; \ + fi + +# Install with dev dependencies (for development) +dev: venv + .venv/bin/pip install -e ".[dev]" + +# Full installation: venv + deps + LaunchAgent + CLI + MCP +install: venv + @echo "Installing dependencies..." + .venv/bin/pip install -e . + @echo "" + @echo "Installing LaunchAgent..." + ./scripts/install-launchagent.sh + @echo "" + @echo "Adding to Claude Code..." + @CLAUDE_CMD=$$(command -v claude || echo "$$HOME/.local/bin/claude"); \ + if [ -x "$$CLAUDE_CMD" ]; then \ + $$CLAUDE_CMD mcp add --transport http --scope user session-analytics http://localhost:8081/mcp 2>/dev/null && \ + echo "Added session-analytics to Claude Code" || \ + echo "session-analytics already configured in Claude Code"; \ + else \ + echo "Note: claude not found. Run manually:"; \ + echo " claude mcp add --transport http --scope user session-analytics http://localhost:8081/mcp"; \ + fi + @echo "" + @echo "Installation complete!" + @echo "" + @echo "Make sure ~/.local/bin is in your PATH:" + @echo ' export PATH="$$HOME/.local/bin:$$PATH"' + +# Uninstall: LaunchAgent + CLI + MCP config +uninstall: + @echo "Uninstalling..." + ./scripts/uninstall-launchagent.sh + @echo "" + @echo "Removing from Claude Code..." + @CLAUDE_CMD=$$(command -v claude || echo "$$HOME/.local/bin/claude"); \ + if [ -x "$$CLAUDE_CMD" ]; then \ + $$CLAUDE_CMD mcp remove --scope user session-analytics 2>/dev/null && \ + echo "Removed session-analytics from Claude Code" || \ + echo "session-analytics not found in Claude Code"; \ + fi + @echo "" + @echo "Uninstall complete!" + @echo "Note: venv and source code remain in place." diff --git a/README.md b/README.md new file mode 100644 index 0000000..b724c57 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Claude Session Analytics + +MCP server for queryable analytics on Claude Code session logs. + +## Overview + +Replaces `parse-session-logs.sh` with a persistent, queryable analytics layer. Parses JSONL session logs from `~/.claude/projects/` and provides: + +- **User-centric timeline**: Events across conversations, organized by timestamp +- **Rich querying**: Tool frequency, command breakdown, sequences, permission gaps +- **Persistent storage**: SQLite at `~/.claude/contrib/analytics/data.db` +- **Auto-refresh**: Queries automatically refresh stale data (>5 min old) +- **CLI access**: Full CLI for shell scripts and hooks + +## Installation + +```bash +make install +``` + +This will: +1. Create a virtual environment +2. Install dependencies +3. Set up a LaunchAgent for auto-start +4. Add the MCP server to Claude Code + +## Development + +```bash +make dev # Install dev dependencies +./scripts/dev.sh # Run in dev mode with auto-reload +``` + +## Commands + +```bash +make check # Run fmt, lint, test +make install # Install LaunchAgent + CLI +make uninstall # Remove LaunchAgent + CLI +``` + +## MCP Tools + +| Tool | Purpose | +|------|---------| +| `ingest_logs` | Refresh data from JSONL files | +| `query_timeline` | Events in time window | +| `query_tool_frequency` | Tool usage counts | +| `query_commands` | Bash command breakdown | +| `query_sequences` | Common tool patterns | +| `query_permission_gaps` | Commands needing settings.json | +| `query_sessions` | Session metadata | +| `query_tokens` | Token usage analysis | +| `get_insights` | Pre-computed patterns for /improve-workflow | +| `get_status` | Ingestion status + DB stats | + +## License + +MIT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3194b72 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "claude-session-analytics" +version = "0.1.0" +description = "MCP server for queryable analytics on Claude Code session logs" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [ + { name = "Evan Senter" } +] +keywords = ["mcp", "claude", "analytics", "session-logs"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "fastmcp>=0.1.0", + "uvicorn>=0.30.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "ruff>=0.8.0", +] + +[project.scripts] +session-analytics = "session_analytics.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/session_analytics"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.ruff] +target-version = "py310" +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501"] # Line length handled by formatter diff --git a/scripts/com.evansenter.claude-session-analytics.plist b/scripts/com.evansenter.claude-session-analytics.plist new file mode 100644 index 0000000..d8421b0 --- /dev/null +++ b/scripts/com.evansenter.claude-session-analytics.plist @@ -0,0 +1,41 @@ + + + + + Label + com.evansenter.claude-session-analytics + + ProgramArguments + + __VENV_PYTHON__ + -m + session_analytics.server + + + WorkingDirectory + __PROJECT_DIR__ + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + PYTHONPATH + __PROJECT_DIR__/src + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + __HOME__/.claude/session-analytics.log + + StandardErrorPath + __HOME__/.claude/session-analytics.err + + ProcessType + Background + + diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..c86e1f3 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Run session analytics in development mode (foreground, auto-reload, verbose logging) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +LABEL="com.evansenter.claude-session-analytics" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" + +cd "$PROJECT_DIR" +source .venv/bin/activate + +# Stop LaunchAgent if running (to free port 8081) +LAUNCHAGENT_WAS_RUNNING=false +if launchctl list 2>/dev/null | grep -q "$LABEL"; then + echo "Stopping LaunchAgent for dev mode..." + launchctl unload "$PLIST" 2>/dev/null + LAUNCHAGENT_WAS_RUNNING=true + osascript -e 'display notification "Stopped for dev mode" with title "Session Analytics"' 2>/dev/null +fi + +# Restart LaunchAgent on exit +cleanup() { + if [[ "$LAUNCHAGENT_WAS_RUNNING" == "true" && -f "$PLIST" ]]; then + echo "" + echo "Restarting LaunchAgent..." + launchctl load "$PLIST" + osascript -e 'display notification "LaunchAgent restarted" with title "Session Analytics"' 2>/dev/null + fi +} +trap cleanup EXIT + +echo "Starting session analytics in dev mode (Ctrl+C to stop)..." +echo "Add to Claude Code: claude mcp add --transport http --scope user session-analytics http://127.0.0.1:8081/mcp" +echo "" + +# DEV_MODE enables verbose logging +DEV_MODE=1 uvicorn session_analytics.server:create_app --host 127.0.0.1 --port 8081 --reload --factory diff --git a/scripts/install-launchagent.sh b/scripts/install-launchagent.sh new file mode 100755 index 0000000..40c9398 --- /dev/null +++ b/scripts/install-launchagent.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Install the session analytics server as a macOS LaunchAgent (auto-starts on login) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +VENV_PYTHON="$PROJECT_DIR/.venv/bin/python" +PLIST_TEMPLATE="$SCRIPT_DIR/com.evansenter.claude-session-analytics.plist" +PLIST_DEST="$HOME/Library/LaunchAgents/com.evansenter.claude-session-analytics.plist" +LABEL="com.evansenter.claude-session-analytics" + +# Check venv exists +if [[ ! -f "$VENV_PYTHON" ]]; then + echo "Error: Virtual environment not found at $PROJECT_DIR/.venv" + echo "Run: python3 -m venv .venv && source .venv/bin/activate && pip install -e ." + exit 1 +fi + +# Create LaunchAgents directory if needed +mkdir -p "$HOME/Library/LaunchAgents" +mkdir -p "$HOME/.claude" + +# Stop existing service if running +if launchctl list | grep -q "$LABEL"; then + echo "Stopping existing service..." + launchctl unload "$PLIST_DEST" 2>/dev/null || true +fi + +# Generate plist with correct paths +echo "Installing LaunchAgent..." +sed -e "s|__VENV_PYTHON__|$VENV_PYTHON|g" \ + -e "s|__PROJECT_DIR__|$PROJECT_DIR|g" \ + -e "s|__HOME__|$HOME|g" \ + "$PLIST_TEMPLATE" > "$PLIST_DEST" + +# Load the service +echo "Starting service..." +launchctl load "$PLIST_DEST" + +# Verify it's running +sleep 1 +if launchctl list | grep -q "$LABEL"; then + echo "" + echo "Session analytics installed and running!" + echo " Logs: ~/.claude/session-analytics.log" + echo " Errors: ~/.claude/session-analytics.err" + echo "" + echo "To uninstall: $SCRIPT_DIR/uninstall-launchagent.sh" + osascript -e 'display notification "LaunchAgent installed and running" with title "Session Analytics"' 2>/dev/null +else + echo "Error: Service failed to start. Check ~/.claude/session-analytics.err" + osascript -e 'display notification "Failed to start - check logs" with title "Session Analytics" sound name "Basso"' 2>/dev/null + exit 1 +fi diff --git a/scripts/uninstall-launchagent.sh b/scripts/uninstall-launchagent.sh new file mode 100755 index 0000000..9e556ed --- /dev/null +++ b/scripts/uninstall-launchagent.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Uninstall the session analytics LaunchAgent + +set -e + +PLIST_DEST="$HOME/Library/LaunchAgents/com.evansenter.claude-session-analytics.plist" +LABEL="com.evansenter.claude-session-analytics" + +if [[ ! -f "$PLIST_DEST" ]]; then + echo "LaunchAgent not installed." + exit 0 +fi + +echo "Stopping service..." +launchctl unload "$PLIST_DEST" 2>/dev/null || true + +echo "Removing plist..." +rm -f "$PLIST_DEST" + +echo "Session analytics LaunchAgent uninstalled." + +echo "" +echo "Note: Logs remain at ~/.claude/session-analytics.log" +osascript -e 'display notification "LaunchAgent uninstalled" with title "Session Analytics"' 2>/dev/null diff --git a/src/session_analytics/__init__.py b/src/session_analytics/__init__.py index e69de29..345cbea 100644 --- a/src/session_analytics/__init__.py +++ b/src/session_analytics/__init__.py @@ -0,0 +1,3 @@ +"""Claude Session Analytics - MCP server for queryable session log analytics.""" + +__version__ = "0.1.0" diff --git a/src/session_analytics/guide.md b/src/session_analytics/guide.md new file mode 100644 index 0000000..1fe271b --- /dev/null +++ b/src/session_analytics/guide.md @@ -0,0 +1,68 @@ +# Session Analytics Usage Guide + +This MCP server provides queryable analytics on Claude Code session logs. + +## Quick Start + +The server auto-refreshes data when queries detect stale data (>5 min old). +You can also manually trigger ingestion: + +``` +ingest_logs(days=7) # Process last 7 days of logs +``` + +## Available Tools + +### Ingestion + +| Tool | Purpose | +|------|---------| +| `ingest_logs` | Refresh data from JSONL files | +| `get_status` | Ingestion status + DB stats | + +### Queries + +| Tool | Purpose | +|------|---------| +| `query_timeline` | Events in time window | +| `query_tool_frequency` | Tool usage counts | +| `query_commands` | Bash command breakdown | +| `query_sequences` | Common tool patterns | +| `query_permission_gaps` | Commands needing settings.json | +| `query_sessions` | Session metadata | +| `query_tokens` | Token usage analysis | +| `get_insights` | Pre-computed patterns | + +## Common Patterns + +### Understanding tool usage + +``` +query_tool_frequency(days=30) +``` + +### Finding permission gaps + +``` +query_permission_gaps(threshold=5) # Commands used 5+ times that need permission +``` + +### Analyzing workflows + +``` +query_sequences(min_count=3, length=3) # Common 3-tool sequences +``` + +## Integration with /improve-workflow + +The `get_insights` tool returns pre-computed patterns specifically for +the `/improve-workflow` command: + +``` +get_insights(refresh=True) # Force fresh analysis +``` + +## Data Location + +- Database: `~/.claude/contrib/analytics/data.db` +- Logs parsed from: `~/.claude/projects/**/*.jsonl` diff --git a/src/session_analytics/server.py b/src/session_analytics/server.py new file mode 100644 index 0000000..5ce5ea2 --- /dev/null +++ b/src/session_analytics/server.py @@ -0,0 +1,126 @@ +"""MCP Session Analytics Server. + +Provides tools for querying Claude Code session logs: +- ingest_logs: Refresh data from JSONL files +- query_timeline: Events in time window +- query_tool_frequency: Tool usage counts +- query_commands: Bash command breakdown +- query_sequences: Common tool patterns +- query_permission_gaps: Commands needing settings.json +- query_sessions: Session metadata +- query_tokens: Token usage analysis +- get_insights: Pre-computed patterns for /improve-workflow +- get_status: Ingestion status + DB stats +""" + +import logging +import os +from pathlib import Path + +from fastmcp import FastMCP + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("session-analytics") +if os.environ.get("DEV_MODE"): + logger.setLevel(logging.DEBUG) + +# Initialize MCP server +mcp = FastMCP("session-analytics") + + +@mcp.resource("session-analytics://guide", description="Usage guide and best practices") +def usage_guide() -> str: + """Return the session analytics usage guide from external markdown file.""" + guide_path = Path(__file__).parent / "guide.md" + try: + return guide_path.read_text() + except FileNotFoundError: + return "# Session Analytics Usage Guide\n\nGuide file not found. See CLAUDE.md for usage." + + +@mcp.tool() +def get_status() -> dict: + """Get ingestion status and database stats. + + Returns: + Status info including last ingestion time, event count, and DB size + """ + # Placeholder - will be implemented in Phase 2 + return { + "status": "ok", + "version": "0.1.0", + "message": "Session analytics server is running. Storage layer not yet implemented.", + "db_path": str(Path.home() / ".claude" / "contrib" / "analytics" / "data.db"), + } + + +@mcp.tool() +def ingest_logs(days: int = 7, project: str | None = None, force: bool = False) -> dict: + """Refresh data from JSONL session log files. + + Args: + days: Number of days to look back (default: 7) + project: Optional project path filter + force: Force re-ingestion even if data is fresh + + Returns: + Ingestion stats (files processed, entries added, etc.) + """ + # Placeholder - will be implemented in Phase 3 + return { + "status": "not_implemented", + "message": "Ingestion will be implemented in Phase 3", + "days": days, + "project": project, + "force": force, + } + + +@mcp.tool() +def query_tool_frequency(days: int = 7, project: str | None = None) -> dict: + """Get tool usage frequency counts. + + Args: + days: Number of days to analyze (default: 7) + project: Optional project path filter + + Returns: + Tool frequency breakdown + """ + # Placeholder - will be implemented in Phase 4 + return { + "status": "not_implemented", + "message": "Query will be implemented in Phase 4", + "days": days, + "project": project, + } + + +def create_app(): + """Create the ASGI app for uvicorn.""" + # stateless_http=True allows resilience to server restarts + return mcp.http_app(stateless_http=True) + + +def main(): + """Run the MCP server.""" + import uvicorn + + port = int(os.environ.get("PORT", 8081)) + host = os.environ.get("HOST", "127.0.0.1") + + print(f"Starting Claude Session Analytics on {host}:{port}") + print( + f"Add to Claude Code: claude mcp add --transport http --scope user session-analytics http://{host}:{port}/mcp" + ) + + uvicorn.run(create_app(), host=host, port=port) + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..b76b24c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for claude-session-analytics.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..83cca08 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""Pytest configuration and fixtures.""" + +import pytest + + +@pytest.fixture +def sample_session_log_entry(): + """Sample JSONL entry from a Claude Code session log.""" + return { + "uuid": "test-uuid-12345", + "timestamp": "2025-01-01T12:00:00.000Z", + "sessionId": "session-abc123", + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "tool-123", + "name": "Bash", + "input": {"command": "git status", "description": "Check git status"}, + } + ], + }, + } diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..cc43083 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,27 @@ +"""Tests for the MCP server.""" + +from session_analytics.server import get_status, ingest_logs, query_tool_frequency + + +def test_get_status(): + """Test that get_status returns expected fields.""" + # FastMCP wraps functions - access the underlying fn + result = get_status.fn() + assert result["status"] == "ok" + assert "version" in result + assert "db_path" in result + + +def test_ingest_logs_placeholder(): + """Test that ingest_logs returns placeholder response.""" + result = ingest_logs.fn(days=7) + assert result["status"] == "not_implemented" + assert result["days"] == 7 + + +def test_query_tool_frequency_placeholder(): + """Test that query_tool_frequency returns placeholder response.""" + result = query_tool_frequency.fn(days=14, project="/some/path") + assert result["status"] == "not_implemented" + assert result["days"] == 14 + assert result["project"] == "/some/path"