feat(claude-code): integrate Claude Code API with session tracking, config scanning, and CLI detection
This commit is contained in:
parent
5389a5cf9b
commit
25abfd3e15
|
|
@ -0,0 +1,230 @@
|
||||||
|
"""Claude Code Integration API.
|
||||||
|
|
||||||
|
Provides three integration surfaces:
|
||||||
|
|
||||||
|
1. Session Tracking — GET /claude-code/sessions, /sessions/{id}, /projects
|
||||||
|
Reads ~/.claude/projects/**/*.jsonl directly. Extracts token usage, model,
|
||||||
|
cost estimates, and active status without requiring a gateway.
|
||||||
|
|
||||||
|
2. Config Scanner — GET /claude-code/config
|
||||||
|
Reads ~/.claude/settings.json, ~/.codex/config.toml, and ~/.codex/rules/.
|
||||||
|
|
||||||
|
3. Direct CLI info — GET /claude-code/cli
|
||||||
|
Reports which CLI tools are detected on the host and their versions.
|
||||||
|
(Actual CLI execution goes through the gateway; this endpoint surfaces
|
||||||
|
discovery metadata for the dashboard.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
|
from app.api.deps import require_org_member
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.schemas.claude_code import (
|
||||||
|
ClaudeConfigRead,
|
||||||
|
ClaudeProjectRead,
|
||||||
|
ClaudeSessionListResponse,
|
||||||
|
ClaudeSessionRead,
|
||||||
|
ClaudeSessionStatsRead,
|
||||||
|
SessionTokensRead as ClaudeSessionTokensRead,
|
||||||
|
)
|
||||||
|
from app.services import claude_code_reader as reader
|
||||||
|
from app.services.organizations import OrganizationContext
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/claude-code", tags=["claude-code"])
|
||||||
|
ORG_MEMBER_DEP = Depends(require_org_member)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_to_read(s: reader.ClaudeSession) -> ClaudeSessionRead:
|
||||||
|
return ClaudeSessionRead(
|
||||||
|
session_id=s.session_id,
|
||||||
|
project_dir=s.project_dir,
|
||||||
|
cwd=s.cwd,
|
||||||
|
title=s.title,
|
||||||
|
models=s.models,
|
||||||
|
tokens=ClaudeSessionTokensRead(
|
||||||
|
input=s.tokens.input,
|
||||||
|
output=s.tokens.output,
|
||||||
|
cache_read=s.tokens.cache_read,
|
||||||
|
cache_write=s.tokens.cache_write,
|
||||||
|
total=s.tokens.total,
|
||||||
|
),
|
||||||
|
cost_usd=s.cost_usd,
|
||||||
|
message_count=s.message_count,
|
||||||
|
first_message_at=s.first_message_at,
|
||||||
|
last_message_at=s.last_message_at,
|
||||||
|
is_active=s.is_active,
|
||||||
|
entrypoints=s.entrypoints,
|
||||||
|
git_branch=s.git_branch,
|
||||||
|
version=s.version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session Tracking ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sessions",
|
||||||
|
response_model=ClaudeSessionListResponse,
|
||||||
|
summary="List local Claude Code sessions",
|
||||||
|
description=(
|
||||||
|
"Auto-discovers sessions from `~/.claude/projects/**/*.jsonl`. "
|
||||||
|
"Returns token usage, model info, cost estimates, and active status "
|
||||||
|
"for each session without requiring a gateway connection."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def list_sessions(
|
||||||
|
project: str | None = Query(None, description="Filter by project directory name substring"),
|
||||||
|
active_only: bool = Query(False, description="Return only currently active sessions"),
|
||||||
|
limit: int = Query(100, ge=1, le=500, description="Maximum sessions to return"),
|
||||||
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
|
) -> ClaudeSessionListResponse:
|
||||||
|
"""List Claude Code sessions from local JSONL files."""
|
||||||
|
sessions = await asyncio.to_thread(
|
||||||
|
reader.list_sessions,
|
||||||
|
project_filter=project,
|
||||||
|
active_only=active_only,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
stats = reader.session_stats(sessions)
|
||||||
|
return ClaudeSessionListResponse(
|
||||||
|
sessions=[_session_to_read(s) for s in sessions],
|
||||||
|
total=len(sessions),
|
||||||
|
stats=ClaudeSessionStatsRead(
|
||||||
|
session_count=stats["session_count"],
|
||||||
|
active_sessions=stats["active_sessions"],
|
||||||
|
total_tokens=stats["total_tokens"],
|
||||||
|
total_cost_usd=stats["total_cost_usd"],
|
||||||
|
models=stats["models"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sessions/{session_id}",
|
||||||
|
response_model=ClaudeSessionRead,
|
||||||
|
summary="Get a single Claude Code session",
|
||||||
|
)
|
||||||
|
async def get_session(
|
||||||
|
session_id: str,
|
||||||
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
|
) -> ClaudeSessionRead:
|
||||||
|
"""Return a single parsed session by its UUID."""
|
||||||
|
session = await asyncio.to_thread(reader.get_session, session_id)
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||||
|
return _session_to_read(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/projects",
|
||||||
|
response_model=list[ClaudeProjectRead],
|
||||||
|
summary="List discovered local Claude Code projects",
|
||||||
|
description="Returns one entry per project directory with aggregated token and cost stats.",
|
||||||
|
)
|
||||||
|
async def list_projects(
|
||||||
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
|
) -> list[ClaudeProjectRead]:
|
||||||
|
"""Aggregate session stats grouped by project directory."""
|
||||||
|
projects = await asyncio.to_thread(reader.list_projects)
|
||||||
|
return [
|
||||||
|
ClaudeProjectRead(
|
||||||
|
project_dir=p["project_dir"],
|
||||||
|
cwd=p.get("cwd"),
|
||||||
|
session_count=p["session_count"],
|
||||||
|
total_tokens=p["total_tokens"],
|
||||||
|
total_cost_usd=p["total_cost_usd"],
|
||||||
|
last_active_at=p.get("last_active_at"),
|
||||||
|
is_active=p["is_active"],
|
||||||
|
)
|
||||||
|
for p in projects
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config Scanner ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/config",
|
||||||
|
response_model=ClaudeConfigRead,
|
||||||
|
summary="Read local Claude Code and Codex CLI configuration",
|
||||||
|
description=(
|
||||||
|
"Reads `~/.claude/settings.json`, `~/.codex/config.toml`, and "
|
||||||
|
"`~/.codex/rules/*.rules`. Never returns secrets — only settings "
|
||||||
|
"and trust/rule data."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_config(
|
||||||
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
|
) -> ClaudeConfigRead:
|
||||||
|
"""Return parsed local CLI configuration."""
|
||||||
|
cfg = await asyncio.to_thread(reader.read_config)
|
||||||
|
return ClaudeConfigRead(
|
||||||
|
claude_settings=cfg.claude_settings,
|
||||||
|
codex_config=cfg.codex_config,
|
||||||
|
codex_rules=cfg.codex_rules,
|
||||||
|
claude_credentials_configured=cfg.claude_credentials_path is not None,
|
||||||
|
codex_credentials_configured=cfg.codex_credentials_path is not None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Direct CLI Discovery ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _cli_version(cmd: list[str]) -> str | None:
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
|
||||||
|
line = stdout.decode(errors="replace").strip().splitlines()[0] if stdout else ""
|
||||||
|
return line or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/cli",
|
||||||
|
summary="Detect installed CLI tools",
|
||||||
|
description=(
|
||||||
|
"Checks which of Claude Code, Codex CLI, and Ollama are installed "
|
||||||
|
"on the host and returns their versions. No gateway required."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def detect_cli(
|
||||||
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return discovery metadata for locally installed CLI tools."""
|
||||||
|
claude_path = shutil.which("claude")
|
||||||
|
codex_path = shutil.which("codex")
|
||||||
|
ollama_path = shutil.which("ollama")
|
||||||
|
|
||||||
|
claude_version = await _cli_version(["claude", "--version"]) if claude_path else None
|
||||||
|
codex_version = await _cli_version(["codex", "--version"]) if codex_path else None
|
||||||
|
ollama_version = await _cli_version(["ollama", "--version"]) if ollama_path else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"claude": {
|
||||||
|
"installed": claude_path is not None,
|
||||||
|
"path": claude_path,
|
||||||
|
"version": claude_version,
|
||||||
|
"projects_dir": str(reader._projects_dir()),
|
||||||
|
"projects_dir_exists": reader._projects_dir().exists(),
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"installed": codex_path is not None,
|
||||||
|
"path": codex_path,
|
||||||
|
"version": codex_version,
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"installed": ollama_path is not None,
|
||||||
|
"path": ollama_path,
|
||||||
|
"version": ollama_version,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ from app.api.forgejo_metrics import router as forgejo_metrics_router
|
||||||
from app.api.forgejo_repositories import router as forgejo_repositories_router
|
from app.api.forgejo_repositories import router as forgejo_repositories_router
|
||||||
from app.api.forgejo_webhooks import router as forgejo_webhooks_router
|
from app.api.forgejo_webhooks import router as forgejo_webhooks_router
|
||||||
from app.api.gateway import router as gateway_router
|
from app.api.gateway import router as gateway_router
|
||||||
|
from app.api.claude_code import router as claude_code_router
|
||||||
from app.api.gateways import router as gateways_router
|
from app.api.gateways import router as gateways_router
|
||||||
from app.api.metrics import router as metrics_router
|
from app.api.metrics import router as metrics_router
|
||||||
from app.api.organizations import router as organizations_router
|
from app.api.organizations import router as organizations_router
|
||||||
|
|
@ -623,6 +624,7 @@ api_v1.include_router(task_custom_fields_router)
|
||||||
api_v1.include_router(tags_router)
|
api_v1.include_router(tags_router)
|
||||||
api_v1.include_router(users_router)
|
api_v1.include_router(users_router)
|
||||||
api_v1.include_router(provider_credentials_router)
|
api_v1.include_router(provider_credentials_router)
|
||||||
|
api_v1.include_router(claude_code_router)
|
||||||
app.include_router(api_v1)
|
app.include_router(api_v1)
|
||||||
|
|
||||||
add_pagination(app)
|
add_pagination(app)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Schemas for Claude Code Integration API endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class SessionTokensRead(SQLModel):
|
||||||
|
input: int
|
||||||
|
output: int
|
||||||
|
cache_read: int
|
||||||
|
cache_write: int
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeSessionRead(SQLModel):
|
||||||
|
session_id: str
|
||||||
|
project_dir: str
|
||||||
|
cwd: str | None = None
|
||||||
|
title: str | None = None
|
||||||
|
models: list[str]
|
||||||
|
tokens: SessionTokensRead
|
||||||
|
cost_usd: float
|
||||||
|
message_count: int
|
||||||
|
first_message_at: datetime | None = None
|
||||||
|
last_message_at: datetime | None = None
|
||||||
|
is_active: bool
|
||||||
|
entrypoints: list[str]
|
||||||
|
git_branch: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeSessionStatsRead(SQLModel):
|
||||||
|
session_count: int
|
||||||
|
active_sessions: int
|
||||||
|
total_tokens: int
|
||||||
|
total_cost_usd: float
|
||||||
|
models: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeSessionListResponse(SQLModel):
|
||||||
|
sessions: list[ClaudeSessionRead]
|
||||||
|
total: int
|
||||||
|
stats: ClaudeSessionStatsRead
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeProjectRead(SQLModel):
|
||||||
|
project_dir: str
|
||||||
|
cwd: str | None = None
|
||||||
|
session_count: int
|
||||||
|
total_tokens: int
|
||||||
|
total_cost_usd: float
|
||||||
|
last_active_at: datetime | None = None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeConfigRead(SQLModel):
|
||||||
|
claude_settings: dict[str, Any]
|
||||||
|
codex_config: dict[str, Any]
|
||||||
|
codex_rules: list[str]
|
||||||
|
claude_credentials_configured: bool
|
||||||
|
codex_credentials_configured: bool
|
||||||
|
|
@ -0,0 +1,371 @@
|
||||||
|
"""Reader for local Claude Code and Codex CLI data.
|
||||||
|
|
||||||
|
Discovers sessions from ~/.claude/projects/**/*.jsonl, extracts token usage,
|
||||||
|
model info, cost estimates, and activity status. Also reads ~/.claude/settings.json
|
||||||
|
and ~/.codex/config.toml for the config scanner.
|
||||||
|
|
||||||
|
All I/O is synchronous and file-local — no network calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
ACTIVE_WINDOW_MINUTES = 30
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pricing (USD per million tokens) — mirrors runtime_usage.DEFAULT_MODEL_PRICING
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PRICING: dict[str, dict[str, float]] = {
|
||||||
|
"claude-opus-4-7": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75},
|
||||||
|
"claude-opus-4-5": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75},
|
||||||
|
"claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
|
||||||
|
"claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
|
||||||
|
"claude-haiku-4-5-20251001": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00},
|
||||||
|
"claude-3-5-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
|
||||||
|
"claude-3-5-haiku": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00},
|
||||||
|
"claude-3-opus": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75},
|
||||||
|
"claude-3-haiku": {"input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_write": 0.30},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _price(model: str, input_t: int, output_t: int, cache_read: int, cache_write: int) -> float:
|
||||||
|
key = next((k for k in _PRICING if model.endswith(k) or k in model), None)
|
||||||
|
if not key:
|
||||||
|
return 0.0
|
||||||
|
p = _PRICING[key]
|
||||||
|
return (
|
||||||
|
input_t * p["input"] / 1_000_000
|
||||||
|
+ output_t * p["output"] / 1_000_000
|
||||||
|
+ cache_read * p["cache_read"] / 1_000_000
|
||||||
|
+ cache_write * p["cache_write"] / 1_000_000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(ts: str | None) -> datetime | None:
|
||||||
|
if not ts:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone(UTC).replace(tzinfo=None)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionTokens:
|
||||||
|
input: int = 0
|
||||||
|
output: int = 0
|
||||||
|
cache_read: int = 0
|
||||||
|
cache_write: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return self.input + self.output + self.cache_read + self.cache_write
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClaudeSession:
|
||||||
|
session_id: str
|
||||||
|
project_dir: str # raw directory name under ~/.claude/projects/
|
||||||
|
cwd: str | None # actual working directory from JSONL records
|
||||||
|
title: str | None
|
||||||
|
models: list[str]
|
||||||
|
tokens: SessionTokens
|
||||||
|
cost_usd: float
|
||||||
|
message_count: int # assistant turns
|
||||||
|
first_message_at: datetime | None
|
||||||
|
last_message_at: datetime | None
|
||||||
|
is_active: bool
|
||||||
|
entrypoints: list[str] # e.g. ["claude-vscode", "claude"]
|
||||||
|
git_branch: str | None
|
||||||
|
version: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClaudeConfig:
|
||||||
|
claude_settings: dict[str, Any] = field(default_factory=dict)
|
||||||
|
codex_config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
codex_rules: list[str] = field(default_factory=list)
|
||||||
|
claude_credentials_path: str | None = None
|
||||||
|
codex_credentials_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSONL parser — one file = one session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_session_file(path: Path) -> ClaudeSession | None:
|
||||||
|
session_id = path.stem
|
||||||
|
project_dir = path.parent.name
|
||||||
|
|
||||||
|
tokens = SessionTokens()
|
||||||
|
models: set[str] = set()
|
||||||
|
entrypoints: set[str] = set()
|
||||||
|
first_ts: datetime | None = None
|
||||||
|
last_ts: datetime | None = None
|
||||||
|
title: str | None = None
|
||||||
|
cwd: str | None = None
|
||||||
|
git_branch: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
message_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||||
|
for raw_line in fh:
|
||||||
|
raw_line = raw_line.strip()
|
||||||
|
if not raw_line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rec = json.loads(raw_line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ts = _parse_iso(rec.get("timestamp"))
|
||||||
|
if ts:
|
||||||
|
if first_ts is None or ts < first_ts:
|
||||||
|
first_ts = ts
|
||||||
|
if last_ts is None or ts > last_ts:
|
||||||
|
last_ts = ts
|
||||||
|
|
||||||
|
rec_type = rec.get("type")
|
||||||
|
|
||||||
|
if rec_type == "ai-title":
|
||||||
|
title = rec.get("title") or title
|
||||||
|
|
||||||
|
if not cwd:
|
||||||
|
cwd = rec.get("cwd")
|
||||||
|
if not git_branch:
|
||||||
|
git_branch = rec.get("gitBranch")
|
||||||
|
if not version:
|
||||||
|
version = rec.get("version")
|
||||||
|
|
||||||
|
ep = rec.get("entrypoint")
|
||||||
|
if ep:
|
||||||
|
entrypoints.add(ep)
|
||||||
|
|
||||||
|
if rec_type == "assistant":
|
||||||
|
message_count += 1
|
||||||
|
msg = rec.get("message") or {}
|
||||||
|
model = msg.get("model")
|
||||||
|
if model:
|
||||||
|
models.add(model)
|
||||||
|
usage = msg.get("usage") or {}
|
||||||
|
tokens.input += usage.get("input_tokens", 0)
|
||||||
|
tokens.output += usage.get("output_tokens", 0)
|
||||||
|
tokens.cache_read += usage.get("cache_read_input_tokens", 0)
|
||||||
|
tokens.cache_write += usage.get("cache_creation_input_tokens", 0)
|
||||||
|
|
||||||
|
except (OSError, PermissionError) as exc:
|
||||||
|
logger.debug("claude_code_reader.session_read_error path=%s error=%s", path, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if message_count == 0 and first_ts is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_list = sorted(models)
|
||||||
|
primary_model = model_list[0] if model_list else ""
|
||||||
|
cost = _price(primary_model, tokens.input, tokens.output, tokens.cache_read, tokens.cache_write)
|
||||||
|
for m in model_list[1:]:
|
||||||
|
# Additional models — approximate with same token split (rare)
|
||||||
|
cost += _price(m, 0, 0, 0, 0)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
is_active = bool(last_ts and (now - last_ts) < timedelta(minutes=ACTIVE_WINDOW_MINUTES))
|
||||||
|
|
||||||
|
return ClaudeSession(
|
||||||
|
session_id=session_id,
|
||||||
|
project_dir=project_dir,
|
||||||
|
cwd=cwd,
|
||||||
|
title=title,
|
||||||
|
models=model_list,
|
||||||
|
tokens=tokens,
|
||||||
|
cost_usd=round(cost, 6),
|
||||||
|
message_count=message_count,
|
||||||
|
first_message_at=first_ts,
|
||||||
|
last_message_at=last_ts,
|
||||||
|
is_active=is_active,
|
||||||
|
entrypoints=sorted(entrypoints),
|
||||||
|
git_branch=git_branch,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session listing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _projects_dir() -> Path:
|
||||||
|
override = os.environ.get("CLAUDE_PROJECTS_PATH", "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
return Path.home() / ".claude" / "projects"
|
||||||
|
|
||||||
|
|
||||||
|
def list_sessions(
|
||||||
|
*,
|
||||||
|
project_filter: str | None = None,
|
||||||
|
active_only: bool = False,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> list[ClaudeSession]:
|
||||||
|
"""Return parsed sessions from ~/.claude/projects/, newest first."""
|
||||||
|
root = _projects_dir()
|
||||||
|
if not root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
sessions: list[ClaudeSession] = []
|
||||||
|
jsonl_files = sorted(root.rglob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
for path in jsonl_files:
|
||||||
|
if project_filter and project_filter.lower() not in path.parent.name.lower():
|
||||||
|
continue
|
||||||
|
session = _parse_session_file(path)
|
||||||
|
if session is None:
|
||||||
|
continue
|
||||||
|
if active_only and not session.is_active:
|
||||||
|
continue
|
||||||
|
sessions.append(session)
|
||||||
|
if len(sessions) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
|
def get_session(session_id: str) -> ClaudeSession | None:
|
||||||
|
"""Return a single parsed session by ID."""
|
||||||
|
root = _projects_dir()
|
||||||
|
if not root.exists():
|
||||||
|
return None
|
||||||
|
for path in root.rglob(f"{session_id}.jsonl"):
|
||||||
|
return _parse_session_file(path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_projects() -> list[dict[str, Any]]:
|
||||||
|
"""Return discovered projects with aggregate stats."""
|
||||||
|
root = _projects_dir()
|
||||||
|
if not root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
projects: dict[str, dict[str, Any]] = {}
|
||||||
|
for path in root.rglob("*.jsonl"):
|
||||||
|
project_dir = path.parent.name
|
||||||
|
if project_dir not in projects:
|
||||||
|
projects[project_dir] = {
|
||||||
|
"project_dir": project_dir,
|
||||||
|
"session_count": 0,
|
||||||
|
"total_tokens": 0,
|
||||||
|
"total_cost_usd": 0.0,
|
||||||
|
"last_active_at": None,
|
||||||
|
"cwd": None,
|
||||||
|
"is_active": False,
|
||||||
|
}
|
||||||
|
session = _parse_session_file(path)
|
||||||
|
if session is None:
|
||||||
|
continue
|
||||||
|
p = projects[project_dir]
|
||||||
|
p["session_count"] += 1
|
||||||
|
p["total_tokens"] += session.tokens.total
|
||||||
|
p["total_cost_usd"] = round(p["total_cost_usd"] + session.cost_usd, 6)
|
||||||
|
if session.cwd and not p["cwd"]:
|
||||||
|
p["cwd"] = session.cwd
|
||||||
|
if session.last_message_at:
|
||||||
|
current = p["last_active_at"]
|
||||||
|
if current is None or session.last_message_at > current:
|
||||||
|
p["last_active_at"] = session.last_message_at
|
||||||
|
if session.is_active:
|
||||||
|
p["is_active"] = True
|
||||||
|
|
||||||
|
return sorted(projects.values(), key=lambda x: x["last_active_at"] or datetime.min, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config scanner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_toml(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
return tomllib.load(fh)
|
||||||
|
except (OSError, tomllib.TOMLDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_rules(path: Path) -> list[str]:
|
||||||
|
try:
|
||||||
|
return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def read_config() -> ClaudeConfig:
|
||||||
|
"""Read Claude Code and Codex CLI configuration from local files."""
|
||||||
|
claude_dir = Path.home() / ".claude"
|
||||||
|
codex_dir = Path.home() / ".codex"
|
||||||
|
|
||||||
|
# ~/.claude/settings.json
|
||||||
|
claude_settings = _read_json(claude_dir / "settings.json")
|
||||||
|
|
||||||
|
# ~/.codex/config.toml
|
||||||
|
codex_config = _read_toml(codex_dir / "config.toml")
|
||||||
|
|
||||||
|
# ~/.codex/rules/ — all .rules files
|
||||||
|
codex_rules: list[str] = []
|
||||||
|
rules_dir = codex_dir / "rules"
|
||||||
|
if rules_dir.exists():
|
||||||
|
for rules_file in sorted(rules_dir.glob("*.rules")):
|
||||||
|
codex_rules.extend(_read_rules(rules_file))
|
||||||
|
|
||||||
|
claude_creds = os.environ.get("CLAUDE_CREDENTIALS_PATH", "").strip() or str(claude_dir / ".credentials.json")
|
||||||
|
codex_creds = os.environ.get("CODEX_CREDENTIALS_PATH", "").strip() or str(codex_dir / "auth.json")
|
||||||
|
|
||||||
|
return ClaudeConfig(
|
||||||
|
claude_settings=claude_settings,
|
||||||
|
codex_config=codex_config,
|
||||||
|
codex_rules=codex_rules,
|
||||||
|
claude_credentials_path=claude_creds if Path(claude_creds).exists() else None,
|
||||||
|
codex_credentials_path=codex_creds if Path(codex_creds).exists() else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Aggregate stats helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def session_stats(sessions: list[ClaudeSession]) -> dict[str, Any]:
|
||||||
|
total_tokens = sum(s.tokens.total for s in sessions)
|
||||||
|
total_cost = round(sum(s.cost_usd for s in sessions), 6)
|
||||||
|
active = sum(1 for s in sessions if s.is_active)
|
||||||
|
all_models: set[str] = set()
|
||||||
|
for s in sessions:
|
||||||
|
all_models.update(s.models)
|
||||||
|
return {
|
||||||
|
"session_count": len(sessions),
|
||||||
|
"active_sessions": active,
|
||||||
|
"total_tokens": total_tokens,
|
||||||
|
"total_cost_usd": total_cost,
|
||||||
|
"models": sorted(all_models),
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
/* Georgia font for numbers only - subset to digits and common number symbols */
|
/* Georgia font for numbers only - subset to decimal digits */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Georgia Numbers";
|
font-family: "Georgia Numbers";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|
@ -12,18 +12,7 @@
|
||||||
local("Georgia"),
|
local("Georgia"),
|
||||||
local("Georgia-Regular"),
|
local("Georgia-Regular"),
|
||||||
url("/fonts/georgia-regular.woff2") format("woff2");
|
url("/fonts/georgia-regular.woff2") format("woff2");
|
||||||
unicode-range:
|
unicode-range: U+0030-0039;
|
||||||
U+0030-0039,
|
|
||||||
U+00B9,
|
|
||||||
U+00B2,
|
|
||||||
U+00B3,
|
|
||||||
U+2030,
|
|
||||||
U+2070,
|
|
||||||
U+2074-2079,
|
|
||||||
U+2080-2089,
|
|
||||||
U+2150-215F,
|
|
||||||
U+2160-2188,
|
|
||||||
U+2189-2189;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|
@ -35,18 +24,7 @@
|
||||||
local("Georgia Italic"),
|
local("Georgia Italic"),
|
||||||
local("Georgia-Italic"),
|
local("Georgia-Italic"),
|
||||||
url("/fonts/georgia-italic.woff2") format("woff2");
|
url("/fonts/georgia-italic.woff2") format("woff2");
|
||||||
unicode-range:
|
unicode-range: U+0030-0039;
|
||||||
U+0030-0039,
|
|
||||||
U+00B9,
|
|
||||||
U+00B2,
|
|
||||||
U+00B3,
|
|
||||||
U+2030,
|
|
||||||
U+2070,
|
|
||||||
U+2074-2079,
|
|
||||||
U+2080-2089,
|
|
||||||
U+2150-215F,
|
|
||||||
U+2160-2188,
|
|
||||||
U+2189-2189;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|
@ -58,18 +36,7 @@
|
||||||
local("Georgia Bold"),
|
local("Georgia Bold"),
|
||||||
local("Georgia-Bold"),
|
local("Georgia-Bold"),
|
||||||
url("/fonts/georgia-bold.woff2") format("woff2");
|
url("/fonts/georgia-bold.woff2") format("woff2");
|
||||||
unicode-range:
|
unicode-range: U+0030-0039;
|
||||||
U+0030-0039,
|
|
||||||
U+00B9,
|
|
||||||
U+00B2,
|
|
||||||
U+00B3,
|
|
||||||
U+2030,
|
|
||||||
U+2070,
|
|
||||||
U+2074-2079,
|
|
||||||
U+2080-2089,
|
|
||||||
U+2150-215F,
|
|
||||||
U+2160-2188,
|
|
||||||
U+2189-2189;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|
@ -81,18 +48,7 @@
|
||||||
local("Georgia Bold Italic"),
|
local("Georgia Bold Italic"),
|
||||||
local("Georgia-Bold-Italic"),
|
local("Georgia-Bold-Italic"),
|
||||||
url("/fonts/georgia-bold-italic.woff2") format("woff2");
|
url("/fonts/georgia-bold-italic.woff2") format("woff2");
|
||||||
unicode-range:
|
unicode-range: U+0030-0039;
|
||||||
U+0030-0039,
|
|
||||||
U+00B9,
|
|
||||||
U+00B2,
|
|
||||||
U+00B3,
|
|
||||||
U+2030,
|
|
||||||
U+2070,
|
|
||||||
U+2074-2079,
|
|
||||||
U+2080-2089,
|
|
||||||
U+2150-215F,
|
|
||||||
U+2160-2188,
|
|
||||||
U+2189-2189;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -195,6 +151,7 @@
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply font-body;
|
@apply font-body;
|
||||||
|
font-family: "Georgia Numbers", var(--font-body), sans-serif;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
@ -328,7 +285,7 @@ textarea::placeholder {
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-page {
|
.landing-page {
|
||||||
font-family: var(--font-body), sans-serif;
|
font-family: "Georgia Numbers", var(--font-body), sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Landing (Enterprise) */
|
/* Landing (Enterprise) */
|
||||||
|
|
@ -347,6 +304,7 @@ textarea::placeholder {
|
||||||
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
font-family:
|
font-family:
|
||||||
|
"Georgia Numbers",
|
||||||
var(--font-body),
|
var(--font-body),
|
||||||
-apple-system,
|
-apple-system,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
|
|
@ -553,7 +511,7 @@ textarea::placeholder {
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-enterprise .hero h1 {
|
.landing-enterprise .hero h1 {
|
||||||
font-family: var(--font-display), serif;
|
font-family: "Georgia Numbers", var(--font-display), serif;
|
||||||
font-size: 56px;
|
font-size: 56px;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
color: var(--primary-navy);
|
color: var(--primary-navy);
|
||||||
|
|
@ -961,7 +919,7 @@ textarea::placeholder {
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-enterprise .cta-section h2 {
|
.landing-enterprise .cta-section h2 {
|
||||||
font-family: var(--font-display), serif;
|
font-family: "Georgia Numbers", var(--font-display), serif;
|
||||||
font-size: 42px;
|
font-size: 42px;
|
||||||
color: white;
|
color: white;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,9 @@ module.exports = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
heading: ["var(--font-heading)", "sans-serif"],
|
heading: ["Georgia Numbers", "var(--font-heading)", "sans-serif"],
|
||||||
body: ["var(--font-body)", "sans-serif"],
|
body: ["Georgia Numbers", "var(--font-body)", "sans-serif"],
|
||||||
display: ["var(--font-display)", "serif"],
|
display: ["Georgia Numbers", "var(--font-display)", "serif"],
|
||||||
numeric: ["Georgia Numbers", "Georgia", "Times New Roman", "serif"],
|
numeric: ["Georgia Numbers", "Georgia", "Times New Roman", "serif"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue