This commit is contained in:
Marcin Dudek 2026-08-15 16:13:58 +08:00 committed by GitHub
commit db464aef17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 403 additions and 0 deletions

171
skills/html-report/SKILL.md Normal file
View File

@ -0,0 +1,171 @@
---
name: html-report
description: Generate self-contained HTML reports instead of long terminal output. Use when output would exceed ~10 lines, when the user invokes /report, or when a task produces structured results such as audits, research findings, comparisons, or task-completion summaries. Do NOT use for short answers, yes/no responses, or quick status updates under 10 lines.
license: Apache-2.0
---
# HTML Report Generation
## When to Use
- Task completions with file changes
- Research results and analysis
- Audits and quality checks
- Error/blocker reports requiring user action
- Comparisons (tools, approaches, options)
- Any structured output exceeding ~10 lines
## When NOT to Use
- Short answers, yes/no, status updates
- Quick confirmations (<10 lines)
## Report Types
### task-completion
Sections: Summary, Changes Made (files list), Verification Results, Next Steps
### research
Sections: Key Findings, Detailed Analysis, Recommendations, Sources
### audit
Sections: Overall Score/Grade, Pass/Fail Table, Detailed Findings, Recommendations
### error-blocker
Sections: What's Blocked, Root Cause Analysis, What User Needs To Do, Workarounds
### comparison
Sections: Options Overview Table (with pros/cons), Detailed Analysis, Recommendation
## Workflow
1. Generate HTML content using the template below
2. Get project name:
```bash
tmux display-message -p '#S' 2>/dev/null || basename "$PWD"
```
3. Get target path:
```bash
python3 scripts/report.py --path --project "{name}" --title "{title}" --type {type}
```
4. Write the HTML file to that path
5. Open it:
```bash
python3 scripts/report.py --open "{filepath}"
```
6. Print ONE line to terminal: `Report: {title} -> {filepath}`
**Always run `python3 scripts/report.py --help` first** to see all available options.
## HTML Template
```html
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>REPORT_TITLE</title>
<meta name="report-type" content="REPORT_TYPE">
<meta name="report-project" content="PROJECT_NAME">
<meta name="report-date" content="YYYY-MM-DD">
<script type="application/json" id="report-meta">
{"title": "REPORT_TITLE", "type": "REPORT_TYPE", "project": "PROJECT_NAME", "date": "YYYY-MM-DD"}
</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
:root { --pico-font-size: 15px; }
body { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
.report-header { border-bottom: 1px solid var(--pico-muted-border-color); padding-bottom: 1rem; margin-bottom: 2rem; }
.report-header h1 { margin-bottom: 0.3rem; }
.meta { font-size: 0.85em; opacity: 0.6; }
.meta span { margin-right: 1.5em; }
.badge { display: inline-block; padding: 0.15em 0.5em; border-radius: 4px; font-size: 0.8em; font-weight: 600; }
.badge-success { background: #065f46; color: #6ee7b7; }
.badge-warning { background: #78350f; color: #fcd34d; }
.badge-error { background: #7f1d1d; color: #fca5a5; }
.badge-info { background: #1e3a5f; color: #93c5fd; }
table { width: 100%; }
table td, table th { padding: 0.5rem 0.75rem; }
.score { font-size: 2em; font-weight: 700; }
.pass { color: #6ee7b7; }
.fail { color: #fca5a5; }
.warn { color: #fcd34d; }
pre { font-size: 0.85em; overflow-x: auto; }
.section { margin-bottom: 2rem; }
details summary { cursor: pointer; font-weight: 600; }
details { margin-bottom: 1rem; }
</style>
</head>
<body>
<div class="report-header">
<h1>REPORT_TITLE</h1>
<div class="meta">
<span>PROJECT_NAME</span>
<span>YYYY-MM-DD HH:MM</span>
<span class="badge badge-info">REPORT_TYPE</span>
</div>
</div>
<main>
<!-- REPORT CONTENT HERE -->
</main>
<footer style="margin-top:3rem;padding-top:1rem;border-top:1px solid var(--pico-muted-border-color);font-size:0.8em;opacity:0.4">
Generated by Claude Code
</footer>
</body>
</html>
```
## Content Patterns
### Pass/Fail Row (for audit tables)
```html
<tr>
<td>Check Name</td>
<td><span class="pass">✓ Pass</span></td>
<td>Details about what was checked</td>
</tr>
```
### File Change Row (for task-completion)
```html
<tr>
<td><code>path/to/file.py</code></td>
<td>Modified</td>
<td>Brief description of change</td>
</tr>
```
### Comparison Cell
```html
<td>
<strong>Option Name</strong><br>
<span class="pass">+ Pro point</span><br>
<span class="fail">- Con point</span>
</td>
```
## Images in Reports
- **NEVER embed base64-encoded images** in report HTML
- When including screenshots or images:
1. Save the image file in the same directory as the report HTML
2. Reference with a relative path: `<img src="screenshot.webp" alt="description">`
- Image filenames should be descriptive: `homepage-hero.webp`, `error-state.webp`
## Helper Script
`scripts/report.py` provides path generation, index management, and cross-platform file opening.
Key flags:
- `--path` — print the target file path for a new report (auto-creates directories)
- `--open <file>` — open an HTML file in the default browser (macOS, Linux, Windows)
- `--list` — list recent reports from the index
- `--project`, `--title`, `--type` — metadata for path generation and indexing
The script has **zero external dependencies** — standard library only.
## Important Rules
- Reports are **self-contained** — all CSS is inline or from a CDN (no local assets except images)
- Always include the `<script id="report-meta">` JSON block for indexing
- Always include both `<meta name="report-*">` tags AND the JSON block
- Use semantic HTML: `<section>`, `<table>`, `<details>`, `<pre><code>`
- Keep reports focused — one topic per report
- Print only ONE line to the terminal after generating: `Report: {title} -> {filepath}`

View File

@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Report CLI — file organization, indexing, and opening for Claude session reports."""
import argparse
import json
import os
import platform
import re
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
REPORTS_DIR = Path.home() / "claude-reports"
INDEX_FILE = REPORTS_DIR / "index.json"
def slugify(text: str) -> str:
"""Convert text to URL-friendly slug."""
text = text.lower().strip()
text = re.sub(r'[^\w\s-]', '', text)
text = re.sub(r'[\s_]+', '-', text)
text = re.sub(r'-+', '-', text)
return text[:60].strip('-')
def get_path(project: str, title: str, report_type: str) -> str:
"""Generate report file path and ensure directories exist."""
now = datetime.now()
date_str = now.strftime("%Y-%m-%d")
time_str = now.strftime("%H%M%S")
slug = slugify(title)
report_dir = REPORTS_DIR / project / date_str
report_dir.mkdir(parents=True, exist_ok=True)
filename = f"{time_str}-{slug}.html"
return str(report_dir / filename)
def load_index() -> list:
"""Load index.json, return empty list on failure."""
try:
if INDEX_FILE.exists():
return json.loads(INDEX_FILE.read_text())
except Exception:
pass
return []
def save_index(entries: list) -> None:
"""Save index.json."""
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
INDEX_FILE.write_text(json.dumps(entries, indent=2))
def extract_meta_from_html(filepath: Path) -> dict:
"""Extract report metadata from HTML file."""
meta = {"title": filepath.stem, "type": "task-completion"}
try:
content = filepath.read_text()
# Try embedded JSON metadata first
json_match = re.search(r'<script[^>]*id="report-meta"[^>]*>(.*?)</script>', content, re.DOTALL)
if json_match:
data = json.loads(json_match.group(1))
meta.update(data)
return meta
# Fall back to meta tags
for tag in re.finditer(r'<meta\s+name="report-(\w+)"\s+content="([^"]*)"', content):
meta[tag.group(1)] = tag.group(2)
# Try title tag
title_match = re.search(r'<title>(.*?)</title>', content)
if title_match:
meta["title"] = title_match.group(1)
except Exception:
pass
return meta
def add_to_index(filepath: str) -> None:
"""Add a report to the index."""
path = Path(filepath)
if not path.exists():
return
rel = path.relative_to(REPORTS_DIR)
parts = rel.parts # project/date/filename.html
if len(parts) < 3:
return
project = parts[0]
date_str = parts[1]
time_str = path.stem.split('-')[0] if '-' in path.stem else "000000"
meta = extract_meta_from_html(path)
entry = {
"path": str(rel),
"project": project,
"title": meta.get("title", path.stem),
"type": meta.get("type", "task-completion"),
"date": date_str,
"time": f"{time_str[:2]}:{time_str[2:4]}:{time_str[4:6]}" if len(time_str) >= 6 else time_str,
}
entries = load_index()
# Remove existing entry for same path
entries = [e for e in entries if e.get("path") != str(rel)]
entries.append(entry)
# Sort by date+time descending
entries.sort(key=lambda e: (e.get("date", ""), e.get("time", "")), reverse=True)
save_index(entries)
def open_report(filepath: str) -> None:
"""Open report in browser (cross-platform)."""
path = Path(filepath)
if not path.exists():
print(f"Error: {filepath} not found", file=sys.stderr)
sys.exit(1)
# Add to index
try:
if path.is_relative_to(REPORTS_DIR):
add_to_index(filepath)
except Exception:
pass
# Open in browser — cross-platform
system = platform.system()
if system == "Darwin":
subprocess.run(["open", str(path)], check=False)
elif system == "Linux":
subprocess.run(["xdg-open", str(path)], check=False)
elif system == "Windows":
os.startfile(str(path))
else:
print(f"Auto-open not supported on {system}. Open manually: {path}")
return
print(f"Opened: {path.name}")
def list_reports(project: str = None, report_type: str = None, days: int = 30) -> None:
"""List reports as JSON, filtered by project/type/days."""
entries = load_index()
# If index is empty or corrupt, rebuild
if not entries:
rebuild_index()
entries = load_index()
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
filtered = []
for e in entries:
if e.get("date", "") < cutoff:
continue
if project and e.get("project") != project:
continue
if report_type and e.get("type") != report_type:
continue
filtered.append(e)
print(json.dumps(filtered, indent=2))
def rebuild_index() -> None:
"""Rebuild index.json from disk scan."""
entries = []
if not REPORTS_DIR.exists():
save_index(entries)
return
for html_file in sorted(REPORTS_DIR.rglob("*.html"), reverse=True):
try:
rel = html_file.relative_to(REPORTS_DIR)
parts = rel.parts
if len(parts) < 3:
continue
project = parts[0]
date_str = parts[1]
time_str = html_file.stem.split('-')[0] if '-' in html_file.stem else "000000"
meta = extract_meta_from_html(html_file)
entries.append({
"path": str(rel),
"project": project,
"title": meta.get("title", html_file.stem),
"type": meta.get("type", "task-completion"),
"date": date_str,
"time": f"{time_str[:2]}:{time_str[2:4]}:{time_str[4:6]}" if len(time_str) >= 6 else time_str,
})
except Exception:
continue
entries.sort(key=lambda e: (e.get("date", ""), e.get("time", "")), reverse=True)
save_index(entries)
print(f"Indexed {len(entries)} reports")
def main():
parser = argparse.ArgumentParser(description="Report CLI for Claude sessions")
parser.add_argument("--path", action="store_true", help="Generate report file path")
parser.add_argument("--open", metavar="FILE", help="Open report in browser")
parser.add_argument("--list", action="store_true", help="List reports as JSON")
parser.add_argument("--index", action="store_true", help="Rebuild index from disk")
parser.add_argument("--project", help="Project name")
parser.add_argument("--title", help="Report title")
parser.add_argument("--type", dest="report_type", help="Report type",
choices=["task-completion", "research", "audit", "error-blocker", "comparison"])
parser.add_argument("--days", type=int, default=30, help="Days to look back (default: 30)")
args = parser.parse_args()
if args.path:
if not args.project or not args.title or not args.report_type:
parser.error("--path requires --project, --title, and --type")
print(get_path(args.project, args.title, args.report_type))
elif args.open:
open_report(args.open)
elif args.list:
list_reports(args.project, args.report_type, args.days)
elif args.index:
rebuild_index()
else:
parser.print_help()
if __name__ == "__main__":
main()