#!/usr/bin/env python3 """ISO Scalpel — command-line interface. Provides scriptable access to the ISO engine without the GUI. Every feature of the graphical application is reachable from here, plus the filesystem diff mode. Usage: python cli.py [options] Commands: new Create a new ISO image list List the contents of an ISO image info Show volume metadata for an ISO image add Add a file or directory to an image extract Extract a file or directory from an image rm Remove an entry from an image boot Configure or show El Torito boot information diff Compare the filesystems of two images gui Launch the graphical interface (default if no command) """ # This file is part of ISO Scalpel. # Copyright (C) 2025 Jeremy Anderson # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, see # or write to the Free Software Foundation, Inc., 51 Franklin Street, # Fifth Floor, Boston, MA 02110-1301 USA. from __future__ import annotations import argparse import os import sys # Make the package importable when run directly. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from iso_scalpel import __app_name__, __version__ from iso_scalpel.diff import DiffEntry, DiffStatus, diff_images, format_diff_text from iso_scalpel.iso_handler import BootInfo, IsoHandler, NameType, NewIsoOptions from iso_scalpel.iso_record import NAME_TYPE_LABELS, _human_size # -------------------------------------------------------------------------- # Argument parsing # -------------------------------------------------------------------------- def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="iso-scalpel", description=f"{__app_name__} {__version__} — disc image editor (CLI)", ) p.add_argument("--version", action="version", version=f"{__app_name__} {__version__}") sub = p.add_subparsers(dest="command", metavar="") # --- new ----------------------------------------------------------- sp_new = sub.add_parser("new", help="Create a new ISO image") sp_new.add_argument("output", help="Output .iso filename") sp_new.add_argument("-l", "--label", default="CDROM", help="Volume label") sp_new.add_argument("--level", type=int, choices=[1, 2, 3], default=1, help="ISO9660 interchange level (default: 1)") sp_new.add_argument("--joliet", type=int, choices=[1, 2, 3], default=None, help="Enable Joliet at the given level") sp_new.add_argument("--rock-ridge", choices=["1.09", "1.12"], default=None, help="Enable Rock Ridge") sp_new.add_argument("--udf", choices=["2.50", "2.60"], default=None, help="Enable UDF") sp_new.add_argument("--publisher", default="") sp_new.add_argument("--preparer", default="") sp_new.add_argument("--application", default="ISO Scalpel") sp_new.add_argument("--add", action="append", default=[], help="Add a file or directory to the new image (repeatable). " "Format: LOCAL_PATH[:ISO_PATH]") sp_new.set_defaults(func=cmd_new) # --- list ---------------------------------------------------------- sp_list = sub.add_parser("list", help="List the contents of an ISO image") sp_list.add_argument("file", help="ISO image file") sp_list.add_argument("path", nargs="?", default="/", help="Directory to list (default: /)") sp_list.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"], default=None, help="Naming convention to display") sp_list.add_argument("-r", "--recursive", action="store_true", help="Recursively list all entries") sp_list.set_defaults(func=cmd_list) # --- info ---------------------------------------------------------- sp_info = sub.add_parser("info", help="Show volume metadata") sp_info.add_argument("file", help="ISO image file") sp_info.set_defaults(func=cmd_info) # --- add ----------------------------------------------------------- sp_add = sub.add_parser("add", help="Add a file or directory to an image") sp_add.add_argument("file", help="ISO image file to modify") sp_add.add_argument("local", help="Local file or directory to add") sp_add.add_argument("iso_path", nargs="?", default="/", help="Destination directory in the image (default: /)") sp_add.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"], default=None, help="Naming convention for the destination") sp_add.set_defaults(func=cmd_add) # --- extract ------------------------------------------------------- sp_ex = sub.add_parser("extract", help="Extract a file or directory") sp_ex.add_argument("file", help="ISO image file") sp_ex.add_argument("iso_path", help="Path inside the image to extract") sp_ex.add_argument("local", help="Local destination path") sp_ex.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"], default=None) sp_ex.set_defaults(func=cmd_extract) # --- rm ------------------------------------------------------------ sp_rm = sub.add_parser("rm", help="Remove an entry from an image") sp_rm.add_argument("file", help="ISO image file to modify") sp_rm.add_argument("iso_path", help="Path inside the image to remove") sp_rm.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"], default=None) sp_rm.set_defaults(func=cmd_rm) # --- boot ---------------------------------------------------------- sp_boot = sub.add_parser("boot", help="Show or configure El Torito boot") sp_boot.add_argument("file", help="ISO image file") sp_boot.add_argument("--set", metavar="BOOT_FILE", help="Set a host file as the boot image") sp_boot.add_argument("--platform", type=lambda x: int(x, 0), default=0, help="Platform ID (0=x86, 1=PPC, 2=Mac, 0xEF=EFI)") sp_boot.add_argument("--media", default="noemul", choices=["noemul", "floppy", "hdemul"], help="Media type") sp_boot.add_argument("--no-bootable", action="store_true", help="Mark as non-bootable") sp_boot.add_argument("--info-table", action="store_true", help="Patch boot-info-table (for ISOLINUX)") sp_boot.add_argument("--clear", action="store_true", help="Remove the El Torito boot record") sp_boot.set_defaults(func=cmd_boot) # --- diff ---------------------------------------------------------- sp_diff = sub.add_parser("diff", help="Compare the filesystems of two images") sp_diff.add_argument("file_a", help="First ISO image (the 'from')") sp_diff.add_argument("file_b", help="Second ISO image (the 'to')") sp_diff.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"], default=None, help="Naming convention to compare in") sp_diff.add_argument("--all", action="store_true", help="Show unchanged entries too") sp_diff.set_defaults(func=cmd_diff) # --- gui ----------------------------------------------------------- sp_gui = sub.add_parser("gui", help="Launch the graphical interface") sp_gui.add_argument("file", nargs="?", help="Optional ISO file to open") sp_gui.set_defaults(func=cmd_gui) return p # -------------------------------------------------------------------------- # Name-type parsing helper # -------------------------------------------------------------------------- _VIEW_MAP = { "iso9660": NameType.ISO9660, "rr": NameType.ROCK_RIDGE, "joliet": NameType.JOLIET, "udf": NameType.UDF, } def _resolve_view(handler: IsoHandler, view: str | None) -> NameType: if view is None: return handler.default_name_type() nt = _VIEW_MAP.get(view) if nt is None: raise SystemExit(f"Unknown view: {view}") return nt # -------------------------------------------------------------------------- # Command implementations # -------------------------------------------------------------------------- def cmd_new(args: argparse.Namespace) -> int: opts = NewIsoOptions( volume_label=args.label, interchange_level=args.level, joliet=args.joliet, rock_ridge=args.rock_ridge, udf=args.udf, publisher=args.publisher, preparer=args.preparer, application=args.application, ) handler = IsoHandler() try: handler.new(opts) nt = handler.default_name_type() for spec in args.add: if ":" in spec and os.path.exists(spec.split(":", 1)[0]): local, iso_path = spec.split(":", 1) else: local, iso_path = spec, "/" if os.path.isdir(local): name = os.path.basename(local) new_dir = handler.add_directory(iso_path, nt, name) _import_tree(handler, local, iso_path.rstrip("/") + "/" + new_dir, nt) else: handler.add_file(local, iso_path, nt, nice_name=os.path.basename(local)) handler.save(args.output) except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary print(f"error: {exc}", file=sys.stderr) return 1 finally: handler.close() print(f"Created {args.output} ({_human_size(os.path.getsize(args.output))})") return 0 def _import_tree(handler: IsoHandler, local_dir: str, iso_dir: str, nt: NameType) -> None: for entry in sorted(os.listdir(local_dir)): full = os.path.join(local_dir, entry) if os.path.isdir(full): new_dir = handler.add_directory(iso_dir, nt, entry) _import_tree(handler, full, iso_dir.rstrip("/") + "/" + new_dir, nt) else: handler.add_file(full, iso_dir, nt, nice_name=entry) def cmd_list(args: argparse.Namespace) -> int: handler = IsoHandler() try: handler.open(args.file) except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary print(f"error: {exc}", file=sys.stderr) return 1 nt = _resolve_view(handler, args.view) if args.recursive: entries = [] _collect_recursive(handler, args.path, nt, entries) else: entries = handler.list_dir(args.path, nt) print(f"{args.file} ({NAME_TYPE_LABELS[nt]} view)") print(f"{'Name':<40} {'Size':>12} {'Type':<6} {'Date'}") for rec in entries: name = rec.name + ("/" if rec.is_dir else "") size = "" if rec.is_dir else _human_size(rec.size) typ = "dir" if rec.is_dir else "file" print(f"{name:<40} {size:>12} {typ:<6} {rec.date_label}") handler.close() return 0 def _collect_recursive(handler: IsoHandler, path: str, nt: NameType, out: list) -> None: for rec in handler.list_dir(path, nt): out.append(rec) if rec.is_dir: _collect_recursive(handler, rec.path, nt, out) def cmd_info(args: argparse.Namespace) -> int: handler = IsoHandler() try: handler.open(args.file) except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary print(f"error: {exc}", file=sys.stderr) return 1 p = handler.get_properties() print(f"File: {handler.filename}") print(f"Volume label: {p.volume_label}") print(f"Image size: {_human_size(p.total_size)} ({p.total_size} bytes)") print(f"Block size: {p.block_size}") print(f"ISO9660 level: {p.interchange_level}") print(f"Extensions: {', '.join(p.extensions) or '(none)'}") # Optional descriptor fields are printed only when present. optional_fields = ( ("System ID:", p.system_id), ("Publisher:", p.publisher), ("Preparer:", p.preparer), ("Application:", p.application), ("Volume set ID:", p.volume_set_id if p.volume_set_id and p.volume_set_id.strip() else ""), ) for label, value in optional_fields: if value: print(f"{label:<17}{value}") boot = handler.get_boot_info() print(f"El Torito boot: {'enabled' if boot.enabled else 'disabled'}") handler.close() return 0 def cmd_add(args: argparse.Namespace) -> int: handler = IsoHandler() try: handler.open(args.file) nt = _resolve_view(handler, args.view) if os.path.isdir(args.local): name = os.path.basename(args.local) new_dir = handler.add_directory(args.iso_path, nt, name) _import_tree(handler, args.local, args.iso_path.rstrip("/") + "/" + new_dir, nt) else: handler.add_file(args.local, args.iso_path, nt, nice_name=os.path.basename(args.local)) handler.save(args.file) except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary print(f"error: {exc}", file=sys.stderr) return 1 finally: handler.close() print(f"Added {args.local} to {args.file}") return 0 def cmd_extract(args: argparse.Namespace) -> int: handler = IsoHandler() try: handler.open(args.file) nt = _resolve_view(handler, args.view) # determine if it's a dir or file by looking it up recs = {r.path: r for r in handler.list_dir( args.iso_path.rpartition("/")[0] or "/", nt)} rec = recs.get(args.iso_path) if rec and rec.is_dir: handler.extract_dir(args.iso_path, nt, args.local) else: handler.extract_file(args.iso_path, nt, args.local) except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary print(f"error: {exc}", file=sys.stderr) return 1 finally: handler.close() print(f"Extracted {args.iso_path} to {args.local}") return 0 def cmd_rm(args: argparse.Namespace) -> int: handler = IsoHandler() try: handler.open(args.file) nt = _resolve_view(handler, args.view) # look up whether it's a dir parent = args.iso_path.rpartition("/")[0] or "/" recs = {r.path: r for r in handler.list_dir(parent, nt)} rec = recs.get(args.iso_path) is_dir = rec.is_dir if rec else False handler.remove(args.iso_path, nt, is_dir) handler.save(args.file) except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary print(f"error: {exc}", file=sys.stderr) return 1 finally: handler.close() print(f"Removed {args.iso_path} from {args.file}") return 0 def cmd_boot(args: argparse.Namespace) -> int: handler = IsoHandler() try: handler.open(args.file) if args.clear: handler.clear_boot() handler.save(args.file) print("Boot record removed.") return 0 if args.set: info = BootInfo( bootable=not args.no_bootable, platform_id=args.platform, media_name=args.media, boot_info_table=args.info_table, load_segment=0x07C0, ) if args.platform == 0xEF: info.efi = True handler.set_boot(info, boot_file_local=args.set) handler.save(args.file) print(f"Boot image set to {args.set}") return 0 # just show info info = handler.get_boot_info() print(f"Boot enabled: {info.enabled}") print(f"Bootable: {info.bootable}") print(f"Boot image: {info.boot_image_path or '(none)'}") print(f"Platform ID: 0x{info.platform_id:02X}") print(f"Media: {info.media_name}") except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary print(f"error: {exc}", file=sys.stderr) return 1 finally: handler.close() return 0 def cmd_diff(args: argparse.Namespace) -> int: ha = IsoHandler() hb = IsoHandler() try: ha.open(args.file_a) hb.open(args.file_b) nt = _resolve_view(ha, args.view) if args.view else None result = diff_images(ha, hb, name_type=nt) except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary print(f"error: {exc}", file=sys.stderr) return 1 finally: ha.close() hb.close() if args.all: # Show every entry, including unchanged. The size annotation is # selected by status via a flat table so the print loop stays linear. print(f"--- {result.a_file}") print(f"+++ {result.b_file}") print(f"{result.added_count} added, {result.removed_count} removed, " f"{result.modified_count} modified, {result.same_count} unchanged") print() def _size_note(e: DiffEntry) -> str: if e.status == DiffStatus.MODIFIED: return f"{e.a_size} -> {e.b_size} bytes" if e.status == DiffStatus.ADDED: return f"{e.b_size} bytes" return f"{e.a_size} bytes" for e in result.entries: mark = e.status_label name = e.path + ("/" if e.is_dir else "") print(f" {mark} {name} ({_size_note(e)})") else: print(format_diff_text(result)) # POSIX diff(1) convention: 0 = identical, 1 = different, 2 = error. return 0 if result.is_identical else 1 def cmd_gui(args: argparse.Namespace) -> int: from iso_scalpel.app import run argv = ["iso-scalpel"] if args.file: argv.append(args.file) return run(argv) # -------------------------------------------------------------------------- # Entry point # -------------------------------------------------------------------------- def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) if not getattr(args, "command", None): # No subcommand: launch the GUI. from iso_scalpel.app import run return run(["iso-scalpel"] + (argv or [])) return args.func(args) if __name__ == "__main__": raise SystemExit(main())