#!/usr/bin/env bash # # system-report.sh — collect a Linux system status report and optionally save it. # Full documentation: see system-report.md in this same folder. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/../lib/common.sh" SCRIPT_HELP=$(cat <<'HELP' Usage: ./System/system-report.sh [OPTIONS] Collects host, OS, CPU, memory, disk, network-interface, and network-mount status. Displays a text report and can save it as TXT, JSON, or HTML. Wi-Fi details use iw, NetworkManager, and kernel wireless statistics when available. Interfaces are grouped as Ethernet, Wi-Fi, Docker/container, then other virtual devices. Options: -o, --output PATH Save directly to PATH; otherwise ask interactively -f, --format FORMAT Report format: txt, json, or html --force Overwrite an existing output file without prompting -h, --help Show this help message and exit Examples: ./System/system-report.sh ./System/system-report.sh --output "$HOME/report.json" --format json ./System/system-report.sh --output /tmp/system-status.html --force HELP ) common::show_help_if_requested "$@" OUTPUT_PATH="" OUTPUT_FORMAT="" FORCE=0 while [[ $# -gt 0 ]]; do case "$1" in -o|--output) [[ $# -ge 2 ]] || common::die "$1 requires a path" OUTPUT_PATH="$2" shift 2 ;; -f|--format) [[ $# -ge 2 ]] || common::die "$1 requires txt, json, or html" OUTPUT_FORMAT="${2,,}" case "$OUTPUT_FORMAT" in txt|json|html) ;; *) common::die "unsupported report format: $2 (use txt, json, or html)" ;; esac shift 2 ;; --force) FORCE=1 shift ;; *) common::die "unknown argument: $1" ;; esac done common::require_cmd python3 python3 -c "$(cat <<'PYCODE' import datetime import html import json import os import pathlib import platform import re import shutil import socket import subprocess import sys import tempfile import time def run(command, timeout=10): if shutil.which(command[0]) is None: return None try: result = subprocess.run( command, check=False, capture_output=True, text=True, timeout=timeout, ) except (OSError, subprocess.TimeoutExpired): return None return result.stdout.strip() if result.returncode == 0 else None def read_text(path): try: return pathlib.Path(path).read_text(encoding="utf-8").strip() except (OSError, UnicodeError): return None def human_bytes(value): if value is None: return "Unavailable" amount = float(value) for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"): if amount < 1024 or unit == "PiB": return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} B" amount /= 1024 return f"{amount:.1f} PiB" def parse_os_release(): try: release = platform.freedesktop_os_release() return release.get("PRETTY_NAME") or release.get("NAME"), release.get("VERSION") except (AttributeError, OSError): values = {} try: for line in pathlib.Path("/etc/os-release").read_text(encoding="utf-8").splitlines(): if "=" in line: key, value = line.split("=", 1) values[key] = value.strip().strip('"') except OSError: return None, None return values.get("PRETTY_NAME") or values.get("NAME"), values.get("VERSION") def cpu_counters(): try: fields = pathlib.Path("/proc/stat").read_text(encoding="ascii").splitlines()[0].split() values = [int(value) for value in fields[1:]] idle = values[3] + (values[4] if len(values) > 4 else 0) return sum(values), idle except (OSError, IndexError, ValueError): return None def cpu_usage(): before = cpu_counters() if before is None: return None time.sleep(1) after = cpu_counters() if after is None: return None total_delta = after[0] - before[0] idle_delta = after[1] - before[1] if total_delta <= 0: return None return round(max(0.0, min(100.0, 100.0 * (total_delta - idle_delta) / total_delta)), 1) def memory_info(): values = {} try: for line in pathlib.Path("/proc/meminfo").read_text(encoding="ascii").splitlines(): key, value = line.split(":", 1) values[key] = int(value.strip().split()[0]) * 1024 except (OSError, ValueError, IndexError): return {"total_bytes": None, "used_bytes": None, "available_bytes": None} total = values.get("MemTotal") available = values.get("MemAvailable") used = max(0, total - available) if total is not None and available is not None else None return { "total_bytes": total, "used_bytes": used, "available_bytes": available, "total": human_bytes(total), "used": human_bytes(used), "available": human_bytes(available), } def load_average(): try: return [round(value, 2) for value in os.getloadavg()] except (AttributeError, OSError): return None def parse_json_command(command): output = run(command) if output is None: return None try: return json.loads(output) except json.JSONDecodeError: return None def mount_points(node): points = node.get("mountpoints") or [] if isinstance(points, str): points = [points] return [point for point in points if isinstance(point, str) and point] def device_path(node): return node.get("path") or ("/dev/" + node["name"] if node.get("name") else "Unknown") def disk_kind(node): name = (node.get("kname") or node.get("name") or "").lower() transport = (node.get("tran") or "").upper() if name.startswith("nvme") or transport == "NVME": return "NVMe SSD" if node.get("rota") == 0: kind = "SSD" elif node.get("rota") == 1: kind = "HDD" else: kind = "Unknown media" if transport: kind = f"{kind} ({transport})" return kind def swap_devices(): devices = set() try: lines = pathlib.Path("/proc/swaps").read_text(encoding="utf-8").splitlines()[1:] except (OSError, UnicodeError): return devices for line in lines: fields = line.split() if fields: devices.add(os.path.realpath(fields[0])) return devices def collect_disks(): data = parse_json_command([ "lsblk", "--json", "--bytes", "--paths", "--output", "NAME,KNAME,PATH,TYPE,SIZE,ROTA,TRAN,MODEL,FSTYPE,MOUNTPOINTS", ]) if not data: return [], "Disk inventory unavailable (lsblk missing or unsupported)." swaps = swap_devices() disks = [] def add_mounts(node, results): path = device_path(node) filesystem = node.get("fstype") for target in mount_points(node): try: usage = shutil.disk_usage(target) total, used = usage.total, usage.used percent = round(100 * used / total, 1) if total else None except OSError: total = used = percent = None results.append({ "device": path, "target": target, "filesystem": filesystem, "total_bytes": total, "used_bytes": used, "total": human_bytes(total), "used": human_bytes(used), "used_percent": percent, }) for child in node.get("children") or []: add_mounts(child, results) for node in data.get("blockdevices", []): if node.get("type") != "disk": continue name = (node.get("kname") or node.get("name") or "").lower() if name.startswith(("loop", "ram", "zram")): continue mounts = [] add_mounts(node, mounts) descendants = [] def collect_paths(current): descendants.append(os.path.realpath(device_path(current))) for child in current.get("children") or []: collect_paths(child) collect_paths(node) active_swap = any(path in swaps for path in descendants) if mounts: status = "Mounted" elif active_swap: status = "In use as swap (not mounted)" else: status = "Not mounted and not in use" size = node.get("size") disks.append({ "device": device_path(node), "model": (node.get("model") or "").strip() or None, "kind": disk_kind(node), "size_bytes": size, "size": human_bytes(size), "status": status, "mounts": mounts, }) return disks, None def read_addresses(interface): data = parse_json_command(["ip", "-j", "address", "show", "dev", interface]) addresses = [] if data: for entry in data[0].get("addr_info", []): local = entry.get("local") if local: addresses.append(f"{local}/{entry.get('prefixlen', '')}".rstrip("/")) return addresses def split_nmcli_fields(line): fields = [] value = [] escaped = False for character in line: if escaped: value.append(character) escaped = False elif character == "\\": escaped = True elif character == ":": fields.append("".join(value)) value = [] else: value.append(character) fields.append("".join(value)) return fields def nmcli_wifi_info(interface): output = run([ "nmcli", "--terse", "--escape", "yes", "--fields", "IN-USE,SSID,FREQ,SIGNAL,BARS,CHAN", "device", "wifi", "list", "--rescan", "no", "ifname", interface, ]) if not output: return {} for line in output.splitlines(): fields = split_nmcli_fields(line) if len(fields) < 6 or fields[0] != "*": continue frequency = re.search(r"\d+", fields[2]) channel = re.search(r"\d+", fields[5]) try: signal = int(fields[3]) except ValueError: signal = None return { "ssid": fields[1] or None, "frequency_mhz": int(frequency.group()) if frequency else None, "signal_percent": signal, "signal_bars": fields[4] or None, "channel": int(channel.group()) if channel else None, } return {} def proc_wireless_info(interface): try: lines = pathlib.Path("/proc/net/wireless").read_text(encoding="ascii").splitlines()[2:] except (OSError, UnicodeError): return {} for line in lines: name, separator, data = line.partition(":") if not separator or name.strip() != interface: continue fields = data.split() result = {} if len(fields) > 2: try: result["signal_dbm"] = round(float(fields[2].rstrip("."))) except ValueError: pass if len(fields) > 1: try: result["signal_quality"] = round(float(fields[1].rstrip("."))) except ValueError: pass return result return {} def wifi_info(interface): wireless_dir = pathlib.Path("/sys/class/net") / interface / "wireless" iw_output = run(["iw", "dev", interface, "link"]) if not wireless_dir.exists() and iw_output is None: return None values = { "ssid": None, "signal_dbm": None, "signal_quality": None, "signal_percent": None, "signal_bars": None, "frequency_mhz": None, "channel": None, "rx_bitrate": None, "tx_bitrate": None, "bitrate_note": "iw is not installed" if shutil.which("iw") is None else None, } if iw_output: patterns = { "ssid": r"^\s*SSID:\s*(.+)$", "signal_dbm": r"^\s*signal:\s*(-?\d+)\s*dBm", "frequency_mhz": r"^\s*freq:\s*(\d+)", "rx_bitrate": r"^\s*rx bitrate:\s*(.+)$", "tx_bitrate": r"^\s*tx bitrate:\s*(.+)$", } for key, pattern in patterns.items(): match = re.search(pattern, iw_output, re.MULTILINE) if match: value = match.group(1).strip() values[key] = int(value) if key in ("signal_dbm", "frequency_mhz") else value for key, value in nmcli_wifi_info(interface).items(): if values.get(key) is None: values[key] = value for key, value in proc_wireless_info(interface).items(): if values.get(key) is None: values[key] = value return values def is_docker_interface(name): return name == "docker0" or name.startswith("veth") or bool(re.fullmatch(r"br-[0-9a-f]{12}", name)) def collect_interfaces(): root = pathlib.Path("/sys/class/net") try: names = sorted(path.name for path in root.iterdir()) except OSError: return [], "Network interface inventory unavailable (/sys/class/net unreadable)." interfaces = [] for name in names: base = root / name state = read_text(base / "operstate") or "unknown" carrier = read_text(base / "carrier") if carrier == "0": state = "disconnected" elif carrier == "1" and state == "unknown": state = "up" wireless = wifi_info(name) has_device = (base / "device").exists() has_wireless = (base / "wireless").exists() speed = None if wireless is None: raw_speed = read_text(base / "speed") try: parsed_speed = int(raw_speed) if raw_speed is not None else None speed = parsed_speed if parsed_speed is not None and parsed_speed > 0 else None except ValueError: pass if wireless is not None or has_wireless: group = "physical_wifi" if has_device or has_wireless else "other_virtual" interface_type = "Wi-Fi" elif has_device: group = "physical_ethernet" interface_type = "Ethernet" elif is_docker_interface(name): group = "docker_virtual" interface_type = "Docker/container virtual" else: group = "other_virtual" interface_type = "Virtual/other" interfaces.append({ "name": name, "state": state, "type": interface_type, "group": group, "addresses": read_addresses(name), "speed_mbps": speed, "wifi": wireless, }) group_order = {"physical_ethernet": 0, "physical_wifi": 1, "docker_virtual": 2, "other_virtual": 3} interfaces.sort(key=lambda item: (group_order[item["group"]], item["name"])) return interfaces, None def collect_network_mounts(): data = parse_json_command(["findmnt", "--json", "--output", "TARGET,SOURCE,FSTYPE,OPTIONS"]) if not data: return [], "Network mount inventory unavailable (findmnt missing or unsupported)." remote_types = { "9p", "afs", "beegfs", "ceph", "cifs", "davfs", "glusterfs", "lustre", "ncpfs", "nfs", "nfs4", "smb3", "sshfs", "fuse.sshfs", } mounts = [] def visit(entries): for item in entries: filesystem = (item.get("fstype") or "").lower() source = item.get("source") or "" if filesystem in remote_types or filesystem.startswith(("fuse.rclone", "fuse.sshfs")) or source.startswith(("//", "\\\\")): mounts.append({ "target": item.get("target"), "source": source, "filesystem": item.get("fstype"), "options": item.get("options"), }) visit(item.get("children") or []) visit(data.get("filesystems", [])) return mounts, None def collect_report(): host = socket.gethostname() fqdn = socket.getfqdn() domain = run(["hostname", "-d"]) if not domain or domain in ("(none)", "localdomain"): domain = fqdn[len(host) + 1:] if fqdn.startswith(host + ".") and fqdn != host else None os_name, os_version = parse_os_release() product = read_text("/sys/devices/virtual/dmi/id/product_name") vendor = read_text("/sys/devices/virtual/dmi/id/sys_vendor") cpu = cpu_usage() memory = memory_info() disks, disk_note = collect_disks() interfaces, interface_note = collect_interfaces() network_mounts, mount_note = collect_network_mounts() return { "generated_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"), "host": {"hostname": host, "fqdn": fqdn, "domain": domain}, "operating_system": { "name": os_name, "version": os_version, "architecture": platform.machine(), "kernel": platform.release(), "system_type": " ".join(value for value in (vendor, product) if value) or platform.system(), }, "cpu": { "usage_percent": cpu, "sample_seconds": 1, "logical_cores": os.cpu_count(), "load_average_1_5_15": load_average(), }, "memory": memory, "disks": disks, "network_interfaces": interfaces, "network_mounts": network_mounts, "collection_notes": [note for note in (disk_note, interface_note, mount_note) if note], } def value_or_unavailable(value): return "Unavailable" if value is None or value == "" else str(value) def render_text(report): host = report["host"] os_info = report["operating_system"] cpu = report["cpu"] memory = report["memory"] lines = [ "SYSTEM STATUS REPORT", "====================", f"Generated: {report['generated_at']}", "", "HOST", "----", f" Hostname: {value_or_unavailable(host['hostname'])}", f" Domain: {value_or_unavailable(host['domain'])}", f" FQDN: {value_or_unavailable(host['fqdn'])}", "", "OPERATING SYSTEM", "-----------------", f" OS: {value_or_unavailable(os_info['name'])}", f" Version: {value_or_unavailable(os_info['version'])}", f" Architecture: {value_or_unavailable(os_info['architecture'])}", f" Kernel: {value_or_unavailable(os_info['kernel'])}", f" System type: {value_or_unavailable(os_info['system_type'])}", "", "CPU", "---", f" Usage at sample: {value_or_unavailable(cpu['usage_percent'])}% (sample: {cpu['sample_seconds']} second)", f" Logical cores: {value_or_unavailable(cpu['logical_cores'])}", f" Load average (1, 5, 15 min): {value_or_unavailable(cpu['load_average_1_5_15'])}", "", "MEMORY", "------", f" Total: {value_or_unavailable(memory.get('total'))}", f" Used: {value_or_unavailable(memory.get('used'))}", f" Available: {value_or_unavailable(memory.get('available'))}", "", "DISKS", "-----", ] if report["disks"]: for disk in report["disks"]: lines.append(f" {disk['device']} | {disk['kind']} | {disk['size']} | {disk['status']}") if disk["model"]: lines.append(f" Model: {disk['model']}") if disk["mounts"]: for mount in disk["mounts"]: percent = f" ({mount['used_percent']}%)" if mount["used_percent"] is not None else "" lines.append(f" {mount['target']} [{value_or_unavailable(mount['filesystem'])}]: {mount['used']} used of {mount['total']}{percent}") else: lines.append(" No mounted filesystems") else: lines.append(" No physical disks found") lines.extend(["", "NETWORK INTERFACES", "------------------", "Physical interfaces"]) interface_groups = [ ("physical_ethernet", " Ethernet"), ("physical_wifi", " Wi-Fi"), ("docker_virtual", " Docker / container interfaces"), ("other_virtual", " Other virtual interfaces"), ] virtual_header_shown = False for group, title in interface_groups: entries = [item for item in report["network_interfaces"] if item["group"] == group] if not entries: continue if group in ("docker_virtual", "other_virtual"): if not virtual_header_shown: lines.extend(["", "Virtual interfaces (Docker/container first, then other)"]) virtual_header_shown = True lines.append(title) for interface in entries: lines.append(f" {interface['name']} [{interface['state']}]") if interface["addresses"]: lines.append(f" IP: {', '.join(interface['addresses'])}") if interface["speed_mbps"] is not None: lines.append(f" Link speed: {interface['speed_mbps']} Mbps") wifi = interface["wifi"] if wifi is not None: lines.append(f" SSID: {value_or_unavailable(wifi['ssid'])}") signal_parts = [] if wifi["signal_dbm"] is not None: signal_parts.append(f"{wifi['signal_dbm']} dBm") if wifi["signal_quality"] is not None: signal_parts.append(f"quality {wifi['signal_quality']}") if wifi["signal_percent"] is not None: signal_parts.append(f"{wifi['signal_percent']}%") if wifi["signal_bars"]: signal_parts.append(wifi["signal_bars"]) lines.append(f" Signal: {', '.join(signal_parts) if signal_parts else 'Unavailable'}") lines.append(f" Frequency: {value_or_unavailable(wifi['frequency_mhz'])} MHz") lines.append(f" Channel: {value_or_unavailable(wifi['channel'])}") bitrate_note = f" ({wifi['bitrate_note']})" if wifi["bitrate_note"] else "" lines.append(f" RX/TX bitrate: {value_or_unavailable(wifi['rx_bitrate'])} / {value_or_unavailable(wifi['tx_bitrate'])}{bitrate_note}") if not report["network_interfaces"]: lines.append(" No network interfaces found") lines.extend(["", "NETWORK MOUNTS", "--------------"]) if report["network_mounts"]: for mount in report["network_mounts"]: lines.append(f" {mount['source']} on {mount['target']} ({mount['filesystem']})") else: lines.append(" No network mounts detected") if report["collection_notes"]: lines.extend(["", "COLLECTION NOTES"]) lines.extend(f" {note}" for note in report["collection_notes"]) return "\n".join(lines) + "\n" def render_html(report): def esc(value): return html.escape(value_or_unavailable(value)) def section(title, body): return f"

{html.escape(title)}

{body}
" host = report["host"] os_info = report["operating_system"] cpu = report["cpu"] memory = report["memory"] cards = "".join([ f"
CPU load{esc(cpu['usage_percent'])}%{cpu['sample_seconds']} second sample
", f"
Memory used{esc(memory.get('used'))}of {esc(memory.get('total'))}
", f"
Memory available{esc(memory.get('available'))}RAM
", f"
Load average{esc(cpu['load_average_1_5_15'])}1 / 5 / 15 min
", ]) identity = "
" + "".join([ f"
Hostname
{esc(host['hostname'])}
", f"
Domain
{esc(host['domain'])}
", f"
FQDN
{esc(host['fqdn'])}
", f"
Operating system
{esc(os_info['name'])}
", f"
Version
{esc(os_info['version'])}
", f"
Architecture
{esc(os_info['architecture'])}
", f"
Kernel
{esc(os_info['kernel'])}
", f"
System type
{esc(os_info['system_type'])}
", f"
Logical CPU cores
{esc(cpu['logical_cores'])}
", ]) + "
" disk_rows = [] for disk in report["disks"]: mount_details = [] for mount in disk["mounts"]: percent = f" ({mount['used_percent']}%)" if mount["used_percent"] is not None else "" mount_details.append( f"
  • {esc(mount['target'])} · {esc(mount['filesystem'])}" f"
    {esc(mount['used'])} used of {esc(mount['total'])}{percent}
  • " ) details = "" if mount_details else "No mounted filesystems" model = f"{esc(disk['model'])}" if disk["model"] else "" disk_rows.append( f"{esc(disk['device'])}{model}" f"{esc(disk['kind'])}{esc(disk['size'])}" f"{esc(disk['status'])}{details}" ) if disk_rows: disk_body = "
    " + "".join(disk_rows) + "
    DeviceMediaSizeStatusMounts and usage
    " else: disk_body = "

    No physical disks found.

    " groups = [ ("physical_ethernet", "Physical interfaces · Ethernet"), ("physical_wifi", "Physical interfaces · Wi-Fi"), ("docker_virtual", "Virtual interfaces · Docker / containers"), ("other_virtual", "Virtual interfaces · Other"), ] interface_sections = [] for group, title in groups: interfaces = [item for item in report["network_interfaces"] if item["group"] == group] if not interfaces: continue items = [] for interface in interfaces: properties = [f"{esc(interface['state'])}"] if interface["addresses"]: properties.append(f"IP {esc(', '.join(interface['addresses']))}") if interface["speed_mbps"] is not None: properties.append(f"{interface['speed_mbps']} Mbps") wifi = interface["wifi"] if wifi is not None: properties.extend([ f"SSID {esc(wifi['ssid'])}", f"Signal {esc(wifi['signal_dbm'])} dBm · {esc(wifi['signal_percent'])}% · quality {esc(wifi['signal_quality'])}", f"{esc(wifi['frequency_mhz'])} MHz", f"Channel {esc(wifi['channel'])}", f"RX/TX {esc(wifi['rx_bitrate'])} / {esc(wifi['tx_bitrate'])}{' · ' + esc(wifi['bitrate_note']) if wifi['bitrate_note'] else ''}", ]) items.append( f"
    {esc(interface['name'])}" f"{esc(interface['type'])}
    " + "".join(properties) + "
    " ) interface_sections.append(f"

    {html.escape(title)}

    {''.join(items)}
    ") interfaces_body = "".join(interface_sections) or "

    No network interfaces found.

    " mount_rows = "".join( f"{esc(mount['source'])}{esc(mount['target'])}{esc(mount['filesystem'])}" for mount in report["network_mounts"] ) mount_body = ( "
    " + mount_rows + "
    SourceMount pointType
    " if mount_rows else "

    No network mounts detected.

    " ) notes = "" if report["collection_notes"]: notes = section("Collection notes", "") return ( "\n\n\n" "\n\n" "System Status Report\n\n\n
    \n" f"

    System Status Report

    {esc(host['hostname'])} · {esc(os_info['name'])}

    Generated {esc(report['generated_at'])}
    \n" + f"
    {cards}
    \n" + section("System identity", identity) + "\n" + section("Storage", disk_body) + "\n" + section("Network interfaces", interfaces_body) + "\n" + section("Network mounts", mount_body) + "\n" + notes + "\n
    \n\n" ) def ask(prompt, default): suffix = " [" + default + "]" try: answer = input(prompt + suffix + ": ").strip() except EOFError: return default return answer or default def ask_yes_no(prompt, default): marker = "Y/n" if default else "y/N" try: answer = input(f"{prompt} [{marker}]: ").strip().lower() except EOFError: return default if not answer: return default return answer in ("y", "yes") def infer_format(path, requested): if requested: return requested suffix = pathlib.Path(path).suffix.lower().lstrip(".") return suffix if suffix in ("txt", "json", "html") else "txt" def save_report(path_text, format_name, force, interactive, report, text_report): path = pathlib.Path(path_text).expanduser() if not path.parent.exists() or not path.parent.is_dir(): raise OSError(f"output directory does not exist: {path.parent}") if path.exists() and not force: if not interactive or not ask_yes_no(f"{path} already exists. Overwrite it?", False): print(f"Report not written: {path} already exists.", file=sys.stderr) return False rendered = text_report if format_name == "txt" else json.dumps(report, ensure_ascii=False, indent=2) + "\n" if format_name == "json" else render_html(report) temporary = None try: with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as output: temporary = pathlib.Path(output.name) output.write(rendered) os.replace(temporary, path) finally: if temporary is not None and temporary.exists(): temporary.unlink() print(f"Report saved: {path}") return True def main(): output_path = sys.argv[1] or None requested_format = sys.argv[2] or None force = sys.argv[3] == "1" interactive = sys.stdin.isatty() and sys.stdout.isatty() report = collect_report() text_report = render_text(report) if output_path: print(text_report, end="") format_name = infer_format(output_path, requested_format) saved = save_report(output_path, format_name, force, interactive, report, text_report) return 1 if not saved and not interactive else 0 if not interactive: if requested_format == "json": print(json.dumps(report, ensure_ascii=False, indent=2)) elif requested_format == "html": print(render_html(report), end="") else: print(text_report, end="") return 0 print(text_report, end="") if not ask_yes_no("Save this report to a file?", True): return 0 format_name = requested_format if format_name is None: format_name = ask("Report format (txt/json/html)", "txt").lower() if format_name not in ("txt", "json", "html"): print("Unsupported format; using txt.", file=sys.stderr) format_name = "txt" default_path = str(pathlib.Path.home() / f"report.{format_name}") output_path = ask("Output path", default_path) try: save_report(output_path, format_name, force, interactive, report, text_report) except OSError as error: print(f"Error: could not save report: {error}", file=sys.stderr) return 1 return 0 try: sys.exit(main()) except OSError as error: print(f"Error: {error}", file=sys.stderr) sys.exit(1) PYCODE )" "$OUTPUT_PATH" "$OUTPUT_FORMAT" "$FORCE"