From 29ad8a6741aad364fad2ee3201483f7c417cd539 Mon Sep 17 00:00:00 2001 From: Marcin Dudek Date: Fri, 10 Apr 2026 13:31:50 +0200 Subject: [PATCH] =?UTF-8?q?Add=20html-report=20skill=20=E2=80=94=20self-co?= =?UTF-8?q?ntained=20HTML=20reports=20for=20long=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teaches Claude to generate structured HTML reports instead of dumping long text to the terminal. Includes a zero-dependency Python helper script for path generation, index management, and cross-platform opening. --- skills/html-report/SKILL.md | 171 ++++++++++++++++++++ skills/html-report/scripts/report.py | 232 +++++++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 skills/html-report/SKILL.md create mode 100755 skills/html-report/scripts/report.py diff --git a/skills/html-report/SKILL.md b/skills/html-report/SKILL.md new file mode 100644 index 00000000..bdab9d8b --- /dev/null +++ b/skills/html-report/SKILL.md @@ -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 + + + + + + REPORT_TITLE + + + + + + + + +
+

REPORT_TITLE

+
+ PROJECT_NAME + YYYY-MM-DD HH:MM + REPORT_TYPE +
+
+
+ +
+ + + +``` + +## Content Patterns + +### Pass/Fail Row (for audit tables) +```html + + Check Name + ✓ Pass + Details about what was checked + +``` + +### File Change Row (for task-completion) +```html + + path/to/file.py + Modified + Brief description of change + +``` + +### Comparison Cell +```html + + Option Name
+ + Pro point
+ - Con point + +``` + +## 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: `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 ` — 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 `', 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'(.*?)', 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()