main
  1#!/usr/bin/env -S uv run --script
  2# /// script
  3# requires-python = ">=3.11"
  4# dependencies = []
  5# ///
  6"""
  7usage-report: Generate usage reports from collected metrics.
  8
  9Reads JSON files from ~/.local/share/usage-metrics/ and produces
 10markdown or HTML reports for awareness, pruning, and optimization.
 11"""
 12import argparse
 13import html
 14import json
 15import os
 16import sys
 17from collections import Counter
 18from datetime import date, timedelta
 19from pathlib import Path
 20
 21METRICS_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "usage-metrics"
 22
 23
 24def load_host_data(days: int) -> dict[str, list[dict]]:
 25    hosts_dir = METRICS_DIR / "hosts"
 26    if not hosts_dir.exists():
 27        return {}
 28    cutoff = date.today() - timedelta(days=days)
 29    result: dict[str, list[dict]] = {}
 30    for host_dir in hosts_dir.iterdir():
 31        if not host_dir.is_dir():
 32            continue
 33        hostname = host_dir.name
 34        result[hostname] = []
 35        for f in sorted(host_dir.iterdir()):
 36            if not f.name.endswith(".json"):
 37                continue
 38            try:
 39                file_date = date.fromisoformat(f.stem)
 40            except ValueError:
 41                continue
 42            if file_date < cutoff:
 43                continue
 44            try:
 45                result[hostname].append(json.loads(f.read_text()))
 46            except (json.JSONDecodeError, OSError):
 47                continue
 48    return result
 49
 50
 51def load_shared_data(days: int) -> list[dict]:
 52    shared_dir = METRICS_DIR / "shared"
 53    if not shared_dir.exists():
 54        return []
 55    cutoff = date.today() - timedelta(days=days)
 56    result = []
 57    for f in sorted(shared_dir.iterdir()):
 58        if not f.name.endswith(".json"):
 59            continue
 60        try:
 61            file_date = date.fromisoformat(f.stem)
 62        except ValueError:
 63            continue
 64        if file_date < cutoff:
 65            continue
 66        try:
 67            result.append(json.loads(f.read_text()))
 68        except (json.JSONDecodeError, OSError):
 69            continue
 70    return result
 71
 72
 73def aggregate_commands(data_list: list[dict], key: str = "shell") -> Counter:
 74    total = Counter()
 75    for data in data_list:
 76        section = data.get(key, {})
 77        cmds = section.get("all_commands", {}) or section.get("all_binaries", {})
 78        if isinstance(cmds, dict):
 79            total.update(cmds)
 80    return total
 81
 82
 83def aggregate_pi(shared_list: list[dict]) -> dict:
 84    tools = Counter()
 85    skills = Counter()
 86    models = Counter()
 87    providers = Counter()
 88    total_sessions = 0
 89    for data in shared_list:
 90        pi = data.get("pi", {})
 91        total_sessions += pi.get("sessions_count", 0)
 92        tools.update(pi.get("tools", {}))
 93        skills.update(pi.get("skills_loaded", {}))
 94        models.update(pi.get("models_used", {}))
 95        providers.update(pi.get("providers_used", {}))
 96    all_declared = set()
 97    if shared_list:
 98        for data in shared_list:
 99            all_declared.update(data.get("pi", {}).get("skills_never_used", []))
100            all_declared.update(data.get("pi", {}).get("skills_loaded", {}).keys())
101    never_used = sorted(s for s in all_declared if s not in skills)
102    return {
103        "sessions": total_sessions,
104        "tools": tools,
105        "skills": skills,
106        "skills_never_used": never_used,
107        "models": models,
108        "providers": providers,
109    }
110
111
112# ── Markdown report ──────────────────────────────────────────────────
113
114def format_counter_md(counter: Counter, limit: int = 20) -> str:
115    items = counter.most_common(limit)
116    if not items:
117        return "  (no data)\n"
118    max_val = items[0][1] if items else 1
119    max_name = max(len(name) for name, _ in items)
120    lines = []
121    for name, count in items:
122        bar_len = max(1, int(50 * count / max_val))
123        bar = "█" * bar_len
124        lines.append(f"  {name:<{max_name}}  {count:>7}  {bar}")
125    return "\n".join(lines) + "\n"
126
127
128def generate_md(days: int, host_data: dict, shared_data: list) -> str:
129    lines = [f"# Usage Report — last {days} days", f"Generated: {date.today()}\n"]
130    for hostname, data_list in sorted(host_data.items()):
131        if not data_list:
132            continue
133        lines.append(f"## Host: {hostname} ({len(data_list)} days of data)\n")
134        shell_cmds = aggregate_commands(data_list, "shell")
135        total_cmds = sum(shell_cmds.values())
136        lines.append(f"### Shell Commands\nTotal: {total_cmds} commands, {len(shell_cmds)} unique\n")
137        lines.append(format_counter_md(shell_cmds))
138        acct_cmds = aggregate_commands(data_list, "process_accounting")
139        if acct_cmds:
140            lines.append(f"### Process Accounting\nTotal: {sum(acct_cmds.values())} executions, {len(acct_cmds)} unique\n")
141            lines.append(format_counter_md(acct_cmds))
142        latest = data_list[-1]
143        nix = latest.get("nix_packages", {})
144        if isinstance(nix, dict) and "total_bins" in nix:
145            all_used = set(shell_cmds.keys()) | set(acct_cmds.keys())
146            unused_bins = set(nix.get("unused_bins", [])) - all_used
147            lines.append(f"### Nix Packages\nInstalled: {nix['total_bins']} | Used: {nix['total_bins'] - len(unused_bins)} | Never used: {len(unused_bins)}\n")
148        emacs = latest.get("emacs", {})
149        if isinstance(emacs, dict) and "declared_count" in emacs:
150            unused_pkgs = emacs.get("unused_packages", [])
151            lines.append(f"### Emacs Packages\nDeclared: {emacs['declared_count']} | Unused: {len(unused_pkgs)}\n")
152        custom = latest.get("custom_tools", {})
153        if isinstance(custom, dict) and "defined" in custom:
154            used = custom.get("used", [])
155            unused = custom.get("unused", [])
156            lines.append(f"### Custom Tools\nDefined: {len(custom['defined'])} | Used: {len(used)} | Unused: {len(unused)}\n")
157    if shared_data:
158        pi = aggregate_pi(shared_data)
159        lines.append(f"## Pi Agent\nSessions: {pi['sessions']}\n")
160        lines.append("### Tools\n" + format_counter_md(pi["tools"]))
161        lines.append("### Skills\n" + format_counter_md(pi["skills"]))
162        if pi["skills_never_used"]:
163            lines.append(f"### Never Used Skills: {', '.join(pi['skills_never_used'])}\n")
164        lines.append("### Models\n" + format_counter_md(pi["models"]))
165        lines.append("### Providers\n" + format_counter_md(pi["providers"]))
166    return "\n".join(lines)
167
168
169# ── HTML report ──────────────────────────────────────────────────────
170
171HTML_STYLE = """
172:root {
173  --bg: #0d1117; --surface: #161b22; --border: #30363d;
174  --text: #e6edf3; --text-dim: #8b949e; --text-bright: #f0f6fc;
175  --accent: #58a6ff; --green: #3fb950; --red: #f85149;
176  --orange: #d29922; --purple: #bc8cff;
177  --bar-bg: #21262d;
178}
179* { box-sizing: border-box; margin: 0; padding: 0; }
180body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
181  background: var(--bg); color: var(--text); line-height: 1.5; padding: 2rem; max-width: 1200px; margin: 0 auto; }
182h1 { color: var(--text-bright); margin-bottom: 0.5rem; font-size: 1.8rem; }
183h2 { color: var(--accent); margin: 2rem 0 1rem; font-size: 1.4rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
184h3 { color: var(--text); margin: 1.5rem 0 0.75rem; font-size: 1.1rem; }
185.subtitle { color: var(--text-dim); margin-bottom: 2rem; }
186.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0; }
187.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
188.stat-value { font-size: 1.8rem; font-weight: 700; color: var(--text-bright); }
189.stat-label { font-size: 0.85rem; color: var(--text-dim); }
190.stat-value.green { color: var(--green); }
191.stat-value.red { color: var(--red); }
192.stat-value.orange { color: var(--orange); }
193.stat-value.purple { color: var(--purple); }
194.bar-chart { margin: 0.5rem 0; }
195.bar-row { display: flex; align-items: center; margin: 2px 0; font-size: 0.85rem; font-family: 'SF Mono', 'Fira Code', monospace; }
196.bar-name { width: 220px; text-align: right; padding-right: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; }
197.bar-track { flex: 1; height: 20px; background: var(--bar-bg); border-radius: 3px; overflow: hidden; border: 1px solid var(--border); }
198.bar-fill { display: block; height: 100%; min-width: 2px; }
199.bar-fill.blue { background: var(--accent); }
200.bar-fill.green { background: var(--green); }
201.bar-fill.purple { background: var(--purple); }
202.bar-fill.orange { background: var(--orange); }
203.bar-count { width: 70px; text-align: right; padding-left: 8px; color: var(--text-dim); flex-shrink: 0; }
204details { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; margin: 0.75rem 0; }
205details > summary { padding: 0.75rem 1rem; cursor: pointer; font-weight: 600; color: var(--text); user-select: none; }
206details > summary:hover { color: var(--accent); }
207details > .content { padding: 0 1rem 1rem; }
208.tag-list { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.5rem 0; }
209.tag { background: var(--bar-bg); border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; font-size: 0.8rem; color: var(--text-dim); }
210.tag.unused { border-color: var(--red); color: var(--red); opacity: 0.8; }
211.tag.used { border-color: var(--green); color: var(--green); }
212.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin: 1.5rem 0 1rem; }
213.tab { padding: 0.5rem 1.2rem; cursor: pointer; color: var(--text-dim); border-bottom: 2px solid transparent; font-weight: 500; }
214.tab:hover { color: var(--text); }
215.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
216.tab-content { display: none; }
217.tab-content.active { display: block; }
218.services-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.3rem; margin: 0.5rem 0; }
219.service-item { font-size: 0.8rem; color: var(--text-dim); font-family: monospace; }
220"""
221
222HTML_SCRIPT = """
223function switchTab(group, tabName) {
224  document.querySelectorAll(`[data-tab-group="${group}"] .tab`).forEach(t => t.classList.remove('active'));
225  document.querySelectorAll(`[data-tab-content="${group}"]`).forEach(c => c.classList.remove('active'));
226  document.querySelector(`[data-tab-group="${group}"] [data-tab="${tabName}"]`).classList.add('active');
227  document.querySelector(`[data-tab-content="${group}"][data-tab="${tabName}"]`).classList.add('active');
228}
229"""
230
231
232def h(text: str) -> str:
233    return html.escape(str(text))
234
235
236def html_bar_chart(counter: Counter, limit: int = 20, color: str = "blue", show_all: bool = False) -> str:
237    items = counter.most_common()
238    if not items:
239        return '<div class="bar-chart"><em style="color:var(--text-dim)">No data</em></div>'
240    max_val = items[0][1]
241    top_items = items[:limit]
242    rest_items = items[limit:]
243
244    def render_rows(rows):
245        out = []
246        for name, count in rows:
247            pct = max(0.5, 100 * count / max_val) if max_val else 0
248            out.append(f'''<div class="bar-row">
249  <span class="bar-name" title="{h(name)}">{h(name)}</span>
250  <span class="bar-track"><span class="bar-fill {color}" style="width:{pct:.1f}%"></span></span>
251  <span class="bar-count">{count:,}</span>
252</div>''')
253        return "\n".join(out)
254
255    result = f'<div class="bar-chart">{render_rows(top_items)}</div>'
256
257    if rest_items:
258        result += f'''<details><summary>Show all ({len(items)} total)</summary>
259<div class="content"><div class="bar-chart">{render_rows(items)}</div></div></details>'''
260
261    return result
262
263
264def html_stat_card(value, label: str, color: str = "") -> str:
265    cls = f" {color}" if color else ""
266    return f'<div class="stat-card"><div class="stat-value{cls}">{h(str(value))}</div><div class="stat-label">{h(label)}</div></div>'
267
268
269def html_tag_list(items: list[str], css_class: str = "") -> str:
270    cls = f" {css_class}" if css_class else ""
271    return '<div class="tag-list">' + "".join(f'<span class="tag{cls}">{h(i)}</span>' for i in sorted(items)) + '</div>'
272
273
274def generate_html(days: int, host_data: dict, shared_data: list) -> str:
275    parts = []
276    parts.append(f'''<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
277<meta name="viewport" content="width=device-width, initial-scale=1">
278<title>Usage Report — last {days} days</title>
279<style>{HTML_STYLE}</style></head><body>
280<h1>Usage Report</h1>
281<p class="subtitle">Last {days} days · Generated {date.today()}</p>''')
282
283    # ── Aggregate cross-host view ──
284    all_shell = Counter()
285    all_acct = Counter()
286    all_nix_total = 0
287    all_nix_unused = set()
288    all_custom_defined = set()
289    all_custom_used = set()
290    all_services = set()
291    host_count = 0
292
293    for hostname, data_list in host_data.items():
294        if not data_list:
295            continue
296        host_count += 1
297        all_shell += aggregate_commands(data_list, "shell")
298        all_acct += aggregate_commands(data_list, "process_accounting")
299        latest = data_list[-1]
300        nix = latest.get("nix_packages", {})
301        if isinstance(nix, dict) and "total_bins" in nix:
302            all_nix_total = max(all_nix_total, nix.get("total_bins", 0))
303            unused_set = set(nix.get("unused_bins", [])) - set(all_shell.keys()) - set(all_acct.keys())
304            all_nix_unused |= unused_set
305        custom = latest.get("custom_tools", {})
306        if isinstance(custom, dict):
307            all_custom_defined.update(custom.get("defined", []))
308            all_custom_used.update(custom.get("used", []))
309        services = latest.get("services", {})
310        if isinstance(services, dict):
311            all_services.update(services.get("running", []))
312
313    pi = aggregate_pi(shared_data) if shared_data else None
314
315    # ── Overview cards ──
316    parts.append('<h2>Overview</h2>')
317    parts.append('<div class="stats-grid">')
318    parts.append(html_stat_card(host_count, "Hosts reporting"))
319    parts.append(html_stat_card(f"{sum(all_shell.values()):,}", "Shell commands"))
320    parts.append(html_stat_card(len(all_shell), "Unique commands"))
321    parts.append(html_stat_card(f"{sum(all_acct.values()):,}", "Process executions"))
322    if pi:
323        parts.append(html_stat_card(f"{pi['sessions']:,}", "Pi sessions", "purple"))
324    parts.append(html_stat_card(len(all_custom_used), f"Custom tools used", "green"))
325    parts.append(html_stat_card(len(all_custom_defined - all_custom_used), f"Custom tools unused", "red"))
326    parts.append('</div>')
327
328    # ── Tabs: All Hosts / per-host ──
329    tab_names = ["all"] + sorted(host_data.keys())
330    parts.append('<div class="tabs" data-tab-group="hosts">')
331    parts.append(f'<div class="tab active" data-tab="all" onclick="switchTab(\'hosts\',\'all\')">All Hosts</div>')
332    for hn in sorted(host_data.keys()):
333        parts.append(f'<div class="tab" data-tab="{h(hn)}" onclick="switchTab(\'hosts\',\'{h(hn)}\')">{h(hn)}</div>')
334    parts.append('</div>')
335
336    # ── All Hosts tab ──
337    parts.append('<div class="tab-content active" data-tab-content="hosts" data-tab="all">')
338    parts.append('<h3>Shell Commands (all hosts)</h3>')
339    parts.append(html_bar_chart(all_shell, 25, "blue"))
340    if all_acct:
341        parts.append('<h3>Process Accounting (all hosts)</h3>')
342        parts.append(html_bar_chart(all_acct, 25, "green"))
343
344    # Nix packages
345    all_used_bins = set(all_shell.keys()) | set(all_acct.keys())
346    nix_used_count = all_nix_total - len(all_nix_unused) if all_nix_total else 0
347    parts.append('<h3>Nix Packages</h3>')
348    parts.append('<div class="stats-grid">')
349    parts.append(html_stat_card(all_nix_total, "Installed bins"))
350    parts.append(html_stat_card(nix_used_count, "Used (ever)", "green"))
351    parts.append(html_stat_card(len(all_nix_unused), "Never used", "orange"))
352    parts.append('</div>')
353    if all_nix_unused:
354        parts.append(f'<details><summary>Never used bins ({len(all_nix_unused)})</summary><div class="content">')
355        parts.append(html_tag_list(sorted(all_nix_unused), "unused"))
356        parts.append('</div></details>')
357
358    # Custom tools
359    parts.append('<h3>Custom Tools</h3>')
360    if all_custom_used:
361        parts.append(f'<p style="margin:0.5rem 0"><strong>Used:</strong></p>')
362        parts.append(html_tag_list(sorted(all_custom_used), "used"))
363    unused_custom = all_custom_defined - all_custom_used
364    if unused_custom:
365        parts.append(f'<p style="margin:0.5rem 0"><strong>Unused:</strong></p>')
366        parts.append(html_tag_list(sorted(unused_custom), "unused"))
367
368    # Emacs (from any host with data)
369    for hostname, data_list in sorted(host_data.items()):
370        if not data_list:
371            continue
372        emacs = data_list[-1].get("emacs", {})
373        if isinstance(emacs, dict) and emacs.get("declared_count", 0) > 0:
374            unused_pkgs = emacs.get("unused_packages", [])
375            parts.append('<h3>Emacs Packages</h3>')
376            parts.append('<div class="stats-grid">')
377            parts.append(html_stat_card(emacs["declared_count"], "Declared"))
378            parts.append(html_stat_card(emacs.get("loaded_count", 0), "Loaded features", "green"))
379            parts.append(html_stat_card(len(unused_pkgs), "Potentially unused", "orange"))
380            parts.append('</div>')
381            if unused_pkgs:
382                parts.append(f'<details><summary>Potentially unused packages ({len(unused_pkgs)})</summary><div class="content">')
383                parts.append(html_tag_list(unused_pkgs, "unused"))
384                parts.append('</div></details>')
385            cmd_freq = emacs.get("command_frequency", {})
386            if cmd_freq:
387                parts.append('<details><summary>Emacs command frequency</summary><div class="content">')
388                parts.append(html_bar_chart(Counter(cmd_freq), 30, "purple"))
389                parts.append('</div></details>')
390            break  # Same emacs config across hosts
391
392    # Services
393    parts.append('<h3>System Services</h3>')
394    parts.append(f'<details><summary>{len(all_services)} unique services across all hosts</summary><div class="content">')
395    parts.append('<div class="services-grid">')
396    for s in sorted(all_services):
397        parts.append(f'<div class="service-item">{h(s)}</div>')
398    parts.append('</div></div></details>')
399
400    parts.append('</div>')  # end all-hosts tab
401
402    # ── Per-host tabs ──
403    for hostname, data_list in sorted(host_data.items()):
404        if not data_list:
405            continue
406        parts.append(f'<div class="tab-content" data-tab-content="hosts" data-tab="{h(hostname)}">')
407        parts.append(f'<h3>{h(hostname)} — {len(data_list)} days of data</h3>')
408
409        shell_cmds = aggregate_commands(data_list, "shell")
410        parts.append(f'<h3>Shell Commands ({sum(shell_cmds.values()):,} total, {len(shell_cmds)} unique)</h3>')
411        parts.append(html_bar_chart(shell_cmds, 25, "blue"))
412
413        acct_cmds = aggregate_commands(data_list, "process_accounting")
414        if acct_cmds:
415            parts.append(f'<h3>Process Accounting ({sum(acct_cmds.values()):,} execs, {len(acct_cmds)} unique)</h3>')
416            parts.append(html_bar_chart(acct_cmds, 25, "green"))
417
418        latest = data_list[-1]
419        nix = latest.get("nix_packages", {})
420        if isinstance(nix, dict) and "total_bins" in nix:
421            host_used = set(shell_cmds.keys()) | set(acct_cmds.keys())
422            host_unused = set(nix.get("unused_bins", [])) - host_used
423            parts.append('<h3>Nix Packages</h3>')
424            parts.append('<div class="stats-grid">')
425            parts.append(html_stat_card(nix["total_bins"], "Installed"))
426            parts.append(html_stat_card(nix["total_bins"] - len(host_unused), "Used", "green"))
427            parts.append(html_stat_card(len(host_unused), "Never used", "orange"))
428            parts.append('</div>')
429            if host_unused:
430                parts.append(f'<details><summary>Never used ({len(host_unused)})</summary><div class="content">')
431                parts.append(html_tag_list(sorted(host_unused), "unused"))
432                parts.append('</div></details>')
433
434        custom = latest.get("custom_tools", {})
435        if isinstance(custom, dict) and "defined" in custom:
436            parts.append('<h3>Custom Tools</h3>')
437            used = custom.get("used", [])
438            unused = custom.get("unused", [])
439            if used:
440                parts.append(html_tag_list(sorted(used), "used"))
441            if unused:
442                parts.append(html_tag_list(sorted(unused), "unused"))
443
444        services = latest.get("services", {})
445        if isinstance(services, dict) and "running" in services:
446            running = services.get("running", [])
447            parts.append(f'<details><summary>Services ({len(running)} running)</summary><div class="content">')
448            parts.append('<div class="services-grid">')
449            for s in running:
450                parts.append(f'<div class="service-item">{h(s)}</div>')
451            parts.append('</div></div></details>')
452
453        parts.append('</div>')  # end host tab
454
455    # ── Pi Agent section ──
456    if pi:
457        parts.append('<h2>Pi Agent</h2>')
458        parts.append('<div class="stats-grid">')
459        parts.append(html_stat_card(f"{pi['sessions']:,}", "Sessions"))
460        parts.append(html_stat_card(len(pi['tools']), "Unique tools"))
461        parts.append(html_stat_card(len(pi['skills']), "Skills used"))
462        parts.append(html_stat_card(len(pi['skills_never_used']), "Skills never used", "orange"))
463        parts.append(html_stat_card(len(pi['models']), "Models used"))
464        parts.append(html_stat_card(len(pi['providers']), "Providers"))
465        parts.append('</div>')
466
467        parts.append('<h3>Tools</h3>')
468        parts.append(html_bar_chart(pi["tools"], 20, "blue"))
469
470        parts.append('<h3>Skills</h3>')
471        parts.append(html_bar_chart(pi["skills"], 20, "purple"))
472        if pi["skills_never_used"]:
473            parts.append(f'<p style="margin:0.5rem 0;color:var(--text-dim)">Never used:</p>')
474            parts.append(html_tag_list(pi["skills_never_used"], "unused"))
475
476        parts.append('<h3>Models</h3>')
477        parts.append(html_bar_chart(pi["models"], 20, "orange"))
478
479        parts.append('<h3>Providers</h3>')
480        parts.append(html_bar_chart(pi["providers"], 15, "green"))
481
482    parts.append(f'<script>{HTML_SCRIPT}</script></body></html>')
483    return "\n".join(parts)
484
485
486# ── Main ──────────────────────────────────────────────────────────────
487
488def generate_report(days: int, fmt: str) -> str:
489    host_data = load_host_data(days)
490    shared_data = load_shared_data(days)
491    if fmt == "html":
492        return generate_html(days, host_data, shared_data)
493    elif fmt == "json":
494        return json.dumps({
495            "period_days": days,
496            "generated": str(date.today()),
497            "hosts": {h: d for h, d in host_data.items()},
498            "shared": shared_data,
499        }, indent=2, default=str)
500    else:
501        return generate_md(days, host_data, shared_data)
502
503
504def main():
505    parser = argparse.ArgumentParser(description="Generate usage report")
506    parser.add_argument("--days", type=int, default=30, help="Number of days to include (default: 30)")
507    parser.add_argument("--format", choices=["md", "html", "json"], default="md", help="Output format")
508    parser.add_argument("--output", "-o", type=str, help="Write to file instead of stdout")
509    parser.add_argument("--open", action="store_true", help="Open HTML report in browser")
510    args = parser.parse_args()
511
512    report = generate_report(args.days, args.format)
513
514    if args.output:
515        Path(args.output).write_text(report)
516        print(f"Report written to {args.output}", file=sys.stderr)
517    elif args.open and args.format == "html":
518        import subprocess
519        import tempfile
520        tmp = Path(tempfile.mktemp(suffix=".html", prefix="usage-report-"))
521        tmp.write_text(report)
522        print(f"Report written to {tmp}", file=sys.stderr)
523        subprocess.run(["xdg-open", str(tmp)])
524    else:
525        print(report)
526
527
528if __name__ == "__main__":
529    main()