diff --git a/asset_registry.json b/asset_registry.json index b80f7d52..0c1068b4 100644 --- a/asset_registry.json +++ b/asset_registry.json @@ -1,3 +1,20 @@ { - "assets": {} + "assets": { + "ce929473e245c3323128ed7b84ab48a8553cdeea5275c702427d69fdff1db453": { + "path": "/home/worsch/markitect_project/assets/ce/ce929473e245c3323128ed7b84ab48a8553cdeea5275c702427d69fdff1db453.txt", + "content_hash": "ce929473e245c3323128ed7b84ab48a8553cdeea5275c702427d69fdff1db453", + "mime_type": "text/plain", + "size": 23, + "created_at": "2025-10-14T11:39:25.556553", + "description": "Test asset for validation" + }, + "eb41ad8186ddebf801dd8c64bd75e5b18c95d8bce648be10ae298f5fd97fe3a6": { + "path": "/home/worsch/markitect_project/assets/eb/eb41ad8186ddebf801dd8c64bd75e5b18c95d8bce648be10ae298f5fd97fe3a6.png", + "content_hash": "eb41ad8186ddebf801dd8c64bd75e5b18c95d8bce648be10ae298f5fd97fe3a6", + "mime_type": "image/png", + "size": 16, + "created_at": "2025-10-14T13:24:28.557669", + "description": "Added to /tmp/tmps0xehpw5/project" + } + } } \ No newline at end of file diff --git a/assets/ce/ce929473e245c3323128ed7b84ab48a8553cdeea5275c702427d69fdff1db453.txt b/assets/ce/ce929473e245c3323128ed7b84ab48a8553cdeea5275c702427d69fdff1db453.txt new file mode 100644 index 00000000..a2e1193c --- /dev/null +++ b/assets/ce/ce929473e245c3323128ed7b84ab48a8553cdeea5275c702427d69fdff1db453.txt @@ -0,0 +1 @@ +Hello Asset Management! \ No newline at end of file diff --git a/assets/eb/eb41ad8186ddebf801dd8c64bd75e5b18c95d8bce648be10ae298f5fd97fe3a6.png b/assets/eb/eb41ad8186ddebf801dd8c64bd75e5b18c95d8bce648be10ae298f5fd97fe3a6.png new file mode 100644 index 00000000..2431a343 --- /dev/null +++ b/assets/eb/eb41ad8186ddebf801dd8c64bd75e5b18c95d8bce648be10ae298f5fd97fe3a6.png @@ -0,0 +1 @@ +fake png content \ No newline at end of file diff --git a/markitect/asset_commands.py b/markitect/asset_commands.py new file mode 100644 index 00000000..3e242057 --- /dev/null +++ b/markitect/asset_commands.py @@ -0,0 +1,482 @@ +""" +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) \ No newline at end of file diff --git a/markitect/cli.py b/markitect/cli.py index dcbe96f8..a68b2167 100644 --- a/markitect/cli.py +++ b/markitect/cli.py @@ -6394,6 +6394,16 @@ if PROFILE_MANAGEMENT_AVAILABLE: # Register paradigms commands cli.add_command(paradigms) +# Register asset management commands - Issue #143 +try: + from .asset_commands import asset, package, workspace + cli.add_command(asset) + cli.add_command(package) + cli.add_command(workspace) + ASSET_COMMANDS_AVAILABLE = True +except ImportError: + ASSET_COMMANDS_AVAILABLE = False + # Register markdown commands plugin try: from .plugins.builtin.markdown_commands import MarkdownCommandsPlugin diff --git a/markitect/cli_utils.py b/markitect/cli_utils.py new file mode 100644 index 00000000..8c622fa5 --- /dev/null +++ b/markitect/cli_utils.py @@ -0,0 +1,336 @@ +""" +CLI utilities for MarkiTect command-line interface. + +This module provides common utilities and patterns used across CLI commands: +- Output formatting (table, JSON) +- Error handling decorators +- Common Click options +- Configuration loading helpers + +Used by asset management commands and can be extended for other CLI modules. +""" + +import click +import json +import sys +from functools import wraps +from pathlib import Path +from tabulate import tabulate +from typing import Any, Dict, List, Optional, Callable + +# Import for configuration support +try: + from .config_manager import ConfigurationManager + CONFIG_AVAILABLE = True +except ImportError: + CONFIG_AVAILABLE = False + + +def format_table_output(data: List[Dict[str, Any]], headers: List[str], + tablefmt: str = 'grid') -> str: + """Format data as table for console output. + + Args: + data: List of dictionaries containing row data + headers: List of column headers + tablefmt: Table format style (default: 'grid') + + Returns: + Formatted table string + """ + if not data: + return "No data to display" + + # Convert dict data to list of lists for tabulate + table_data = [] + for item in data: + row = [item.get(header.lower(), item.get(header, 'N/A')) for header in headers] + table_data.append(row) + + return tabulate(table_data, headers=headers, tablefmt=tablefmt) + + +def format_json_output(data: Any, indent: int = 2) -> str: + """Format data as JSON for programmatic consumption. + + Args: + data: Data to format as JSON + indent: JSON indentation level + + Returns: + JSON formatted string + """ + return json.dumps(data, indent=indent, default=str) + + +def handle_asset_errors(func: Callable) -> Callable: + """Decorator to handle common asset management errors. + + Provides consistent error handling for asset-related CLI commands. + """ + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except ImportError as e: + if "assets" in str(e).lower(): + click.echo("Error: Asset management backend not available", err=True) + click.echo("Ensure markitect.assets module is properly installed", err=True) + else: + click.echo(f"Import error: {e}", err=True) + sys.exit(1) + except Exception as e: + # Import asset exceptions if available + try: + from .assets import AssetError, PackagingError + if isinstance(e, (AssetError, PackagingError)): + click.echo(f"Asset error: {e}", err=True) + else: + click.echo(f"Unexpected error: {e}", err=True) + except ImportError: + click.echo(f"Unexpected error: {e}", err=True) + sys.exit(1) + + return wrapper + + +def require_workspace(func: Callable) -> Callable: + """Decorator to ensure workspace exists before running command. + + Checks for workspace directory and shows helpful message if not found. + """ + @wraps(func) + def wrapper(*args, **kwargs): + workspace_dir = Path.cwd() / "markitect_workspace" + if not workspace_dir.exists(): + click.echo("No workspace found in current directory", err=True) + click.echo("Run 'markitect workspace init' to create one", err=True) + sys.exit(1) + return func(*args, **kwargs) + + return wrapper + + +# Common Click options +def output_format_option(default: str = 'table'): + """Common output format option for list commands.""" + return click.option( + '--format', 'output_format', + type=click.Choice(['table', 'json']), + default=default, + help=f'Output format (default: {default})' + ) + + +def dry_run_option(): + """Common dry-run option for potentially destructive commands.""" + return click.option( + '--dry-run', is_flag=True, + help='Show what would be done without making changes' + ) + + +def verbose_option(): + """Common verbose option for detailed output.""" + return click.option( + '--verbose', '-v', is_flag=True, + help='Enable verbose output' + ) + + +class ClickOutputFormatter: + """ + Helper class for consistent CLI output formatting across MarkiTect commands. + + Provides standardized methods for displaying success, info, warning, and error + messages with consistent formatting including icons and structured details. + + Usage: + ClickOutputFormatter.success("Operation completed", {"Files": 5}) + ClickOutputFormatter.error("Failed to process") + """ + + @staticmethod + def success(message: str, details: Optional[Dict[str, Any]] = None): + """ + Display success message with checkmark and optional details. + + Args: + message: Success message to display + details: Optional dictionary of key-value details to show + """ + click.echo(f"✓ {message}") + if details: + for key, value in details.items(): + click.echo(f" {key}: {value}") + + @staticmethod + def info(message: str, details: Optional[Dict[str, Any]] = None): + """ + Display informational message with optional details. + + Args: + message: Info message to display + details: Optional dictionary of key-value details to show + """ + click.echo(message) + if details: + for key, value in details.items(): + click.echo(f" {key}: {value}") + + @staticmethod + def warning(message: str): + """ + Display warning message with warning icon. + + Args: + message: Warning message to display + """ + click.echo(f"⚠ {message}", err=True) + + @staticmethod + def error(message: str, exit_code: int = 1): + """ + Display error message with error icon and exit. + + Args: + message: Error message to display + exit_code: Exit code to use (default: 1) + """ + click.echo(f"✗ {message}", err=True) + sys.exit(exit_code) + + @staticmethod + def table(data: List[Dict[str, Any]], headers: List[str]): + """Display data as formatted table.""" + if not data: + click.echo("No data to display") + return + + table_output = format_table_output(data, headers) + click.echo(table_output) + + @staticmethod + def json_output(data: Any): + """Display data as JSON.""" + json_output = format_json_output(data) + click.echo(json_output) + + +def get_configuration() -> Optional[Dict[str, Any]]: + """Get current markitect configuration. + + Returns: + Configuration dictionary if available, None otherwise + """ + if not CONFIG_AVAILABLE: + return None + + try: + config_manager = ConfigurationManager() + return config_manager.get_config() + except Exception: + return None + + +def get_asset_config() -> Dict[str, Any]: + """Get asset management configuration with defaults. + + Returns: + Asset configuration dictionary with sensible defaults + """ + config = get_configuration() + + if config and 'asset_management' in config: + asset_config = config['asset_management'] + else: + asset_config = {} + + # Apply defaults + defaults = { + 'enabled': True, + 'workspace_path': './markitect_workspace', + 'shared_assets_path': './markitect_workspace/shared_assets', + 'packages_path': './markitect_workspace/packages', + 'auto_dedupe': True, + 'symlink_preferred': True, + 'fallback_to_copy': True, + 'compression_level': 6, + 'include_manifest': True, + 'validate_on_create': True, + 'cache_enabled': True, + 'batch_size': 100, + 'max_file_size_mb': 50 + } + + # Merge with defaults + for key, default_value in defaults.items(): + if key not in asset_config: + asset_config[key] = default_value + + return asset_config + + +def validate_file_path(path: str, must_exist: bool = True) -> Path: + """Validate and normalize file path. + + Args: + path: File path string + must_exist: Whether file must exist + + Returns: + Validated Path object + + Raises: + click.ClickException: If validation fails + """ + file_path = Path(path).resolve() + + if must_exist and not file_path.exists(): + raise click.ClickException(f"File not found: {file_path}") + + if must_exist and file_path.is_dir(): + raise click.ClickException(f"Expected file, got directory: {file_path}") + + return file_path + + +def validate_directory_path(path: str, must_exist: bool = True, + create_if_missing: bool = False) -> Path: + """Validate and normalize directory path. + + Args: + path: Directory path string + must_exist: Whether directory must exist + create_if_missing: Whether to create directory if missing + + Returns: + Validated Path object + + Raises: + click.ClickException: If validation fails + """ + dir_path = Path(path).resolve() + + if not dir_path.exists(): + if create_if_missing: + dir_path.mkdir(parents=True, exist_ok=True) + elif must_exist: + raise click.ClickException(f"Directory not found: {dir_path}") + elif dir_path.exists() and not dir_path.is_dir(): + raise click.ClickException(f"Expected directory, got file: {dir_path}") + + return dir_path + + +def confirm_destructive_action(message: str, default: bool = False) -> bool: + """Prompt user to confirm destructive action. + + Args: + message: Confirmation message + default: Default choice if user just presses enter + + Returns: + True if user confirms, False otherwise + """ + return click.confirm(message, default=default) \ No newline at end of file diff --git a/tests/test_issue_143_cli_commands.py b/tests/test_issue_143_cli_commands.py new file mode 100644 index 00000000..53780d6a --- /dev/null +++ b/tests/test_issue_143_cli_commands.py @@ -0,0 +1,171 @@ +""" +Integration tests for Issue #143 CLI commands. + +This module tests the CLI commands implemented for Issue #143: +- Asset management commands (add, list, stats, cleanup) +- Package management commands (create, extract, list, validate) +- Workspace management commands (init, status, sync) + +Tests verify that CLI commands are properly registered and functional. +""" + +import pytest +import tempfile +from pathlib import Path +from click.testing import CliRunner + +# Import CLI module +from markitect.cli import cli + + +class TestAssetCLIIntegration: + """Test asset CLI command integration.""" + + def setup_method(self): + """Set up test environment.""" + self.runner = CliRunner() + + def test_asset_command_group_available(self): + """Test that asset command group is available.""" + result = self.runner.invoke(cli, ['asset', '--help']) + assert result.exit_code == 0 + assert 'Asset management commands' in result.output + + def test_asset_subcommands_available(self): + """Test that asset subcommands are available.""" + result = self.runner.invoke(cli, ['asset', '--help']) + assert result.exit_code == 0 + assert 'add' in result.output + assert 'list' in result.output + assert 'stats' in result.output + assert 'cleanup' in result.output + + +class TestPackageCLIIntegration: + """Test package CLI command integration.""" + + def setup_method(self): + """Set up test environment.""" + self.runner = CliRunner() + + def test_package_command_group_available(self): + """Test that package command group is available.""" + result = self.runner.invoke(cli, ['package', '--help']) + assert result.exit_code == 0 + assert 'Package management commands' in result.output + + def test_package_subcommands_available(self): + """Test that package subcommands are available.""" + result = self.runner.invoke(cli, ['package', '--help']) + assert result.exit_code == 0 + assert 'create' in result.output + assert 'extract' in result.output + assert 'list' in result.output + assert 'validate' in result.output + + +class TestWorkspaceCLIIntegration: + """Test workspace CLI command integration.""" + + def setup_method(self): + """Set up test environment.""" + self.runner = CliRunner() + + def test_workspace_command_group_available(self): + """Test that workspace command group is available.""" + result = self.runner.invoke(cli, ['workspace', '--help']) + assert result.exit_code == 0 + assert 'Workspace management commands' in result.output + + def test_workspace_subcommands_available(self): + """Test that workspace subcommands are available.""" + result = self.runner.invoke(cli, ['workspace', '--help']) + assert result.exit_code == 0 + assert 'init' in result.output + assert 'status' in result.output + assert 'sync' in result.output + + +class TestCLIMainIntegration: + """Test integration with main CLI.""" + + def setup_method(self): + """Set up test environment.""" + self.runner = CliRunner() + + def test_main_cli_shows_asset_commands(self): + """Test that main CLI help shows asset management commands.""" + result = self.runner.invoke(cli, ['--help']) + assert result.exit_code == 0 + assert 'asset' in result.output + assert 'package' in result.output + assert 'workspace' in result.output + + def test_commands_dont_conflict_with_existing(self): + """Test that new commands don't conflict with existing ones.""" + # Test that existing commands still work + result = self.runner.invoke(cli, ['version']) + assert result.exit_code == 0 + + result = self.runner.invoke(cli, ['config-show']) + assert result.exit_code == 0 + + +class TestCLIEndToEndWorkflow: + """Test end-to-end CLI workflow.""" + + def setup_method(self): + """Set up test environment.""" + self.runner = CliRunner() + + def test_basic_workspace_workflow(self): + """Test basic workspace initialization workflow.""" + with self.runner.isolated_filesystem(): + # Initialize workspace + result = self.runner.invoke(cli, ['workspace', 'init']) + assert result.exit_code == 0 + assert 'successfully' in result.output.lower() + + # Check workspace status + result = self.runner.invoke(cli, ['workspace', 'status']) + assert result.exit_code == 0 + assert 'workspace' in result.output.lower() + + def test_asset_stats_command(self): + """Test asset stats command basic functionality.""" + result = self.runner.invoke(cli, ['asset', 'stats']) + # Should not crash and should show some stats + assert result.exit_code == 0 + assert 'assets' in result.output.lower() + + def test_package_list_command(self): + """Test package list command basic functionality.""" + result = self.runner.invoke(cli, ['package', 'list']) + # Should not crash - might show no packages + assert result.exit_code == 0 + + +class TestCLIErrorHandling: + """Test CLI error handling.""" + + def setup_method(self): + """Set up test environment.""" + self.runner = CliRunner() + + def test_invalid_asset_subcommand(self): + """Test handling of invalid asset subcommand.""" + result = self.runner.invoke(cli, ['asset', 'invalid_command']) + assert result.exit_code != 0 + assert 'No such command' in result.output or 'invalid' in result.output + + def test_invalid_package_subcommand(self): + """Test handling of invalid package subcommand.""" + result = self.runner.invoke(cli, ['package', 'invalid_command']) + assert result.exit_code != 0 + assert 'No such command' in result.output or 'invalid' in result.output + + def test_invalid_workspace_subcommand(self): + """Test handling of invalid workspace subcommand.""" + result = self.runner.invoke(cli, ['workspace', 'invalid_command']) + assert result.exit_code != 0 + assert 'No such command' in result.output or 'invalid' in result.output \ No newline at end of file