feat(claude-code): integrate Claude Code API with session tracking, config scanning, and CLI detection

This commit is contained in:
null 2026-05-22 04:23:27 -05:00
parent 5389a5cf9b
commit 25abfd3e15
6 changed files with 681 additions and 55 deletions

View File

@ -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,
},
}

View File

@ -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)

View File

@ -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

View File

@ -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),
}

View File

@ -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;

View File

@ -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"],
}, },
}, },