Merge 29ad8a6741 into f6656c1256
This commit is contained in:
commit
db464aef17
|
|
@ -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}`
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue