diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bc2b812 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git/ +.github/ +.claude/ +tests/ +docs/ +*.md +LICENSE +__pycache__/ +*.pyc +.DS_Store +server.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..562cba1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY cli.py dashboard.py scanner.py ./ + +ENV HOST=0.0.0.0 \ + PORT=8080 \ + PYTHONUNBUFFERED=1 + +EXPOSE 8080 + +# Run scan once at startup, then serve. Bypasses cli.py:cmd_dashboard +# to avoid the webbrowser.open call (irrelevant inside a container). +CMD ["python3", "-c", "from scanner import scan; scan(); from dashboard import serve; serve()"] diff --git a/README.md b/README.md index cc3d0a8..5c2c2f6 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,24 @@ cd claude-usage python3 cli.py dashboard ``` +### Docker + +```bash +docker compose up -d +# Open http://localhost:8080 +``` + +Mounts `~/.claude` into the container, so the SQLite DB at `~/.claude/usage.db` +persists on the host and CLI commands (`python3 cli.py today`) keep working +alongside the container. + +To run single-shot CLI commands without starting the server: + +```bash +docker compose run --rm claude-usage python3 cli.py today +docker compose run --rm claude-usage python3 cli.py stats +``` + --- ## Usage diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..69dae1c --- /dev/null +++ b/compose.yaml @@ -0,0 +1,12 @@ +services: + claude-usage: + build: . + container_name: claude-usage + ports: + - "8080:8080" + volumes: + # JSONL logs (read) + usage.db (read/write) + - ${HOME:?HOME must be set}/.claude:/root/.claude + # Optional: Xcode Claude integration logs (macOS only) + # - ${HOME}/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/projects:/root/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/projects:ro + restart: unless-stopped diff --git a/tests/test_docker_setup.py b/tests/test_docker_setup.py new file mode 100644 index 0000000..83104f7 --- /dev/null +++ b/tests/test_docker_setup.py @@ -0,0 +1,29 @@ +"""Static checks for Docker setup (no docker build required — stdlib only).""" + +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +class TestDockerSetup(unittest.TestCase): + def test_dockerfile_exists_and_uses_python(self): + df = (ROOT / "Dockerfile").read_text() + self.assertRegex(df, r"(?m)^FROM\s+python:", + "Dockerfile must use a Python base image") + + def test_compose_exposes_dashboard_port(self): + compose = (ROOT / "compose.yaml").read_text() + self.assertIn("8080", compose) + + def test_compose_mounts_claude_home(self): + compose = (ROOT / "compose.yaml").read_text() + self.assertTrue( + re.search(r"(~|\$HOME|\$\{HOME\})/\.claude", compose), + f"compose.yaml must bind-mount ~/.claude; got:\n{compose}", + ) + + +if __name__ == "__main__": + unittest.main()