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()