Ryan Malloy bec1606c86 Add comprehensive documentation system and tool call tracking
## Documentation System
- Create complete documentation hub at /dashboard/docs with:
  - Getting Started guide with quick setup and troubleshooting
  - Hook Setup Guide with platform-specific configurations
  - API Reference with all endpoints and examples
  - FAQ with searchable questions and categories
- Add responsive design with interactive features
- Update navigation in base template

## Tool Call Tracking
- Add ToolCall model for tracking Claude Code tool usage
- Create /api/tool-calls endpoints for recording and analytics
- Add tool_call hook type with auto-session detection
- Include tool calls in project statistics and recalculation
- Track tool names, parameters, execution time, and success rates

## Project Enhancements
- Add project timeline and statistics pages (fix 404 errors)
- Create recalculation script for fixing zero statistics
- Update project stats to include tool call counts
- Enhance session model with tool call relationships

## Infrastructure
- Switch from requirements.txt to pyproject.toml/uv.lock
- Add data import functionality for claude.json files
- Update database connection to include all new models
- Add comprehensive API documentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-11 05:58:27 -06:00

390 lines
14 KiB
Python

"""
Data importer for Claude Code .claude.json file.
This module provides functionality to import historical data from the
.claude.json configuration file into the project tracker.
"""
import json
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database.connection import get_db
from app.models.project import Project
from app.models.session import Session
from app.models.conversation import Conversation
router = APIRouter()
class ClaudeJsonImporter:
"""Importer for .claude.json data."""
def __init__(self, db: AsyncSession):
self.db = db
async def import_from_file(self, file_path: str) -> Dict[str, Any]:
"""Import data from .claude.json file."""
if not os.path.exists(file_path):
raise FileNotFoundError(f"Claude configuration file not found: {file_path}")
try:
with open(file_path, 'r', encoding='utf-8') as f:
claude_data = json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in Claude configuration file: {e}")
results = {
"projects_imported": 0,
"sessions_estimated": 0,
"conversations_imported": 0,
"errors": []
}
# Import basic usage statistics
await self._import_usage_stats(claude_data, results)
# Import projects and their history
if "projects" in claude_data:
await self._import_projects(claude_data["projects"], results)
return results
async def _import_usage_stats(self, claude_data: Dict[str, Any], results: Dict[str, Any]):
"""Import basic usage statistics."""
# We could create a synthetic "Claude Code Usage" project to track overall stats
if claude_data.get("numStartups") and claude_data.get("firstStartTime"):
try:
first_start = datetime.fromisoformat(
claude_data["firstStartTime"].replace('Z', '+00:00')
)
# Create a synthetic project for overall Claude Code usage
usage_project = await self._get_or_create_project(
name="Claude Code Usage Statistics",
path="<system>",
description="Imported usage statistics from .claude.json"
)
# Estimate session distribution over time
num_startups = claude_data["numStartups"]
days_since_first = (datetime.now() - first_start.replace(tzinfo=None)).days
if days_since_first > 0:
# Create estimated sessions spread over the usage period
await self._create_estimated_sessions(
usage_project,
first_start.replace(tzinfo=None),
num_startups,
days_since_first
)
results["sessions_estimated"] = num_startups
except Exception as e:
results["errors"].append(f"Failed to import usage stats: {e}")
async def _import_projects(self, projects_data: Dict[str, Any], results: Dict[str, Any]):
"""Import project data from .claude.json."""
for project_path, project_info in projects_data.items():
try:
# Skip system paths or non-meaningful paths
if project_path in ["<system>", "/", "/tmp"]:
continue
# Extract project name from path
project_name = Path(project_path).name or "Unknown Project"
# Create or get existing project
project = await self._get_or_create_project(
name=project_name,
path=project_path
)
results["projects_imported"] += 1
# Import conversation history if available
if "history" in project_info and isinstance(project_info["history"], list):
conversation_count = await self._import_project_history(
project,
project_info["history"]
)
results["conversations_imported"] += conversation_count
except Exception as e:
results["errors"].append(f"Failed to import project {project_path}: {e}")
async def _get_or_create_project(
self,
name: str,
path: str,
description: Optional[str] = None
) -> Project:
"""Get existing project or create new one."""
# Check if project already exists
result = await self.db.execute(
select(Project).where(Project.path == path)
)
existing_project = result.scalars().first()
if existing_project:
return existing_project
# Try to detect languages from path
languages = self._detect_languages(path)
# Create new project
project = Project(
name=name,
path=path,
languages=languages
)
self.db.add(project)
await self.db.commit()
await self.db.refresh(project)
return project
def _detect_languages(self, project_path: str) -> Optional[List[str]]:
"""Attempt to detect programming languages from project directory."""
languages = []
try:
if os.path.exists(project_path) and os.path.isdir(project_path):
# Look for common files to infer languages
files = os.listdir(project_path)
# Python
if any(f.endswith(('.py', '.pyx', '.pyi')) for f in files) or 'requirements.txt' in files:
languages.append('python')
# JavaScript/TypeScript
if any(f.endswith(('.js', '.jsx', '.ts', '.tsx')) for f in files) or 'package.json' in files:
if any(f.endswith(('.ts', '.tsx')) for f in files):
languages.append('typescript')
else:
languages.append('javascript')
# Go
if any(f.endswith('.go') for f in files) or 'go.mod' in files:
languages.append('go')
# Rust
if any(f.endswith('.rs') for f in files) or 'Cargo.toml' in files:
languages.append('rust')
# Java
if any(f.endswith('.java') for f in files) or 'pom.xml' in files:
languages.append('java')
except (OSError, PermissionError):
# If we can't read the directory, that's okay
pass
return languages if languages else None
async def _create_estimated_sessions(
self,
project: Project,
first_start: datetime,
num_startups: int,
days_since_first: int
):
"""Create estimated sessions based on startup count."""
# Check if we already have sessions for this project
existing_sessions = await self.db.execute(
select(Session).where(
Session.project_id == project.id,
Session.session_type == "startup"
)
)
if existing_sessions.scalars().first():
return # Sessions already exist, skip creation
# Don't create too many sessions - limit to reasonable estimates
max_sessions = min(num_startups, 50) # Cap at 50 sessions
# Distribute sessions over the time period
if days_since_first > 0:
sessions_per_day = max_sessions / days_since_first
for i in range(max_sessions):
# Spread sessions over the time period
days_offset = int(i / sessions_per_day) if sessions_per_day > 0 else i
session_time = first_start + timedelta(days=days_offset)
# Estimate session duration (30-180 minutes)
import random
duration = random.randint(30, 180)
session = Session(
project_id=project.id,
start_time=session_time,
end_time=session_time + timedelta(minutes=duration),
session_type="startup",
working_directory=project.path,
duration_minutes=duration,
activity_count=random.randint(5, 25), # Estimated activity
conversation_count=random.randint(2, 8) # Estimated conversations
)
self.db.add(session)
await self.db.commit()
async def _import_project_history(
self,
project: Project,
history: List[Dict[str, Any]]
) -> int:
"""Import conversation history for a project."""
# Check if we already have history conversations for this project
existing_conversations = await self.db.execute(
select(Conversation).where(
Conversation.context.like('%"imported_from": ".claude.json"%'),
Conversation.session.has(Session.project_id == project.id)
)
)
if existing_conversations.scalars().first():
return 0 # History already imported, skip
conversation_count = 0
# Create a synthetic session for imported history
history_session = Session(
project_id=project.id,
start_time=datetime.now() - timedelta(days=30), # Assume recent
session_type="history_import", # Different type to avoid conflicts
working_directory=project.path,
activity_count=len(history),
conversation_count=len(history)
)
self.db.add(history_session)
await self.db.commit()
await self.db.refresh(history_session)
# Import each history entry as a conversation
for i, entry in enumerate(history[:20]): # Limit to 20 entries
try:
display_text = entry.get("display", "")
if display_text:
conversation = Conversation(
session_id=history_session.id,
timestamp=history_session.start_time + timedelta(minutes=i * 5),
user_prompt=display_text,
exchange_type="user_prompt",
context={"imported_from": ".claude.json"}
)
self.db.add(conversation)
conversation_count += 1
except Exception as e:
# Skip problematic entries
continue
if conversation_count > 0:
await self.db.commit()
return conversation_count
@router.post("/import/claude-json")
async def import_claude_json(
file_path: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""
Import data from .claude.json file.
If no file_path is provided, tries to find .claude.json in the user's home directory.
"""
if not file_path:
# Try default location
home_path = Path.home() / ".claude.json"
file_path = str(home_path)
try:
importer = ClaudeJsonImporter(db)
results = await importer.import_from_file(file_path)
return {
"success": True,
"message": "Import completed successfully",
"results": results
}
except FileNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Claude configuration file not found: {e}"
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file format: {e}"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Import failed: {e}"
)
@router.get("/import/claude-json/preview")
async def preview_claude_json_import(
file_path: Optional[str] = None
):
"""
Preview what would be imported from .claude.json file without actually importing.
"""
if not file_path:
home_path = Path.home() / ".claude.json"
file_path = str(home_path)
if not os.path.exists(file_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Claude configuration file not found"
)
try:
with open(file_path, 'r', encoding='utf-8') as f:
claude_data = json.load(f)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid JSON in Claude configuration file: {e}"
)
preview = {
"file_path": file_path,
"file_size_mb": round(os.path.getsize(file_path) / (1024 * 1024), 2),
"claude_usage": {
"num_startups": claude_data.get("numStartups", 0),
"first_start_time": claude_data.get("firstStartTime"),
"prompt_queue_use_count": claude_data.get("promptQueueUseCount", 0)
},
"projects": {
"total_count": len(claude_data.get("projects", {})),
"paths": list(claude_data.get("projects", {}).keys())[:10], # Show first 10
"has_more": len(claude_data.get("projects", {})) > 10
},
"history_entries": 0
}
# Count total history entries across all projects
if "projects" in claude_data:
total_history = sum(
len(proj.get("history", []))
for proj in claude_data["projects"].values()
)
preview["history_entries"] = total_history
return preview