""" Asset management CLI commands for MarkiTect - Issue #143. This module implements CLI commands for asset management including: - Asset management: add, list, stats, cleanup - Package management: create, extract, list, validate - Workspace management: init, status, sync Commands integrate with AssetManager backend from Issue #142 and use common CLI utilities for consistent user experience. """ import click import sys from pathlib import Path # Import asset management backend try: from .assets import AssetManager ASSET_BACKEND_AVAILABLE = True except ImportError: ASSET_BACKEND_AVAILABLE = False # Import CLI utilities from .cli_utils import ( ClickOutputFormatter, handle_asset_errors, output_format_option, dry_run_option, get_asset_config, validate_file_path, validate_directory_path ) def get_asset_manager() -> 'AssetManager': """ Get configured AssetManager instance with current configuration. Returns: AssetManager: Configured instance ready for asset operations Raises: SystemExit: If asset management backend is not available """ if not ASSET_BACKEND_AVAILABLE: ClickOutputFormatter.error("Asset management backend not available") # Get configuration with defaults config = get_asset_config() return AssetManager(config={'assets': config}) # Asset management command group @click.group() def asset(): """ Asset management commands for MarkiTect. Manage assets with content-addressable storage, deduplication, and cross-platform symlink support. Assets are stored in a shared location and can be referenced from multiple markdown documents. \b Examples: markitect asset add logo.png ./project --name company_logo.png markitect asset list --format json markitect asset stats markitect asset cleanup --dry-run """ pass @asset.command('add') @click.argument('file_path', type=click.Path(exists=True)) @click.argument('document_path', type=click.Path()) @click.option('--name', help='Virtual name in document (default: original filename)') @click.option('--force', is_flag=True, help='Overwrite existing virtual name') @click.option('--no-symlink', is_flag=True, help='Force file copy instead of symlink') @handle_asset_errors def asset_add(file_path, document_path, name, force, no_symlink): """ Add asset to the shared asset library with automatic deduplication. Adds the specified file to the asset management system, automatically deduplicating if the same content already exists. Assets are stored using content-addressable hashing and can be referenced with virtual names in markdown documents. \b Arguments: FILE_PATH Path to the asset file to add DOCUMENT_PATH Path to the document directory where asset will be used \b Features: - Automatic content-based deduplication - Cross-platform symlink support with fallback to copying - Virtual naming for flexible document organization - Hash-based integrity verification """ manager = get_asset_manager() # Validate paths file_path = validate_file_path(file_path, must_exist=True) document_path = validate_directory_path(document_path, must_exist=False, create_if_missing=True) # Use original filename if name not specified virtual_name = name or file_path.name # Add the asset result = manager.add_asset(file_path, f"Added to {document_path}") # Display results details = { 'Hash': result.get('hash', 'N/A')[:16] + '...' if result.get('hash') else 'N/A', 'Virtual name': virtual_name, 'Size': f"{result.get('size', 'N/A')} bytes" } ClickOutputFormatter.success("Asset added successfully", details) if result.get('deduplicated', False): ClickOutputFormatter.info("Asset was deduplicated with existing content") @asset.command('list') @click.option('--document', type=click.Path(), help='Filter by document directory') @click.option('--unused', is_flag=True, help='Show only unused assets') @output_format_option() @click.option('--sort', 'sort_field', type=click.Choice(['name', 'size', 'date']), default='name', help='Sort by field (default: name)') @handle_asset_errors def asset_list(document, unused, output_format, sort_field): """List assets.""" manager = get_asset_manager() assets = manager.list_assets() if not assets: ClickOutputFormatter.info("No assets found") return if output_format == 'json': ClickOutputFormatter.json_output(assets) else: # Prepare table data table_data = [] for asset in assets: table_data.append({ 'Hash': asset.get('hash', 'N/A')[:12], # Short hash 'Description': asset.get('description', 'N/A'), 'Size': asset.get('size', 0), 'Date': asset.get('created_at', 'N/A') }) headers = ['Hash', 'Description', 'Size', 'Date'] ClickOutputFormatter.table(table_data, headers) @asset.command('stats') @handle_asset_errors def asset_stats(): """Show asset library statistics.""" manager = get_asset_manager() stats = manager.get_storage_stats() ClickOutputFormatter.info("Asset Library Statistics") details = { 'Total assets': stats.get('total_assets', 0), 'Storage size': f"{stats.get('total_size', 0)} bytes", 'Deduplication savings': f"{stats.get('dedupe_savings', 0)} bytes" } if stats.get('total_size', 0) > 0: savings_pct = (stats.get('dedupe_savings', 0) / stats.get('total_size', 1)) * 100 details['Space saved'] = f"{savings_pct:.1f}%" ClickOutputFormatter.info("", details) @asset.command('cleanup') @click.option('--orphaned', is_flag=True, help='Clean only orphaned assets') @dry_run_option() @handle_asset_errors def asset_cleanup(orphaned, dry_run): """Clean unused assets.""" manager = get_asset_manager() if dry_run: ClickOutputFormatter.info("DRY RUN - no files will be removed") # Get cleanup info result = manager.cleanup_orphaned_assets() removed_count = result.get('removed_count', 0) freed_bytes = result.get('freed_bytes', 0) if dry_run: ClickOutputFormatter.info(f"Would remove {removed_count} orphaned assets") if freed_bytes > 0: ClickOutputFormatter.info(f"Would free {freed_bytes} bytes") else: if removed_count > 0: details = { 'Removed assets': removed_count, 'Freed space': f"{freed_bytes} bytes" } ClickOutputFormatter.success("Cleanup completed", details) else: ClickOutputFormatter.info("No orphaned assets found") # Package management command group @click.group() def package(): """ Package management commands for MarkiTect. Create, extract, validate, and manage .mdpkg packages containing markdown documents and their associated assets. Packages use ZIP format with manifest metadata for reliable distribution. \b Examples: markitect package create ./project project_v1 markitect package extract project_v1.mdpkg --name new_project markitect package list --format table markitect package validate project_v1.mdpkg """ pass @package.command('create') @click.argument('document_dir', type=click.Path(exists=True)) @click.argument('package_name') @click.option('--output', type=click.Path(), help='Output directory (default: workspace/packages)') @click.option('--compression', type=int, default=6, help='ZIP compression level 0-9 (default: 6)') @click.option('--exclude', multiple=True, help='Exclude files matching pattern') @click.option('--include-sources', is_flag=True, help='Include source markdown files') @click.option('--validate', is_flag=True, help='Validate package after creation') @handle_asset_errors def package_create(document_dir, package_name, output, compression, exclude, include_sources, validate): """ Create a .mdpkg package from a document directory. Packages a directory containing markdown documents and assets into a distributable .mdpkg file (ZIP format). Includes manifest metadata for reliable extraction and validation. \b Arguments: DOCUMENT_DIR Directory containing markdown documents and assets PACKAGE_NAME Name for the package (without .mdpkg extension) \b Features: - ZIP-based packaging with configurable compression - Manifest metadata for validation and extraction - Asset embedding and path rewriting - Exclusion patterns for selective packaging """ manager = get_asset_manager() # Validate and prepare paths document_dir = validate_directory_path(document_dir, must_exist=True) # Determine output path if output: output_dir = validate_directory_path(output, must_exist=False, create_if_missing=True) else: output_dir = validate_directory_path("packages", must_exist=False, create_if_missing=True) package_path = output_dir / f"{package_name}.mdpkg" # Create package using AssetManager result = manager.create_package(document_dir, package_path) # Display results details = { 'Package': str(package_path), 'Files': result.get('files_count', 0), 'Size': f"{result.get('total_size', 0)} bytes" } ClickOutputFormatter.success("Package created successfully", details) if validate: # Basic validation - check if file exists and is readable if package_path.exists(): ClickOutputFormatter.success("Package validation passed") else: ClickOutputFormatter.error("Package validation failed") @package.command('extract') @click.argument('package_file', type=click.Path(exists=True)) @click.option('--name', help='Custom extraction name') def package_extract(package_file, name): """Extract package.""" try: manager = get_asset_manager() package_path = Path(package_file) # Determine extraction directory if name: extract_dir = Path.cwd() / name else: extract_dir = Path.cwd() / package_path.stem # Extract package using AssetManager result = manager.extract_package(package_path, extract_dir) click.echo("Package extracted successfully!") click.echo(f"Extracted to: {extract_dir}") click.echo(f"Files: {result.get('files_count', 0)}") except PackagingError as e: click.echo(f"Error extracting package: {e}", err=True) sys.exit(1) except Exception as e: click.echo(f"Unexpected error: {e}", err=True) sys.exit(1) @package.command('list') @output_format_option() @handle_asset_errors def package_list(output_format): """List packages.""" # Find .mdpkg files in common locations package_dirs = [Path.cwd() / "packages", Path.cwd()] packages = [] for pkg_dir in package_dirs: if pkg_dir.exists(): for pkg_file in pkg_dir.glob("*.mdpkg"): packages.append({ 'Name': pkg_file.name, 'Size': pkg_file.stat().st_size }) if not packages: ClickOutputFormatter.info("No packages found") return if output_format == 'json': ClickOutputFormatter.json_output(packages) else: headers = ['Name', 'Size'] ClickOutputFormatter.table(packages, headers) @package.command('validate') @click.argument('package_file', type=click.Path(exists=True)) def package_validate(package_file): """Validate package integrity.""" try: package_path = Path(package_file) # Basic validation if not package_path.suffix == '.mdpkg': click.echo("Invalid package: must have .mdpkg extension", err=True) sys.exit(1) if package_path.stat().st_size == 0: click.echo("Invalid package: file is empty", err=True) sys.exit(1) # Try to read as ZIP import zipfile try: with zipfile.ZipFile(package_path, 'r') as zf: # Check for manifest if 'manifest.json' not in zf.namelist(): click.echo("Warning: Package missing manifest.json") click.echo("Package is valid") except zipfile.BadZipFile: click.echo("Invalid package: not a valid ZIP file", err=True) sys.exit(1) except Exception as e: click.echo(f"Error validating package: {e}", err=True) sys.exit(1) # Workspace management command group @click.group() def workspace(): """ Workspace management commands for MarkiTect. Initialize, manage, and synchronize MarkiTect workspaces containing shared assets, packages, and configuration. Workspaces provide a structured environment for markdown document management. \b Examples: markitect workspace init --template basic markitect workspace status markitect workspace sync --document ./project """ pass @workspace.command('init') @click.option('--template', help='Workspace template to use') @handle_asset_errors def workspace_init(template): """Initialize workspace.""" workspace_dir = Path.cwd() / "markitect_workspace" if workspace_dir.exists(): ClickOutputFormatter.info(f"Workspace already exists at: {workspace_dir}") return # Create workspace structure workspace_dir.mkdir(parents=True, exist_ok=True) (workspace_dir / "shared_assets").mkdir(exist_ok=True) (workspace_dir / "packages").mkdir(exist_ok=True) # Create basic config file if using template if template: ClickOutputFormatter.info(f"Using template: {template}") details = {'Location': str(workspace_dir)} ClickOutputFormatter.success("Workspace initialized successfully", details) @workspace.command('status') def workspace_status(): """Show workspace status.""" try: workspace_dir = Path.cwd() / "markitect_workspace" if not workspace_dir.exists(): click.echo("No workspace found in current directory") click.echo("Run 'markitect workspace init' to create one") return click.echo("Workspace Status") click.echo("=" * 16) click.echo(f"Location: {workspace_dir}") # Count assets and packages assets_dir = workspace_dir / "shared_assets" packages_dir = workspace_dir / "packages" if assets_dir.exists(): asset_count = len(list(assets_dir.iterdir())) click.echo(f"Assets: {asset_count}") if packages_dir.exists(): package_count = len(list(packages_dir.glob("*.mdpkg"))) click.echo(f"Packages: {package_count}") except Exception as e: click.echo(f"Error getting workspace status: {e}", err=True) sys.exit(1) @workspace.command('sync') @click.option('--document', type=click.Path(), help='Sync specific document') def workspace_sync(document): """Sync workspace assets.""" try: workspace_dir = Path.cwd() / "markitect_workspace" if not workspace_dir.exists(): click.echo("No workspace found. Run 'markitect workspace init' first.", err=True) sys.exit(1) if document: click.echo(f"Synchronizing document: {document}") else: click.echo("Synchronizing entire workspace") # Basic sync - ensure directories exist (workspace_dir / "shared_assets").mkdir(exist_ok=True) (workspace_dir / "packages").mkdir(exist_ok=True) click.echo("Workspace synchronized") except Exception as e: click.echo(f"Error syncing workspace: {e}", err=True) sys.exit(1)