Skill: charlie-morrison/client-report-generator (version 1.0.1)

--- name: client-report-generator description: Generate professional client-facing reports from raw data, metrics, and KPIs. Supports analytics summaries, project status reports, monthly/weekly performance reviews, and campaign results. Use when asked to create a client report, generate a performance report, summarize metrics for a client, build a weekly/monthly report, create a project status update, format analytics data into a report, or produce a deliverable report from raw data. Triggers on "client report", "performance report", "weekly report", "monthly report", "status report", "generate report from data", "metrics report", "campaign report", "analytics summary". --- # Client Report Generator Generate polished, client-ready reports from raw data. Feed it CSV, JSON, analytics exports, or plain text metrics — get back a professional report formatted for delivery. ## Workflow ### 1. Ingest Data Determine input type and extract data: - **CSV/TSV file** → Read and parse into structured data - **JSON file/API response** → Parse and extract key metrics - **Pasted text/numbers** → Parse inline data - **URL (dashboard/analytics)** → Use `web_fetch` to extract visible data - **Multiple sources** → Combine into unified dataset Run `scripts/parse_data.py` to normalize any structured input: ```bash python3 scripts/parse_data.py <input-file> [--format csv|json|auto] ``` Output: normalized JSON with detected metrics, dimensions, and time ranges. ### 2. Analyze & Summarize Before generating the report, analyze the data: 1. **Key metrics** — Identify top-line numbers (revenue, growth, conversions, etc.) 2. **Trends** — Period-over-period changes (up/down/flat + percentage) 3. **Highlights** — Best-performing items, records, milestones 4. **Concerns** — Underperforming areas, declining trends, anomalies 5. **Context** — Infer reporting period, industry, and audience from data ### 3. Select Report Template Choose based on user request or data type. See `references/report-templates.md` for detailed templates. | Template | Best For | |----------|----------| | **Performance Review** | Monthly/weekly KPI summaries | | **Campaign Report** | Marketing campaign results | | **Project Status** | Development/project progress updates | | **Analytics Summary** | Website/app analytics overview | | **Custom** | User-specified structure | ### 4. Generate Report Structure every report with: ``` # [Report Title] **Period:** [date range] | **Prepared for:** [client name] | **Date:** [today] ## Executive Summary [2-3 sentences: what happened, key takeaway, recommendation] ## Key Metrics | Metric | Current | Previous | Change | |--------|---------|----------|--------| | ... | ... | ... | +X% | ## [Detailed Sections — template-specific] ## Highlights & Wins - ... ## Areas for Improvement - ... ## Recommendations & Next Steps 1. ... ``` ### 5. Format Output **Default output:** Markdown (clean, portable, renders in most tools) **Other formats on request:** - **HTML** → Run `scripts/report_to_html.py` for styled HTML with inline CSS - **Plain text** → Stripped formatting for email body - **Structured data** → JSON summary of all metrics and analysis ```bash python3 scripts/report_to_html.py <report.md> [--template default|minimal|branded] ``` ## Customization Options Users can specify: - **Client name** — appears in header and throughout - **Reporting period** — "last week", "March 2026", "Q1 2026" - **Tone** — professional (default), friendly, executive-brief - **Sections** — include/exclude specific sections - **Branding** — company name, colors (for HTML output) - **Comparison** — vs previous period, vs target/goal, vs benchmark - **Charts** — include ASCII/text charts for key metrics (when data supports it) - **Language** — generate in specified language ## Data Handling - Automatically detect metric types (currency, percentages, counts, rates) - Format numbers appropriately (commas, decimal places, currency symbols) - Calculate period-over-period changes when historical data is available - Flag statistical anomalies or significant changes (>20% swings) - Round appropriately for audience (executives get rounded numbers, analysts get precision) ## Tips - For executive audiences: lead with the bottom line, keep it to 1 page equivalent - For marketing reports: emphasize ROI and conversion metrics - For project status: focus on timeline, blockers, and deliverables - When data is incomplete: note gaps clearly, don't fabricate numbers - Include "So what?" after every metric — explain why the number matters

path: STATUS.md
content: # client-report-generator — Status

**Status:** Ready
**Price:** $79
**Created:** 2026-03-27

## What It Does
Generates professional client-facing reports from CSV, JSON, or raw data. Includes data parsing, metric detection, trend analysis, and HTML export with multiple themes.

## Components
- `SKILL.md` — Main skill instructions with workflow
- `scripts/parse_data.py` — Data parser (CSV/TSV/JSON → normalized metrics)
- `scripts/report_to_html.py` — Markdown → styled HTML converter (3 themes)
- `references/report-templates.md` — 4 detailed report templates

## Testing
- [x] parse_data.py tested with CSV data (currency, percentage, count detection works)
- [x] parse_data.py tested with JSON array data
- [x] report_to_html.py tested with sample report (default theme)
- [x] report_to_html.py tested with branded theme
- [x] All scripts executable

## Next Steps
- Package to .skill file
- Publish to ClawHub


path: log.md
content: # client-report-generator — Log

## 2026-03-27

### Done
- Initialized skill with scripts, references, assets directories
- Wrote SKILL.md with comprehensive workflow (ingest → analyze → template → generate → format)
- Built `scripts/parse_data.py` — auto-detects CSV/TSV/JSON, classifies metric types (currency, percentage, count, rate, text), computes stats
- Built `scripts/report_to_html.py` — converts Markdown reports to styled HTML with inline CSS, 3 themes (default, minimal, branded)
- Created `references/report-templates.md` with 4 detailed templates: Performance Review, Campaign Report, Project Status, Analytics Summary
- Tested all scripts with sample data — all working
- Removed empty assets/ directory (not needed)
- Created STATUS.md

### Decisions
- Priced at $79 — higher than basic tools ($49) because it solves an expensive recurring problem for agencies
- Focused on practical report types that agencies actually send (not academic/internal)
- 3 HTML themes to cover different brand aesthetics
- No external dependencies — pure Python stdlib for maximum compatibility

### Blockers
- None — ready to package


path: references/report-templates.md
content: # Report Templates

Detailed templates for each report type. Use these as starting structures and adapt to the specific data available.

## Performance Review Template

Best for: monthly/weekly KPI summaries, business metrics reviews.

```
# [Business Name] Performance Report
**Period:** [date range]  |  **Prepared for:** [client]  |  **Date:** [today]

## Executive Summary
[2-3 sentences: overall performance, key win, main concern]

## Key Performance Indicators
| Metric | This Period | Last Period | Change | Target | Status |
|--------|------------|-------------|--------|--------|--------|

## Revenue & Financial
- Total revenue: $X (+Y% vs prior period)
- Average order value: $X
- Revenue by channel/product breakdown

## Traffic & Engagement
- Total sessions/visits
- Unique visitors
- Bounce rate, time on site
- Top pages/content

## Conversion & Sales
- Conversion rate
- Leads generated
- Sales closed
- Pipeline value

## Highlights
- [Best performing metric/channel/campaign]
- [Notable achievement or milestone]

## Areas Requiring Attention
- [Declining metrics with context]
- [Missed targets with root cause analysis]

## Recommendations
1. [Action item with expected impact]
2. [Action item with expected impact]
3. [Action item with expected impact]

## Next Period Focus
- [Priority 1]
- [Priority 2]
```

## Campaign Report Template

Best for: marketing campaign results, ad performance, email campaign summaries.

```
# [Campaign Name] — Results Report
**Campaign period:** [start] — [end]  |  **Client:** [name]  |  **Date:** [today]

## Campaign Overview
- **Objective:** [what the campaign aimed to achieve]
- **Channels:** [platforms/channels used]
- **Budget:** $[total spent] of $[allocated]
- **Target audience:** [description]

## Results Summary
| Metric | Result | Target | vs Target |
|--------|--------|--------|-----------|
| Impressions | | | |
| Clicks | | | |
| CTR | | | |
| Conversions | | | |
| CPA | | | |
| ROAS | | | |

## Channel Breakdown
### [Channel 1]
- Spend: $X | Impressions: X | Clicks: X | CTR: X%
- Top performing ad/creative: [description]

### [Channel 2]
- [same structure]

## Audience Insights
- Best performing segment: [description]
- Geographic performance: [top regions]
- Device split: [desktop vs mobile]

## Creative Performance
| Creative | Impressions | CTR | Conversions |
|----------|------------|-----|-------------|

## Key Learnings
1. [What worked and why]
2. [What didn't work and why]
3. [Unexpected finding]

## Recommendations for Next Campaign
1. [Tactical recommendation]
2. [Budget allocation recommendation]
3. [Creative/messaging recommendation]
```

## Project Status Template

Best for: development progress, project milestones, sprint reviews.

```
# [Project Name] — Status Report
**Period:** [date range]  |  **Prepared for:** [stakeholder]  |  **Date:** [today]
**Overall status:** [On Track / At Risk / Delayed]

## Summary
[2-3 sentences: what was accomplished, current state, key decision needed]

## Progress Overview
| Milestone | Target Date | Status | % Complete |
|-----------|------------|--------|------------|

## Completed This Period
- [Deliverable 1] — [brief description]
- [Deliverable 2] — [brief description]

## In Progress
| Task | Owner | Due | Status |
|------|-------|-----|--------|

## Blockers & Risks
| Issue | Impact | Mitigation | Owner |
|-------|--------|------------|-------|

## Budget Status
- Spent to date: $X of $Y (Z%)
- Projected final cost: $X
- Variance: [over/under by $X]

## Decisions Needed
1. [Decision description + options + recommendation]

## Next Period Plan
- [Priority deliverable 1]
- [Priority deliverable 2]
```

## Analytics Summary Template

Best for: website/app analytics, traffic reports, user behavior analysis.

```
# [Site/App Name] Analytics Summary
**Period:** [date range]  |  **Compared to:** [previous period]  |  **Date:** [today]

## At a Glance
| Metric | Value | vs Previous | Trend |
|--------|-------|-------------|-------|
| Sessions | | | |
| Users | | | |
| Pageviews | | | |
| Bounce Rate | | | |
| Avg. Session Duration | | | |
| Pages/Session | | | |

## Traffic Sources
| Source | Sessions | % of Total | Conversion Rate |
|--------|----------|-----------|-----------------|
| Organic Search | | | |
| Direct | | | |
| Referral | | | |
| Social | | | |
| Email | | | |
| Paid | | | |

## Top Pages
| Page | Views | Avg. Time | Bounce Rate |
|------|-------|-----------|-------------|

## User Behavior
- New vs returning: X% / Y%
- Top entry pages: [list]
- Top exit pages: [list]
- Search terms (if available): [top terms]

## Goals & Conversions
| Goal | Completions | Rate | Value |
|------|------------|------|-------|

## Technical
- Page load time: Xs (target: Ys)
- Mobile vs desktop: X% / Y%
- Top browsers: [list]

## Insights & Recommendations
1. [Data-driven insight + suggested action]
2. [Data-driven insight + suggested action]
```


path: scripts/parse_data.py
content: #!/usr/bin/env python3
"""Parse CSV, TSV, or JSON data into normalized metrics format for report generation."""

import argparse
import csv
import json
import sys
import os
from io import StringIO


def detect_format(filepath):
    """Auto-detect file format from extension and content."""
    ext = os.path.splitext(filepath)[1].lower()
    if ext in ('.json',):
        return 'json'
    if ext in ('.csv',):
        return 'csv'
    if ext in ('.tsv',):
        return 'tsv'
    # Sniff content
    with open(filepath, 'r', encoding='utf-8') as f:
        sample = f.read(2048)
    try:
        json.loads(sample if len(sample) < 2048 else sample)
        return 'json'
    except (json.JSONDecodeError, ValueError):
        pass
    if '\t' in sample and sample.count('\t') > sample.count(','):
        return 'tsv'
    return 'csv'


def detect_metric_type(values):
    """Detect whether a column contains currency, percentages, counts, or rates."""
    sample = [str(v).strip() for v in values if v not in (None, '', 'N/A', '-')][:20]
    if not sample:
        return 'unknown'

    currency_count = sum(1 for v in sample if v.startswith('$') or v.startswith('€') or v.startswith('£'))
    pct_count = sum(1 for v in sample if v.endswith('%'))

    if currency_count > len(sample) * 0.5:
        return 'currency'
    if pct_count > len(sample) * 0.5:
        return 'percentage'

    # Try to parse as numbers
    numeric_count = 0
    has_decimal = False
    for v in sample:
        cleaned = v.replace(',', '').replace('$', '').replace('€', '').replace('£', '').replace('%', '')
        try:
            num = float(cleaned)
            numeric_count += 1
            if '.' in cleaned:
                has_decimal = True
        except ValueError:
            pass

    if numeric_count > len(sample) * 0.5:
        if has_decimal:
            return 'rate'
        return 'count'
    return 'text'


def parse_numeric(value):
    """Parse a string value into a number, stripping currency/percent symbols."""
    if value in (None, '', 'N/A', '-'):
        return None
    s = str(value).strip().replace(',', '').replace('$', '').replace('€', '').replace('£', '').replace('%', '')
    try:
        return float(s)
    except ValueError:
        return None


def compute_stats(values):
    """Compute basic statistics for a list of numeric values."""
    nums = [v for v in values if v is not None]
    if not nums:
        return {}
    total = sum(nums)
    avg = total / len(nums)
    return {
        'count': len(nums),
        'total': round(total, 2),
        'average': round(avg, 2),
        'min': round(min(nums), 2),
        'max': round(max(nums), 2),
    }


def parse_csv_data(filepath, delimiter=','):
    """Parse CSV/TSV file into structured data."""
    with open(filepath, 'r', encoding='utf-8') as f:
        content = f.read()

    reader = csv.DictReader(StringIO(content), delimiter=delimiter)
    rows = list(reader)

    if not rows:
        return {'error': 'No data rows found', 'headers': [], 'rows': []}

    headers = list(rows[0].keys())

    # Analyze each column
    columns = {}
    for header in headers:
        values = [row.get(header, '') for row in rows]
        metric_type = detect_metric_type(values)
        col_info = {
            'name': header,
            'type': metric_type,
            'sample_values': values[:5],
        }
        if metric_type in ('currency', 'percentage', 'count', 'rate'):
            numeric_values = [parse_numeric(v) for v in values]
            col_info['stats'] = compute_stats(numeric_values)
        columns[header] = col_info

    return {
        'format': 'csv',
        'row_count': len(rows),
        'headers': headers,
        'columns': columns,
        'rows': rows,
    }


def parse_json_data(filepath):
    """Parse JSON file into structured data."""
    with open(filepath, 'r', encoding='utf-8') as f:
        data = json.load(f)

    # Handle array of objects
    if isinstance(data, list) and data and isinstance(data[0], dict):
        headers = list(data[0].keys())
        columns = {}
        for header in headers:
            values = [row.get(header, '') for row in data]
            metric_type = detect_metric_type(values)
            col_info = {
                'name': header,
                'type': metric_type,
                'sample_values': values[:5],
            }
            if metric_type in ('currency', 'percentage', 'count', 'rate'):
                numeric_values = [parse_numeric(v) for v in values]
                col_info['stats'] = compute_stats(numeric_values)
            columns[header] = col_info

        return {
            'format': 'json_array',
            'row_count': len(data),
            'headers': headers,
            'columns': columns,
            'rows': data,
        }

    # Handle flat key-value object
    if isinstance(data, dict):
        metrics = {}
        for key, value in data.items():
            if isinstance(value, (int, float)):
                metrics[key] = {
                    'value': value,
                    'type': 'percentage' if 'rate' in key.lower() or 'pct' in key.lower() or 'percent' in key.lower() else 'count',
                }
            elif isinstance(value, str):
                parsed = parse_numeric(value)
                if parsed is not None:
                    metrics[key] = {'value': parsed, 'type': detect_metric_type([value])}
                else:
                    metrics[key] = {'value': value, 'type': 'text'}
            elif isinstance(value, list):
                metrics[key] = {'value': f'[{len(value)} items]', 'type': 'list', 'count': len(value)}
            elif isinstance(value, dict):
                metrics[key] = {'value': f'{{{len(value)} keys}}', 'type': 'object', 'keys': list(value.keys())}

        return {
            'format': 'json_object',
            'metrics': metrics,
        }

    return {'format': 'json_unknown', 'raw_type': type(data).__name__}


def main():
    parser = argparse.ArgumentParser(description='Parse data files into normalized metrics format')
    parser.add_argument('input', help='Input file path (CSV, TSV, or JSON)')
    parser.add_argument('--format', choices=['csv', 'tsv', 'json', 'auto'], default='auto',
                        help='Input format (default: auto-detect)')
    parser.add_argument('--output', '-o', help='Output file path (default: stdout)')
    args = parser.parse_args()

    if not os.path.exists(args.input):
        print(json.dumps({'error': f'File not found: {args.input}'}), file=sys.stderr)
        sys.exit(1)

    fmt = args.format if args.format != 'auto' else detect_format(args.input)

    if fmt == 'json':
        result = parse_json_data(args.input)
    elif fmt == 'tsv':
        result = parse_csv_data(args.input, delimiter='\t')
    else:
        result = parse_csv_data(args.input, delimiter=',')

    result['source_file'] = os.path.basename(args.input)
    output = json.dumps(result, indent=2, default=str)

    if args.output:
        with open(args.output, 'w', encoding='utf-8') as f:
            f.write(output)
        print(f'Parsed data written to {args.output}')
    else:
        print(output)


if __name__ == '__main__':
    main()


path: scripts/report_to_html.py
content: #!/usr/bin/env python3
"""Convert a Markdown report to styled HTML with inline CSS."""

import argparse
import re
import sys
import os

TEMPLATES = {
    'default': {
        'bg': '#ffffff',
        'text': '#333333',
        'accent': '#2563eb',
        'header_bg': '#f8fafc',
        'border': '#e2e8f0',
        'font': "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
    },
    'minimal': {
        'bg': '#ffffff',
        'text': '#1a1a1a',
        'accent': '#000000',
        'header_bg': '#fafafa',
        'border': '#eeeeee',
        'font': "'Georgia', 'Times New Roman', serif",
    },
    'branded': {
        'bg': '#ffffff',
        'text': '#1e293b',
        'accent': '#7c3aed',
        'header_bg': '#faf5ff',
        'border': '#e9d5ff',
        'font': "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
    },
}


def markdown_to_html(md_text, template='default'):
    """Convert markdown report to styled HTML."""
    colors = TEMPLATES.get(template, TEMPLATES['default'])
    lines = md_text.split('\n')
    html_parts = []
    in_table = False
    in_list = False
    in_code = False
    table_rows = []

    for line in lines:
        stripped = line.strip()

        # Code blocks
        if stripped.startswith('```'):
            if in_code:
                html_parts.append('</pre>')
                in_code = False
            else:
                html_parts.append(f'<pre style="background:{colors["header_bg"]};border:1px solid {colors["border"]};border-radius:6px;padding:12px;overflow-x:auto;font-size:13px;">')
                in_code = True
            continue
        if in_code:
            html_parts.append(re.sub(r'[<>&]', lambda m: {'<': '&lt;', '>': '&gt;', '&': '&amp;'}[m.group()], line))
            continue

        # Close list if needed
        if in_list and not stripped.startswith('- ') and not stripped.startswith('* ') and not re.match(r'^\d+\.\s', stripped):
            html_parts.append('</ul>' if html_parts[-1] != '</ol>' else '')
            in_list = False

        # Tables
        if '|' in stripped and stripped.startswith('|'):
            cells = [c.strip() for c in stripped.split('|')[1:-1]]
            if all(re.match(r'^[-:]+$', c) for c in cells):
                continue  # separator row
            if not in_table:
                in_table = True
                table_rows = []
            table_rows.append(cells)
            continue
        elif in_table:
            # Render table
            html_parts.append(f'<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">')
            for i, row in enumerate(table_rows):
                tag = 'th' if i == 0 else 'td'
                bg = colors['header_bg'] if i == 0 else (colors['bg'] if i % 2 == 0 else '#f9fafb')
                weight = 'font-weight:600;' if i == 0 else ''
                cells_html = ''.join(
                    f'<{tag} style="padding:10px 14px;border:1px solid {colors["border"]};text-align:left;{weight}">{apply_inline(c)}</{tag}>'
                    for c in row
                )
                html_parts.append(f'<tr style="background:{bg}">{cells_html}</tr>')
            html_parts.append('</table>')
            in_table = False
            table_rows = []

        # Headers
        if stripped.startswith('# '):
            text = stripped[2:]
            html_parts.append(f'<h1 style="color:{colors["accent"]};font-size:28px;margin:32px 0 16px;padding-bottom:8px;border-bottom:3px solid {colors["accent"]};">{apply_inline(text)}</h1>')
        elif stripped.startswith('## '):
            text = stripped[3:]
            html_parts.append(f'<h2 style="color:{colors["text"]};font-size:22px;margin:28px 0 12px;padding-bottom:6px;border-bottom:1px solid {colors["border"]};">{apply_inline(text)}</h2>')
        elif stripped.startswith('### '):
            text = stripped[4:]
            html_parts.append(f'<h3 style="color:{colors["text"]};font-size:18px;margin:24px 0 10px;">{apply_inline(text)}</h3>')
        # Unordered list
        elif stripped.startswith('- ') or stripped.startswith('* '):
            if not in_list:
                html_parts.append('<ul style="margin:8px 0;padding-left:24px;">')
                in_list = True
            text = stripped[2:]
            html_parts.append(f'<li style="margin:4px 0;line-height:1.6;">{apply_inline(text)}</li>')
        # Ordered list
        elif re.match(r'^\d+\.\s', stripped):
            if not in_list:
                html_parts.append('<ol style="margin:8px 0;padding-left:24px;">')
                in_list = True
            text = re.sub(r'^\d+\.\s', '', stripped)
            html_parts.append(f'<li style="margin:4px 0;line-height:1.6;">{apply_inline(text)}</li>')
        # Horizontal rule
        elif stripped in ('---', '***', '___'):
            html_parts.append(f'<hr style="border:none;border-top:1px solid {colors["border"]};margin:24px 0;">')
        # Empty line
        elif not stripped:
            html_parts.append('')
        # Paragraph
        else:
            html_parts.append(f'<p style="margin:8px 0;line-height:1.7;">{apply_inline(stripped)}</p>')

    # Close any open elements
    if in_list:
        html_parts.append('</ul>')
    if in_table and table_rows:
        html_parts.append(f'<table style="width:100%;border-collapse:collapse;margin:16px 0;">')
        for i, row in enumerate(table_rows):
            tag = 'th' if i == 0 else 'td'
            cells_html = ''.join(f'<{tag} style="padding:10px 14px;border:1px solid {colors["border"]};text-align:left;">{apply_inline(c)}</{tag}>' for c in row)
            html_parts.append(f'<tr>{cells_html}</tr>')
        html_parts.append('</table>')

    body = '\n'.join(html_parts)

    return f'''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
  @media print {{
    body {{ padding: 0; max-width: 100%; }}
  }}
</style>
</head>
<body style="font-family:{colors['font']};color:{colors['text']};background:{colors['bg']};max-width:800px;margin:0 auto;padding:32px 24px;line-height:1.6;">
{body}
<footer style="margin-top:48px;padding-top:16px;border-top:1px solid {colors['border']};font-size:12px;color:#94a3b8;text-align:center;">
Generated by Client Report Generator
</footer>
</body>
</html>'''


def apply_inline(text):
    """Apply inline markdown formatting."""
    # Bold
    text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
    # Italic
    text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
    # Inline code
    text = re.sub(r'`(.+?)`', r'<code style="background:#f1f5f9;padding:2px 6px;border-radius:3px;font-size:13px;">\1</code>', text)
    # Links
    text = re.sub(r'\[(.+?)\]\((.+?)\)', r'<a href="\2" style="color:#2563eb;">\1</a>', text)
    return text


def main():
    parser = argparse.ArgumentParser(description='Convert Markdown report to styled HTML')
    parser.add_argument('input', help='Input Markdown file')
    parser.add_argument('--template', choices=['default', 'minimal', 'branded'], default='default',
                        help='HTML template style (default: default)')
    parser.add_argument('--output', '-o', help='Output HTML file (default: same name with .html)')
    args = parser.parse_args()

    if not os.path.exists(args.input):
        print(f'Error: File not found: {args.input}', file=sys.stderr)
        sys.exit(1)

    with open(args.input, 'r', encoding='utf-8') as f:
        md_content = f.read()

    html = markdown_to_html(md_content, args.template)

    output_path = args.output or os.path.splitext(args.input)[0] + '.html'
    with open(output_path, 'w', encoding='utf-8') as f:
        f.write(html)

    print(f'HTML report written to {output_path}')


if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)