Initial public release v0.1.0 - Linux Wabbajack Modlist Application

Jackify provides native Linux support for Wabbajack modlist installation
   and management with automated Steam integration and Proton configuration.

   Key Features:
   - Almost Native Linux implementation (texconv.exe run via proton)
   - Automated Steam shortcut creation and Proton prefix management
   - Both CLI and GUI interfaces, with Steam Deck optimization

   Supported Games:
   - Skyrim Special Edition
   - Fallout 4
   - Fallout New Vegas
   - Oblivion, Starfield, Enderal, and diverse other games

   Technical Architecture:
   - Clean separation between frontend and backend services
   - Powered by jackify-engine 0.3.x for Wabbajack-matching modlist installation
This commit is contained in:
Omni
2025-09-05 20:46:24 +01:00
commit cd591c14e3
445 changed files with 40398 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""
Jackify CLI Frontend
Command-line interface for Jackify that uses the backend services.
"""

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Jackify CLI Frontend Entry Point
New entry point for the CLI frontend that uses the refactored structure.
"""
import sys
import signal
import logging
from .main import JackifyCLI
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def terminate_children(signum, frame):
"""Signal handler to terminate child processes on exit"""
print("Received signal, shutting down...")
sys.exit(0)
def main():
"""Main entry point for the CLI frontend"""
# Set up signal handlers
signal.signal(signal.SIGTERM, terminate_children)
signal.signal(signal.SIGINT, terminate_children)
try:
cli = JackifyCLI()
exit_code = cli.run()
sys.exit(exit_code or 0)
except KeyboardInterrupt:
print("\nOperation cancelled by user")
sys.exit(130) # Standard exit code for SIGINT
except Exception as e:
print(f"Fatal error: {e}")
logging.exception("Fatal error in CLI frontend")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,5 @@
"""
CLI Commands
Individual command implementations for the CLI interface.
"""

View File

@@ -0,0 +1,159 @@
"""
Configure Modlist Command
CLI command for configuring a modlist post-install.
Extracted from the original jackify-cli.py.
"""
import os
import logging
from typing import Optional
# Import the backend services we'll need
from jackify.backend.models.configuration import ConfigurationContext
from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET
logger = logging.getLogger(__name__)
class ConfigureModlistCommand:
"""Handler for the configure-modlist CLI command."""
def __init__(self, backend_services):
"""Initialize with backend services.
Args:
backend_services: Dictionary of backend service instances
"""
self.backend_services = backend_services
self.test_mode = False # TODO: Get from global config
def add_parser(self, subparsers):
"""Add the configure-modlist subcommand parser.
Args:
subparsers: The ArgumentParser subparsers object
"""
configure_modlist_parser = subparsers.add_parser(
"configure-modlist",
help="Configure a modlist post-install (for GUI integration)"
)
configure_modlist_parser.add_argument(
"--modlist-name",
type=str,
required=True,
help="Name of the modlist to configure (Steam shortcut name)"
)
configure_modlist_parser.add_argument(
"--install-dir",
type=str,
required=True,
help="Install directory of the modlist"
)
configure_modlist_parser.add_argument(
"--download-dir",
type=str,
help="Downloads directory (optional)"
)
configure_modlist_parser.add_argument(
"--nexus-api-key",
type=str,
help="Nexus API key (optional)"
)
configure_modlist_parser.add_argument(
"--mo2-exe-path",
type=str,
help="Path to ModOrganizer.exe (for AppID lookup)"
)
configure_modlist_parser.add_argument(
"--resolution",
type=str,
help="Resolution to set (optional)"
)
configure_modlist_parser.add_argument(
"--skip-confirmation",
action='store_true',
help="Skip confirmation prompts"
)
return configure_modlist_parser
def execute(self, args) -> int:
"""Execute the configure-modlist command.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
logger.info("Starting non-interactive modlist configuration (CLI mode)")
try:
# Build configuration context from args
context = self._build_context_from_args(args)
# Use legacy implementation for now - will migrate to backend services later
result = self._execute_legacy_configuration(context)
logger.info("Finished non-interactive modlist configuration")
return 0 if result is not True else 1
except Exception as e:
logger.error(f"Failed to configure modlist: {e}")
print(f"{COLOR_ERROR}Configuration failed: {e}{COLOR_RESET}")
return 1
def _build_context_from_args(self, args) -> dict:
"""Build context dictionary from command arguments.
Args:
args: Parsed command-line arguments
Returns:
Context dictionary
"""
return {
'modlist_name': getattr(args, 'modlist_name', None),
'install_dir': getattr(args, 'install_dir', None),
'download_dir': getattr(args, 'download_dir', None),
'nexus_api_key': getattr(args, 'nexus_api_key', os.environ.get('NEXUS_API_KEY')),
'mo2_exe_path': getattr(args, 'mo2_exe_path', None),
'resolution': getattr(args, 'resolution', None),
'skip_confirmation': getattr(args, 'skip_confirmation', False),
'modlist_value': getattr(args, 'modlist_value', None),
'modlist_source': getattr(args, 'modlist_source', None),
}
def _execute_legacy_configuration(self, context: dict):
"""Execute configuration using legacy implementation.
This is a temporary bridge - will be replaced with backend service calls.
Args:
context: Configuration context dictionary
Returns:
Result from legacy configuration
"""
# Import backend services
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
from jackify.backend.handlers.config_handler import ConfigHandler
# Create legacy handler instances
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(
config_handler=config_handler,
test_mode=self.test_mode
)
# Execute legacy configuration workflow
# The _configure_new_modlist method already handles Steam restart, manual steps, and configuration
result = modlist_menu._configure_new_modlist(
default_modlist_dir=context['install_dir'],
default_modlist_name=context['modlist_name']
)
# The _configure_new_modlist method already calls run_modlist_configuration_phase internally
# So we don't need to call it again here
return result

View File

@@ -0,0 +1,363 @@
"""
Install Modlist Command
CLI command for installing modlists.
Extracted from the original jackify-cli.py.
"""
import os
import logging
from typing import Optional
# Import the backend services we'll need
from jackify.backend.models.modlist import ModlistContext
from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET
logger = logging.getLogger(__name__)
class InstallModlistCommand:
"""Handler for the install-modlist CLI command."""
def __init__(self, backend_services, system_info):
"""Initialize with backend services.
Args:
backend_services: Dictionary of backend service instances
system_info: System information (steamdeck flag, etc.)
"""
self.backend_services = backend_services
self.system_info = system_info
def add_top_level_args(self, parser):
"""Add top-level install-modlist arguments to the main parser.
Args:
parser: The main ArgumentParser
"""
parser.add_argument(
"--install-modlist",
action="store_true",
help="Enable modlist install/list feature (for GUI integration)"
)
parser.add_argument(
"--list-modlists",
action="store_true",
help="List available modlists for a game type (with --install-modlist)"
)
parser.add_argument(
"--install",
action="store_true",
help="Install a modlist non-interactively (with --install-modlist)"
)
parser.add_argument(
"--game-type",
type=str,
default=None,
help="Game type to filter modlists (skyrim, fallout4, falloutnv, oblivion, starfield, oblivion_remastered, other)"
)
parser.add_argument(
"--modlist-value",
type=str,
help="Modlist identifier for online modlists"
)
def add_parser(self, subparsers):
"""Add the install-modlist subcommand parser.
Args:
subparsers: The ArgumentParser subparsers object
"""
install_modlist_parser = subparsers.add_parser(
"install-modlist",
help="Install or list available modlists"
)
install_modlist_parser.add_argument(
"--list",
action="store_true",
help="List available modlists for a game type"
)
install_modlist_parser.add_argument(
"--game-type",
type=str,
default=None,
help="Game type to filter modlists (skyrim, fallout4, falloutnv, oblivion, starfield, oblivion_remastered, other)"
)
return install_modlist_parser
def execute_top_level(self, args) -> int:
"""Execute top-level install-modlist functionality.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
if getattr(args, 'list_modlists', False):
return self.list_modlists(args)
elif getattr(args, 'install', False):
return self.install_modlist_auto(args)
else:
print(f"{COLOR_ERROR}No valid install-modlist operation specified{COLOR_RESET}")
return 1
def execute_subcommand(self, args) -> int:
"""Execute the install-modlist subcommand.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
if getattr(args, 'list', False):
return self.list_modlists(args)
else:
# Default behavior: run interactive modlist installation
logger.info("Starting interactive modlist installation via subcommand")
try:
# Use the working ModlistInstallCLI for interactive installation
from jackify.backend.core.modlist_operations import ModlistInstallCLI
# Use new SystemInfo pattern
modlist_cli = ModlistInstallCLI(self.system_info)
# Run interactive discovery phase
context = modlist_cli.run_discovery_phase()
if context:
# Run configuration phase (installation + Steam setup)
modlist_cli.configuration_phase()
logger.info("Interactive modlist installation completed successfully")
return 0
else:
logger.info("Modlist installation cancelled by user")
return 1
except Exception as e:
logger.error(f"Failed to install modlist: {e}")
print(f"{COLOR_ERROR}Installation failed: {e}{COLOR_RESET}")
return 1
def list_modlists(self, args) -> int:
"""List available modlists for a game type.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
logger.info("Listing available modlists")
try:
# Use legacy implementation for now - will migrate to backend services later
result = self._execute_legacy_list_modlists(args)
return 0
except Exception as e:
logger.error(f"Failed to list modlists: {e}")
print(f"{COLOR_ERROR}Failed to list modlists: {e}{COLOR_RESET}")
return 1
def install_modlist_auto(self, args) -> int:
"""Install a modlist non-interactively.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
logger.info("Starting non-interactive modlist installation")
try:
# Build context from args
context = self._build_install_context_from_args(args)
# Validate required fields
if not self._validate_install_context(context):
return 1
# Use legacy implementation for now - will migrate to backend services later
result = self._execute_legacy_install(context)
logger.info("Finished non-interactive modlist installation")
return result
except Exception as e:
logger.error(f"Failed to install modlist: {e}")
print(f"{COLOR_ERROR}Installation failed: {e}{COLOR_RESET}")
return 1
def _build_install_context_from_args(self, args) -> dict:
"""Build installation context from command arguments.
Args:
args: Parsed command-line arguments
Returns:
Context dictionary
"""
return {
'modlist_name': getattr(args, 'modlist_name', None),
'install_dir': getattr(args, 'install_dir', None),
'download_dir': getattr(args, 'download_dir', None),
'nexus_api_key': os.environ.get('NEXUS_API_KEY'),
'game_type': getattr(args, 'game_type', None),
'modlist_value': getattr(args, 'modlist_value', None),
'skip_confirmation': True,
'resolution': getattr(args, 'resolution', None),
}
def _validate_install_context(self, context: dict) -> bool:
"""Validate installation context.
Args:
context: Installation context dictionary
Returns:
True if valid, False otherwise
"""
is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type']
missing = [k for k in required_keys if not context.get(k)]
if is_gui_mode and missing:
print(f"ERROR: Missing required arguments for GUI workflow: {', '.join(missing)}")
print("This workflow must be fully non-interactive. Please report this as a bug if you see this message.")
return False
return True
def _execute_legacy_list_modlists(self, args):
"""Execute list modlists using backend implementation.
Args:
args: Parsed command-line arguments
"""
# Import backend services
from jackify.backend.core.modlist_operations import ModlistInstallCLI
# Use new SystemInfo pattern
modlist_cli = ModlistInstallCLI(self.system_info)
# Get all modlists from engine
raw_modlists = modlist_cli.get_all_modlists_from_engine()
# Group by game type as in original CLI
game_type_map = {
'skyrim': ['Skyrim', 'Skyrim Special Edition'],
'fallout4': ['Fallout 4'],
'falloutnv': ['Fallout New Vegas'],
'oblivion': ['Oblivion'],
'starfield': ['Starfield'],
'oblivion_remastered': ['Oblivion Remastered', 'OblivionRemastered'],
'other': None
}
grouped_modlists = {k: [] for k in game_type_map}
for m_info in raw_modlists: # m_info is like {'id': ..., 'game': ...}
found_category = False
for cat_key, cat_keywords in game_type_map.items():
if cat_key == 'other':
continue
if cat_keywords:
for keyword in cat_keywords:
if keyword.lower() in m_info.get('game', '').lower():
grouped_modlists[cat_key].append(m_info)
found_category = True
break
if found_category:
break
if not found_category:
grouped_modlists['other'].append(m_info)
# Output modlists for the requested game type
game_type = (getattr(args, 'game_type', '') or '').lower()
if game_type and game_type in grouped_modlists:
for m in grouped_modlists[game_type]:
print(m.get('id', ''))
else:
# Output all modlists
for cat_key in ['skyrim', 'fallout4', 'falloutnv', 'oblivion', 'starfield', 'oblivion_remastered', 'other']:
for m in grouped_modlists[cat_key]:
print(m.get('id', ''))
def _execute_legacy_install(self, context: dict) -> int:
"""Execute installation using backend implementation.
Args:
context: Installation context dictionary
Returns:
Exit code
"""
# Import backend services
from jackify.backend.core.modlist_operations import ModlistInstallCLI
from jackify.shared.colors import COLOR_WARNING, COLOR_PROMPT
# Use new SystemInfo pattern
modlist_cli = ModlistInstallCLI(self.system_info)
# Detect game type and check support
game_type = None
wabbajack_file_path = context.get('wabbajack_file_path')
modlist_info = context.get('modlist_info')
if wabbajack_file_path:
game_type = modlist_cli.detect_game_type(wabbajack_file_path=wabbajack_file_path)
elif modlist_info:
game_type = modlist_cli.detect_game_type(modlist_info=modlist_info)
elif context.get('game_type'):
game_type = context['game_type']
# Check if game is supported
if game_type and not modlist_cli.check_game_support(game_type):
# Show unsupported game warning
supported_games = modlist_cli.wabbajack_parser.get_supported_games_display_names()
supported_games_str = ", ".join(supported_games)
print(f"\n{COLOR_WARNING}Game Support Notice{COLOR_RESET}")
print(f"{COLOR_WARNING}While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to: {supported_games_str}.{COLOR_RESET}")
print(f"{COLOR_WARNING}We are working to add more automated support in future releases!{COLOR_RESET}")
# Ask for confirmation to continue
response = input(f"{COLOR_PROMPT}Click Enter to continue with the modlist installation, or type 'cancel' to abort: {COLOR_RESET}").strip().lower()
if response == 'cancel':
print("[INFO] Modlist installation cancelled by user.")
return 1
is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
if is_gui_mode:
confirmed_context = modlist_cli.run_discovery_phase(context_override=context)
if confirmed_context:
# For unsupported games, skip post-install configuration
if game_type and not modlist_cli.check_game_support(game_type):
print(f"{COLOR_WARNING}Modlist installation completed successfully.{COLOR_RESET}")
print(f"{COLOR_WARNING}Note: Post-install configuration was skipped for unsupported game type: {game_type}{COLOR_RESET}")
return 0
else:
modlist_cli.configuration_phase()
return 0
else:
print("[INFO] Modlist installation cancelled or not confirmed.")
return 1
else:
# CLI mode: allow interactive prompts as before
confirmed_context = modlist_cli.run_discovery_phase(context_override=context)
if confirmed_context:
# For unsupported games, skip post-install configuration
if game_type and not modlist_cli.check_game_support(game_type):
print(f"{COLOR_WARNING}Modlist installation completed successfully.{COLOR_RESET}")
print(f"{COLOR_WARNING}Note: Post-install configuration was skipped for unsupported game type: {game_type}{COLOR_RESET}")
return 0
else:
modlist_cli.configuration_phase()
return 0
else:
print("[INFO] Modlist installation cancelled or not confirmed.")
return 1

View File

@@ -0,0 +1,247 @@
"""
Tuxborn Command
CLI command for the Tuxborn Automatic Installer.
Extracted from the original jackify-cli.py.
"""
import os
import sys
import logging
from pathlib import Path
from typing import Optional
# Import the backend services we'll need
from jackify.backend.models.modlist import ModlistContext
from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET
logger = logging.getLogger(__name__)
class TuxbornCommand:
"""Handler for the tuxborn-auto CLI command."""
def __init__(self, backend_services, system_info):
"""Initialize with backend services.
Args:
backend_services: Dictionary of backend service instances
system_info: System information (steamdeck flag, etc.)
"""
self.backend_services = backend_services
self.system_info = system_info
def add_args(self, parser):
"""Add tuxborn-auto arguments to the main parser.
Args:
parser: The main ArgumentParser
"""
parser.add_argument(
"--tuxborn-auto",
action="store_true",
help="Run the Tuxborn Automatic Installer non-interactively (for GUI integration)"
)
parser.add_argument(
"--install-dir",
type=str,
help="Install directory for Tuxborn (required with --tuxborn-auto)"
)
parser.add_argument(
"--download-dir",
type=str,
help="Downloads directory for Tuxborn (required with --tuxborn-auto)"
)
parser.add_argument(
"--modlist-name",
type=str,
default="Tuxborn",
help="Modlist name (optional, defaults to 'Tuxborn')"
)
def execute(self, args) -> int:
"""Execute the tuxborn-auto command.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
logger.info("Starting Tuxborn Automatic Installer (GUI integration mode)")
try:
# Set up logging redirection (copied from original)
self._setup_tee_logging()
# Build context from args
context = self._build_context_from_args(args)
# Validate required fields
if not self._validate_context(context):
return 1
# Use legacy implementation for now - will migrate to backend services later
result = self._execute_legacy_tuxborn(context)
logger.info("Finished Tuxborn Automatic Installer")
return result
except Exception as e:
logger.error(f"Failed to run Tuxborn installer: {e}")
print(f"{COLOR_ERROR}Tuxborn installation failed: {e}{COLOR_RESET}")
return 1
finally:
# Restore stdout/stderr
self._restore_stdout_stderr()
def _build_context_from_args(self, args) -> dict:
"""Build context dictionary from command arguments.
Args:
args: Parsed command-line arguments
Returns:
Context dictionary
"""
install_dir = getattr(args, 'install_dir', None)
download_dir = getattr(args, 'download_dir', None)
modlist_name = getattr(args, 'modlist_name', 'Tuxborn')
machineid = 'Tuxborn/Tuxborn'
# Try to get API key from saved config first, then environment variable
from jackify.backend.services.api_key_service import APIKeyService
api_key_service = APIKeyService()
api_key = api_key_service.get_saved_api_key()
if not api_key:
api_key = os.environ.get('NEXUS_API_KEY')
resolution = getattr(args, 'resolution', None)
mo2_exe_path = getattr(args, 'mo2_exe_path', None)
skip_confirmation = True # Always true in GUI mode
context = {
'machineid': machineid,
'modlist_name': modlist_name,
'install_dir': install_dir,
'download_dir': download_dir,
'nexus_api_key': api_key,
'skip_confirmation': skip_confirmation,
'resolution': resolution,
'mo2_exe_path': mo2_exe_path,
}
# PATCH: Always set modlist_value and modlist_source for Tuxborn workflow
context['modlist_value'] = 'Tuxborn/Tuxborn'
context['modlist_source'] = 'identifier'
return context
def _validate_context(self, context: dict) -> bool:
"""Validate Tuxborn context.
Args:
context: Tuxborn context dictionary
Returns:
True if valid, False otherwise
"""
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key']
missing = [k for k in required_keys if not context.get(k)]
if missing:
print(f"{COLOR_ERROR}Missing required arguments for --tuxborn-auto.\\n"
f"--install-dir, --download-dir, and NEXUS_API_KEY (env, 32+ chars) are required.{COLOR_RESET}")
return False
return True
def _setup_tee_logging(self):
"""Set up TEE logging (copied from original implementation)."""
import shutil
# TEE logging setup & log rotation (copied from original)
class TeeStdout:
def __init__(self, *files):
self.files = files
def write(self, data):
for f in self.files:
f.write(data)
f.flush()
def flush(self):
for f in self.files:
f.flush()
log_dir = Path.home() / "Jackify" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
workflow_log_path = log_dir / "tuxborn_workflow.log"
# Log rotation: keep last 3 logs, 1KB each (for testing)
max_logs = 3
max_size = 1024 # 1KB for testing
if workflow_log_path.exists() and workflow_log_path.stat().st_size > max_size:
for i in range(max_logs, 0, -1):
prev = log_dir / f"tuxborn_workflow.log.{i-1}" if i > 1 else workflow_log_path
dest = log_dir / f"tuxborn_workflow.log.{i}"
if prev.exists():
if dest.exists():
dest.unlink()
prev.rename(dest)
self.workflow_log = open(workflow_log_path, 'a')
self.orig_stdout, self.orig_stderr = sys.stdout, sys.stderr
sys.stdout = TeeStdout(sys.stdout, self.workflow_log)
sys.stderr = TeeStdout(sys.stderr, self.workflow_log)
def _restore_stdout_stderr(self):
"""Restore original stdout/stderr."""
if hasattr(self, 'orig_stdout'):
sys.stdout = self.orig_stdout
sys.stderr = self.orig_stderr
if hasattr(self, 'workflow_log'):
self.workflow_log.close()
def _execute_legacy_tuxborn(self, context: dict) -> int:
"""Execute Tuxborn using legacy implementation.
Args:
context: Tuxborn context dictionary
Returns:
Exit code
"""
# Import backend services
from jackify.backend.core.modlist_operations import ModlistInstallCLI
from jackify.backend.handlers.menu_handler import MenuHandler
# Create legacy handler instances
menu_handler = MenuHandler()
modlist_cli = ModlistInstallCLI(
menu_handler=menu_handler,
steamdeck=self.system_info.get('is_steamdeck', False)
)
confirmed_context = modlist_cli.run_discovery_phase(context_override=context)
if confirmed_context:
menu_handler.logger.info("Tuxborn discovery confirmed by GUI. Proceeding to configuration/installation.")
modlist_cli.configuration_phase()
# Handle GUI integration prompts (copied from original)
print('[PROMPT:RESTART_STEAM]')
if os.environ.get('JACKIFY_GUI_MODE'):
input() # Wait for GUI to send confirmation, no CLI prompt
else:
answer = input('Restart Steam automatically now? (Y/n): ')
# ... handle answer as before ...
print('[PROMPT:MANUAL_STEPS]')
if os.environ.get('JACKIFY_GUI_MODE'):
input() # Wait for GUI to send confirmation, no CLI prompt
else:
input('Once you have completed ALL the steps above, press Enter to continue...')
return 0
else:
menu_handler.logger.info("Tuxborn discovery/confirmation cancelled or failed (GUI mode).")
print(f"{COLOR_INFO}Tuxborn installation cancelled or not confirmed.{COLOR_RESET}")
return 1

441
jackify/frontends/cli/main.py Executable file
View File

@@ -0,0 +1,441 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Jackify CLI Frontend - Main Entry Point
Command-line interface for Jackify that uses the backend services.
Extracted and refactored from the original jackify-cli.py.
"""
import sys
import os
import argparse
import logging
# Import from our new backend structure
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.modlist_service import ModlistService
from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET
from jackify import __version__ as jackify_version
# Import our command handlers
from .commands.configure_modlist import ConfigureModlistCommand
from .commands.install_modlist import InstallModlistCommand
from .commands.tuxborn import TuxbornCommand
# Import our menu handlers
from .menus.main_menu import MainMenuHandler
from .menus.tuxborn_menu import TuxbornMenuHandler
from .menus.wabbajack_menu import WabbajackMenuHandler
from .menus.hoolamike_menu import HoolamikeMenuHandler
from .menus.additional_menu import AdditionalMenuHandler
# Import backend handlers for legacy compatibility
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.handlers.path_handler import PathHandler
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
from jackify.backend.handlers.menu_handler import MenuHandler
from jackify.backend.handlers.mo2_handler import MO2Handler
logger = logging.getLogger(__name__)
class JackifyCLI:
"""Main application class for Jackify CLI Frontend"""
def __init__(self, test_mode=False, dev_mode=False):
"""Initialize the JackifyCLI frontend.
Args:
test_mode (bool): If True, run in test mode with minimal side effects
dev_mode (bool): If True, enable development features
"""
# Initialize early (debug flag not yet available)
self._debug_mode = False
# Set test mode flag
self.test_mode = test_mode
self.dev_mode = dev_mode
self.verbose = False
# Configure logging to be quiet by default - will be adjusted after arg parsing
self._configure_logging_early()
# Determine system info
self.system_info = SystemInfo(is_steamdeck=self._is_steamdeck())
# Apply resource limits for optimal operation
self._apply_resource_limits()
# Initialize backend services
self.backend_services = self._initialize_backend_services()
# Initialize command handlers
self.commands = self._initialize_command_handlers()
# Initialize menu handlers with dev_mode
self.menus = self._initialize_menu_handlers()
# Initialize legacy compatibility attributes for menu bridge
self._initialize_legacy_compatibility()
# Initialize state variables
self.parser = None
self.subparsers = None
self.args = None
self.selected_modlist = None
self.setup_complete = False
def _debug_print(self, message):
"""Print debug message only if debug mode is enabled"""
if hasattr(self, '_debug_mode') and self._debug_mode:
logger.debug(message)
def _configure_logging_early(self):
"""Configure logging to be quiet during initialization, will be adjusted after arg parsing"""
# Set root logger to WARNING level initially to suppress INFO messages during init
logging.getLogger().setLevel(logging.WARNING)
# Configure basic logging format
if not logging.getLogger().handlers:
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logging.getLogger().addHandler(handler)
def _configure_logging_final(self):
"""Configure final logging level based on parsed arguments"""
# Use the existing LoggingHandler for proper log rotation
from jackify.backend.handlers.logging_handler import LoggingHandler
# Set up CLI-specific logging with rotation
logging_handler = LoggingHandler()
logging_handler.rotate_log_for_logger('jackify-cli', 'Modlist_Install_workflow_cli.log')
cli_logger = logging_handler.setup_logger('jackify-cli', 'Modlist_Install_workflow_cli.log')
# Configure logging level
if self.args.debug:
cli_logger.setLevel(logging.DEBUG)
print("Debug logging enabled for console and file")
elif self.args.verbose:
cli_logger.setLevel(logging.INFO)
print("Verbose logging enabled for console and file")
else:
# Keep it at WARNING level for clean startup
cli_logger.setLevel(logging.WARNING)
def _is_steamdeck(self):
"""Check if running on Steam Deck"""
try:
if os.path.exists("/etc/os-release"):
with open("/etc/os-release", "r") as f:
content = f.read()
if "steamdeck" in content:
logger.info("Running on Steam Deck")
return True
logger.info("Not running on Steam Deck")
return False
except Exception as e:
logger.error(f"Error detecting Steam Deck: {e}")
return False
def _apply_resource_limits(self):
"""Apply recommended resource limits for optimal Jackify operation"""
try:
from jackify.backend.services.resource_manager import ResourceManager
resource_manager = ResourceManager()
success = resource_manager.apply_recommended_limits()
if success:
status = resource_manager.get_limit_status()
if status['target_achieved']:
logger.info(f"Resource limits optimized: file descriptors set to {status['current_soft']}")
else:
logger.info(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})")
else:
# Log the issue but don't block startup
status = resource_manager.get_limit_status()
logger.warning(f"Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}")
# If we can't increase automatically, provide manual instructions in debug mode
if hasattr(self, '_debug_mode') and self._debug_mode:
instructions = resource_manager.get_manual_increase_instructions()
logger.debug(f"Manual increase instructions available for {instructions['distribution']}")
except Exception as e:
# Don't block startup on resource management errors
logger.warning(f"Error applying resource limits: {e}")
def _initialize_backend_services(self):
"""Initialize backend services.
Returns:
Dictionary of backend service instances
"""
# For now, create a basic modlist service
# TODO: Add other services as needed
services = {
'modlist_service': ModlistService(self.system_info)
}
return services
def _initialize_command_handlers(self):
"""Initialize command handler instances.
Returns:
Dictionary of command handler instances
"""
commands = {
'configure_modlist': ConfigureModlistCommand(self.backend_services),
'install_modlist': InstallModlistCommand(self.backend_services, self.system_info),
'tuxborn': TuxbornCommand(self.backend_services, self.system_info)
}
return commands
def _initialize_menu_handlers(self):
"""Initialize menu handler instances.
Returns:
Dictionary of menu handler instances
"""
menus = {
'main': MainMenuHandler(dev_mode=getattr(self, 'dev_mode', False)),
'tuxborn': TuxbornMenuHandler(),
'wabbajack': WabbajackMenuHandler(),
'hoolamike': HoolamikeMenuHandler(),
'additional': AdditionalMenuHandler()
}
# Set up logging for menu handlers
for menu in menus.values():
menu.logger = logger
return menus
def _initialize_legacy_compatibility(self):
"""
Initialize legacy compatibility attributes for menu bridge.
This provides the legacy attributes that menu handlers expect from cli_instance
until the backend migration is complete.
"""
# LEGACY BRIDGE: Add legacy imports to access original handlers
# Backend handlers are now imported directly from backend package
try:
# Initialize legacy handlers for compatibility
self.config_handler = ConfigHandler()
self.filesystem_handler = FileSystemHandler()
self.path_handler = PathHandler()
self.shortcut_handler = ShortcutHandler(self.config_handler.settings)
self.menu = MenuHandler() # Original menu handler for fallback
self.menu_handler = self.menu # Alias for backend compatibility
# Add MO2 handler to the menu handler for additional tasks menu
self.menu.mo2_handler = MO2Handler(self.menu)
# Set steamdeck attribute that menus expect
self.steamdeck = self.system_info.is_steamdeck
# Initialize settings that legacy code expects
if not hasattr(self.config_handler, 'settings'):
self.config_handler.settings = {}
self.config_handler.settings['steamdeck'] = self.steamdeck
logger.info("Legacy compatibility layer initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize legacy compatibility layer: {e}")
# Continue anyway - some functionality might still work
self.config_handler = None
self.filesystem_handler = None
self.path_handler = None
self.shortcut_handler = None
self.menu = None
self.steamdeck = self.system_info.is_steamdeck
def run(self):
self.parser, self.subparsers, self.args = self._parse_args()
self._debug_mode = self.args.debug
self.verbose = self.args.verbose or self.args.debug
self.dev_mode = getattr(self.args, 'dev', False)
# Re-initialize menus with dev_mode after parsing args
self.menus = self._initialize_menu_handlers()
# Now that we have args, configure logging properly
self._configure_logging_final()
self._debug_print('Initializing Jackify CLI Frontend')
self._debug_print('JackifyCLI.run() called')
self._debug_print(f'Parsed args: {self.args}')
# Handle legacy restart-steam functionality (temporary)
if getattr(self.args, 'restart_steam', False):
self._debug_print('Entering restart_steam workflow')
return self._handle_restart_steam()
# Handle Tuxborn auto mode
if getattr(self.args, 'tuxborn_auto', False):
self._debug_print('Entering Tuxborn workflow')
return self.commands['tuxborn'].execute(self.args)
# Handle install-modlist top-level functionality
if getattr(self.args, 'install_modlist', False):
self._debug_print('Entering install_modlist workflow')
return self.commands['install_modlist'].execute_top_level(self.args)
# Handle subcommands
if getattr(self.args, 'command', None):
return self._run_command(self.args.command, self.args)
# Run interactive mode (legacy for now)
self._run_interactive()
def _parse_args(self):
"""Parse command-line arguments using command handlers"""
parser = argparse.ArgumentParser(description="Jackify: Wabbajack Modlist Manager for Linux/Steam Deck")
parser.add_argument("-V", "--version", action="store_true", help="Show Jackify version and exit")
parser.add_argument("-d", "--debug", action="store_true", help="Enable debug logging (implies verbose)")
parser.add_argument("-v", "--verbose", action="store_true", help="Enable informational console output")
parser.add_argument("--cli", action="store_true", help="Run in CLI mode (default if no GUI available)")
parser.add_argument("--resolution", type=str, help="Resolution to set (optional)")
parser.add_argument('--restart-steam', action='store_true', help='Restart Steam (native, for GUI integration)')
parser.add_argument('--dev', action='store_true', help='Enable development features (show hidden menu items)')
# Add command-specific arguments
self.commands['tuxborn'].add_args(parser)
self.commands['install_modlist'].add_top_level_args(parser)
# Add subcommands
subparsers = parser.add_subparsers(dest="command", help="Command to run")
self.commands['configure_modlist'].add_parser(subparsers)
self.commands['install_modlist'].add_parser(subparsers)
args = parser.parse_args()
if args.version:
print(f"Jackify version {jackify_version}")
sys.exit(0)
return parser, subparsers, args
def _run_command(self, command, args):
"""Run a specific command using command handlers"""
if command == "install-modlist":
return self.commands['install_modlist'].execute_subcommand(args)
elif command == "configure-modlist":
return self.commands['configure_modlist'].execute(args)
elif command == "install-wabbajack":
# Legacy functionality - TODO: extract to command handler
return self._handle_legacy_install_wabbajack()
elif command == "hoolamike":
# Legacy functionality - TODO: extract to command handler
return self._handle_legacy_hoolamike()
elif command == "install-mo2":
print("MO2 installation not yet implemented")
print("This functionality is coming soon!")
return 1
elif command == "configure-nxm":
print("NXM configuration not yet implemented")
print("This functionality is coming soon!")
return 1
elif command == "recovery":
return self._handle_legacy_recovery(args)
elif command == "test-protontricks":
return self._handle_legacy_protontricks_test()
else:
print(f"Unknown command: {command}")
return 1
def _run_interactive(self):
"""Run the CLI interface interactively using the new menu system"""
try:
while True:
# Show main menu and get user's choice
choice = self.menus['main'].show_main_menu(self)
if choice == "exit":
print(f"{COLOR_INFO}Thank you for using Jackify!{COLOR_RESET}")
return 0
elif choice == "wabbajack":
self.menus['wabbajack'].show_wabbajack_tasks_menu(self)
elif choice == "tuxborn":
self.menus['tuxborn'].show_tuxborn_installer_menu(self)
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
# elif choice == "hoolamike":
# self.menus['hoolamike'].show_hoolamike_menu(self)
# elif choice == "additional":
# self.menus['additional'].show_additional_tasks_menu(self)
else:
logger.warning(f"Invalid choice '{choice}' received from show_main_menu.")
except KeyboardInterrupt:
print(f"\n{COLOR_INFO}Exiting Jackify...{COLOR_RESET}")
return 0
except Exception as e:
logger.error(f"Error in interactive mode: {e}")
print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}")
return 1
def _handle_restart_steam(self):
"""Handle restart-steam command - now properly implemented"""
print("[Jackify] Attempting to restart Steam...")
logger.debug("About to call secure_steam_restart()")
try:
# Use the already initialized shortcut_handler
if self.shortcut_handler:
success = self.shortcut_handler.secure_steam_restart()
logger.debug(f"secure_steam_restart() returned: {success}")
if success:
print("[Jackify] Steam restart completed successfully.")
return 0
else:
print("[Jackify] Failed to restart Steam.")
return 1
else:
print("[Jackify] ERROR: ShortcutHandler not initialized")
return 1
except Exception as e:
print(f"[Jackify] ERROR: Exception during Steam restart: {e}")
logger.error(f"Steam restart failed with exception: {e}")
return 1
def _handle_legacy_install_wabbajack(self):
"""Handle install-wabbajack command (legacy functionality)"""
print("Install Wabbajack functionality not yet migrated to new structure")
return 1
def _handle_legacy_hoolamike(self):
"""Handle hoolamike command (legacy functionality)"""
print("Hoolamike functionality not yet migrated to new structure")
return 1
def _handle_legacy_recovery(self, args):
"""Handle recovery command (legacy functionality)"""
print("Recovery functionality not yet migrated to new structure")
return 1
def _handle_legacy_protontricks_test(self):
"""Handle test-protontricks command (legacy functionality)"""
print("Protontricks test functionality not yet migrated to new structure")
return 1
# LEGACY BRIDGE: Methods that menu handlers expect to find on cli_instance
def _cmd_install_wabbajack(self, args):
"""LEGACY BRIDGE: Install Wabbajack application"""
return self._handle_legacy_install_wabbajack()
def main():
"""Legacy main function (not used in new structure)"""
pass
if __name__ == "__main__":
# This should not be called directly - use __main__.py instead
print("Please use: python -m jackify.frontends.cli")
sys.exit(1)

View File

@@ -0,0 +1,20 @@
"""
CLI Menu Components for Jackify Frontend
Extracted from the legacy monolithic CLI system
"""
from .main_menu import MainMenuHandler
from .tuxborn_menu import TuxbornMenuHandler
from .wabbajack_menu import WabbajackMenuHandler
from .hoolamike_menu import HoolamikeMenuHandler
from .additional_menu import AdditionalMenuHandler
from .recovery_menu import RecoveryMenuHandler
__all__ = [
'MainMenuHandler',
'TuxbornMenuHandler',
'WabbajackMenuHandler',
'HoolamikeMenuHandler',
'AdditionalMenuHandler',
'RecoveryMenuHandler'
]

View File

@@ -0,0 +1,73 @@
"""
Additional Tasks Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler.show_additional_tasks_menu()
"""
import time
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO, COLOR_DISABLED
)
from jackify.shared.ui_utils import print_jackify_banner, print_section_header
class AdditionalMenuHandler:
"""
Handles the Additional Tasks menu (MO2, NXM Handling & Recovery)
Extracted from legacy MenuHandler class
"""
def __init__(self):
self.logger = None # Will be set by CLI when needed
def _clear_screen(self):
"""Clear the terminal screen"""
import os
os.system('cls' if os.name == 'nt' else 'clear')
def show_additional_tasks_menu(self, cli_instance):
"""Show the MO2, NXM Handling & Recovery submenu"""
while True:
self._clear_screen()
print_jackify_banner()
print_section_header("Additional Utilities") # Broader title
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install Mod Organizer 2 (Base Setup)")
print(f" {COLOR_ACTION}→ Proton setup for a standalone MO2 instance{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure NXM Handling {COLOR_DISABLED}(Not Implemented){COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Jackify Recovery Tools")
print(f" {COLOR_ACTION}→ Restore files modified or backed up by Jackify{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu
continue
if selection == "1":
self._execute_legacy_install_mo2(cli_instance)
elif selection == "2":
print(f"{COLOR_INFO}Configure NXM Handling is not yet implemented.{COLOR_RESET}")
input("\nPress Enter to return to the Utilities menu...")
elif selection == "3":
self._execute_legacy_recovery_menu(cli_instance)
elif selection == "0":
break
else:
print("Invalid selection. Please try again.")
time.sleep(1)
def _execute_legacy_install_mo2(self, cli_instance):
"""LEGACY BRIDGE: Execute MO2 installation"""
# LEGACY BRIDGE: Use legacy imports until backend migration complete
if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'mo2_handler'):
cli_instance.menu.mo2_handler.install_mo2()
else:
print(f"{COLOR_INFO}MO2 handler not available - this will be implemented in Phase 2.3{COLOR_RESET}")
input("\nPress Enter to continue...")
def _execute_legacy_recovery_menu(self, cli_instance):
"""LEGACY BRIDGE: Execute recovery menu"""
# This will be handled by the RecoveryMenuHandler
from .recovery_menu import RecoveryMenuHandler
recovery_handler = RecoveryMenuHandler()
recovery_handler.logger = self.logger
recovery_handler.show_recovery_menu(cli_instance)

View File

@@ -0,0 +1,32 @@
"""
Hoolamike Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler.show_hoolamike_menu()
"""
from jackify.shared.colors import COLOR_INFO, COLOR_PROMPT, COLOR_RESET
class HoolamikeMenuHandler:
"""
Handles the Hoolamike Tasks menu
Extracted from legacy MenuHandler class
"""
def __init__(self):
self.logger = None # Will be set by CLI when needed
def show_hoolamike_menu(self, cli_instance):
"""
LEGACY BRIDGE: Delegate to legacy menu handler until full backend migration
Args:
cli_instance: Reference to main CLI instance for access to handlers
"""
print(f"{COLOR_INFO}Hoolamike menu functionality has been extracted but needs migration to backend services.{COLOR_RESET}")
print(f"{COLOR_INFO}This will be implemented in Phase 2.3 (Menu Backend Integration).{COLOR_RESET}")
# LEGACY BRIDGE: Use the original menu handler's method
if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'show_hoolamike_menu'):
cli_instance.menu.show_hoolamike_menu(cli_instance)
else:
print(f"{COLOR_INFO}Legacy menu handler not available - returning to main menu.{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")

View File

@@ -0,0 +1,74 @@
"""
Main Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler.show_main_menu()
"""
import time
from typing import Optional
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_ERROR
)
from jackify.shared.ui_utils import print_jackify_banner
class MainMenuHandler:
"""
Handles the main interactive menu display and user input routing
Extracted from legacy MenuHandler class
"""
def __init__(self, dev_mode=False):
self.logger = None # Will be set by CLI when needed
self.dev_mode = dev_mode
def _clear_screen(self):
"""Clear the terminal screen"""
import os
os.system('cls' if os.name == 'nt' else 'clear')
def show_main_menu(self, cli_instance) -> str:
"""
Show the main menu and return user selection
Args:
cli_instance: Reference to main CLI instance for access to handlers
Returns:
str: Menu choice ("wabbajack", "hoolamike", "additional", "exit", "tuxborn")
"""
while True:
self._clear_screen()
print_jackify_banner()
print(f"{COLOR_SELECTION}Main Menu{COLOR_RESET}")
print(f"{COLOR_SELECTION}{'-'*22}{COLOR_RESET}") # Standard separator
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Modlist Tasks")
print(f" {COLOR_ACTION}→ Install & Configure Modlists{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Tuxborn Automatic Installer")
print(f" {COLOR_ACTION}→ Simple, fully automated Tuxborn installation{COLOR_RESET}")
if self.dev_mode:
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Hoolamike Tasks")
print(f" {COLOR_ACTION}→ Wabbajack alternative: Install Modlists, TTW, etc{COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Additional Tasks")
print(f" {COLOR_ACTION}→ Install Wabbajack (via WINE), MO2, NXM Handling, Jackify Recovery{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Exit Jackify")
if self.dev_mode:
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip()
else:
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
if choice.lower() == 'q': # Allow 'q' to re-display menu
continue
if choice == "1":
return "wabbajack"
elif choice == "2":
return "tuxborn" # Will be handled by TuxbornMenuHandler
if self.dev_mode:
if choice == "3":
return "hoolamike"
elif choice == "4":
return "additional"
elif choice == "0":
return "exit"
else:
print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}")
time.sleep(1) # Brief pause for readability

View File

@@ -0,0 +1,174 @@
"""
Recovery Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler._show_recovery_menu()
"""
import logging
from pathlib import Path
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_PROMPT, COLOR_INFO, COLOR_ERROR
)
from jackify.shared.ui_utils import print_jackify_banner, print_section_header
class RecoveryMenuHandler:
"""
Handles the Recovery Tools menu
Extracted from legacy MenuHandler class
"""
def __init__(self):
self.logger = logging.getLogger(__name__)
def _clear_screen(self):
"""Clear the terminal screen"""
import os
os.system('cls' if os.name == 'nt' else 'clear')
def show_recovery_menu(self, cli_instance):
"""Show the recovery tools menu."""
while True:
self._clear_screen()
print_jackify_banner()
print_section_header('Recovery Tools')
print(f"{COLOR_INFO}This allows restoring original Steam configuration files from backups created by Jackify.{COLOR_RESET}")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Restore all backups")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Restore config.vdf only")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Restore libraryfolders.vdf only")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Restore shortcuts.vdf only")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip()
if choice == "1":
self._restore_all_backups(cli_instance)
elif choice == "2":
self._restore_config_vdf(cli_instance)
elif choice == "3":
self._restore_libraryfolders_vdf(cli_instance)
elif choice == "4":
self._restore_shortcuts_vdf(cli_instance)
elif choice == "0":
break
else:
print("Invalid selection. Please try again.")
input("\nPress Enter to continue...")
def _restore_all_backups(self, cli_instance):
"""Restore all supported Steam config files"""
self.logger.info("Recovery selected: Restore all Steam config files")
print("\nAttempting to restore all supported Steam config files...")
# LEGACY BRIDGE: Use legacy handlers until backend migration complete
paths_to_check = {
"libraryfolders": self._get_library_vdf_path(cli_instance),
"config": self._get_config_vdf_path(cli_instance),
"shortcuts": self._get_shortcuts_vdf_path(cli_instance)
}
restored_count = 0
for file_type, file_path in paths_to_check.items():
if file_path:
print(f"Restoring {file_type} ({file_path})...")
latest_backup = self._find_latest_backup(cli_instance, Path(file_path))
if latest_backup:
if self._restore_backup(cli_instance, latest_backup, Path(file_path)):
print(f"Successfully restored {file_type}.")
restored_count += 1
else:
print(f"{COLOR_ERROR}Failed to restore {file_type} from {latest_backup}.{COLOR_RESET}")
else:
print(f"No backup found for {file_type}.")
else:
print(f"Could not locate original file for {file_type} to restore.")
print(f"\nRestore process completed. {restored_count}/{len(paths_to_check)} files potentially restored.")
input("\nPress Enter to continue...")
def _restore_config_vdf(self, cli_instance):
"""Restore config.vdf only"""
self.logger.info("Recovery selected: Restore config.vdf only")
print("\nAttempting to restore config.vdf...")
file_path = self._get_config_vdf_path(cli_instance)
if file_path:
latest_backup = self._find_latest_backup(cli_instance, Path(file_path))
if latest_backup:
if self._restore_backup(cli_instance, latest_backup, Path(file_path)):
print(f"Successfully restored config.vdf from {latest_backup}.")
else:
print(f"{COLOR_ERROR}Failed to restore config.vdf from {latest_backup}.{COLOR_RESET}")
else:
print("No backup found for config.vdf.")
else:
print("Could not locate config.vdf.")
input("\nPress Enter to continue...")
def _restore_libraryfolders_vdf(self, cli_instance):
"""Restore libraryfolders.vdf only"""
self.logger.info("Recovery selected: Restore libraryfolders.vdf only")
print("\nAttempting to restore libraryfolders.vdf...")
file_path = self._get_library_vdf_path(cli_instance)
if file_path:
latest_backup = self._find_latest_backup(cli_instance, Path(file_path))
if latest_backup:
if self._restore_backup(cli_instance, latest_backup, Path(file_path)):
print(f"Successfully restored libraryfolders.vdf from {latest_backup}.")
else:
print(f"{COLOR_ERROR}Failed to restore libraryfolders.vdf from {latest_backup}.{COLOR_RESET}")
else:
print("No backup found for libraryfolders.vdf.")
else:
print("Could not locate libraryfolders.vdf.")
input("\nPress Enter to continue...")
def _restore_shortcuts_vdf(self, cli_instance):
"""Restore shortcuts.vdf only"""
self.logger.info("Recovery selected: Restore shortcuts.vdf only")
print("\nAttempting to restore shortcuts.vdf...")
file_path = self._get_shortcuts_vdf_path(cli_instance)
if file_path:
latest_backup = self._find_latest_backup(cli_instance, Path(file_path))
if latest_backup:
if self._restore_backup(cli_instance, latest_backup, Path(file_path)):
print(f"Successfully restored shortcuts.vdf from {latest_backup}.")
else:
print(f"{COLOR_ERROR}Failed to restore shortcuts.vdf from {latest_backup}.{COLOR_RESET}")
else:
print("No backup found for shortcuts.vdf.")
else:
print("Could not locate shortcuts.vdf.")
input("\nPress Enter to continue...")
# LEGACY BRIDGE methods - delegate to existing handlers
def _get_library_vdf_path(self, cli_instance):
"""LEGACY BRIDGE: Get libraryfolders.vdf path"""
if hasattr(cli_instance, 'path_handler'):
return cli_instance.path_handler.find_steam_library_vdf_path()
return None
def _get_config_vdf_path(self, cli_instance):
"""LEGACY BRIDGE: Get config.vdf path"""
if hasattr(cli_instance, 'path_handler'):
return cli_instance.path_handler.find_steam_config_vdf()
return None
def _get_shortcuts_vdf_path(self, cli_instance):
"""LEGACY BRIDGE: Get shortcuts.vdf path"""
if hasattr(cli_instance, 'shortcut_handler'):
return cli_instance.shortcut_handler._find_shortcuts_vdf()
return None
def _find_latest_backup(self, cli_instance, file_path: Path):
"""LEGACY BRIDGE: Find latest backup file"""
if hasattr(cli_instance, 'filesystem_handler'):
return cli_instance.filesystem_handler.find_latest_backup(file_path)
return None
def _restore_backup(self, cli_instance, backup_path, target_path: Path) -> bool:
"""LEGACY BRIDGE: Restore backup file"""
if hasattr(cli_instance, 'filesystem_handler'):
return cli_instance.filesystem_handler.restore_backup(backup_path, target_path)
return False

View File

@@ -0,0 +1,194 @@
"""
Tuxborn Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler.show_tuxborn_installer_menu()
"""
from pathlib import Path
from typing import Optional
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_PROMPT, COLOR_WARNING
)
from jackify.shared.ui_utils import print_jackify_banner
from jackify.backend.handlers.config_handler import ConfigHandler
class TuxbornMenuHandler:
"""
Handles the Tuxborn Automatic Installer workflow
Extracted from legacy MenuHandler class
"""
def __init__(self):
self.logger = None # Will be set by CLI when needed
def show_tuxborn_installer_menu(self, cli_instance):
"""
Implements the Tuxborn Automatic Installer workflow.
Prompts for install path, downloads path, and Nexus API key, then runs the one-shot install from start to finish
Args:
cli_instance: Reference to main CLI instance for access to handlers
"""
# Import backend service
from jackify.backend.core.modlist_operations import ModlistInstallCLI
print_jackify_banner()
print(f"{COLOR_SELECTION}Tuxborn Automatic Installer{COLOR_RESET}")
print(f"{COLOR_SELECTION}{'-'*32}{COLOR_RESET}")
print(f"{COLOR_INFO}This will install the Tuxborn modlist using the custom Jackify Install Engine in one automated flow.{COLOR_RESET}")
print(f"{COLOR_INFO}You will be prompted for the install location, downloads directory, and your Nexus API key.{COLOR_RESET}\n")
tuxborn_machineid = "Tuxborn/Tuxborn"
tuxborn_modlist_name = "Tuxborn"
# Prompt for install directory
print("----------------------------")
config_handler = ConfigHandler()
base_install_dir = Path(config_handler.get_modlist_install_base_dir())
default_install_dir = base_install_dir / "Skyrim" / "Tuxborn"
print(f"{COLOR_PROMPT}Please enter the directory you wish to use for Tuxborn installation.{COLOR_RESET}")
print(f"(Default: {default_install_dir})")
install_dir_result = self._get_directory_path_legacy(
cli_instance,
prompt_message=f"{COLOR_PROMPT}Install directory (Enter for default, 'q' to cancel): {COLOR_RESET}",
default_path=default_install_dir,
create_if_missing=True,
no_header=True
)
if not install_dir_result:
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
input("Press Enter to return to the main menu...")
return
if isinstance(install_dir_result, tuple):
install_dir, _ = install_dir_result # We'll use the path, creation handled by engine or later
else:
install_dir = install_dir_result
# Prompt for download directory
print("----------------------------")
base_download_dir = Path(config_handler.get_modlist_downloads_base_dir())
default_download_dir = base_download_dir / "Tuxborn"
print(f"{COLOR_PROMPT}Please enter the directory you wish to use for Tuxborn downloads.{COLOR_RESET}")
print(f"(Default: {default_download_dir})")
download_dir_result = self._get_directory_path_legacy(
cli_instance,
prompt_message=f"{COLOR_PROMPT}Download directory (Enter for default, 'q' to cancel): {COLOR_RESET}",
default_path=default_download_dir,
create_if_missing=True,
no_header=True
)
if not download_dir_result:
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
input("Press Enter to return to the main menu...")
return
if isinstance(download_dir_result, tuple):
download_dir, _ = download_dir_result # We'll use the path, creation handled by engine or later
else:
download_dir = download_dir_result
# Prompt for Nexus API key
print("----------------------------")
from jackify.backend.services.api_key_service import APIKeyService
api_key_service = APIKeyService()
saved_key = api_key_service.get_saved_api_key()
api_key = None
if saved_key:
print(f"{COLOR_INFO}A Nexus API Key is already saved.{COLOR_RESET}")
use_saved = input(f"{COLOR_PROMPT}Use the saved API key? [Y/n]: {COLOR_RESET}").strip().lower()
if use_saved in ('', 'y', 'yes'):
api_key = saved_key
else:
new_key = input(f"{COLOR_PROMPT}Enter a new Nexus API Key (or press Enter to keep the saved one): {COLOR_RESET}").strip()
if new_key:
api_key = new_key
replace = input(f"{COLOR_PROMPT}Replace the saved key with this one? [y/N]: {COLOR_RESET}").strip().lower()
if replace == 'y':
if api_key_service.save_api_key(api_key):
print(f"{COLOR_INFO}API key saved successfully.{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}")
else:
print(f"{COLOR_INFO}Using new key for this session only. Saved key unchanged.{COLOR_RESET}")
else:
api_key = saved_key
else:
print(f"{COLOR_PROMPT}A Nexus Mods API key is required for downloading mods.{COLOR_RESET}")
print(f"{COLOR_INFO}You can get your personal key at: {COLOR_SELECTION}https://www.nexusmods.com/users/myaccount?tab=api{COLOR_RESET}")
print(f"{COLOR_WARNING}Your API Key is NOT saved locally. It is used only for this session unless you choose to save it.{COLOR_RESET}")
api_key = input(f"{COLOR_PROMPT}Enter Nexus API Key (or 'q' to cancel): {COLOR_RESET}").strip()
if not api_key or api_key.lower() == 'q':
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
input("Press Enter to return to the main menu...")
return
save = input(f"{COLOR_PROMPT}Would you like to save this API key for future use? [y/N]: {COLOR_RESET}").strip().lower()
if save == 'y':
if api_key_service.save_api_key(api_key):
print(f"{COLOR_INFO}API key saved successfully.{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}")
else:
print(f"{COLOR_INFO}Using API key for this session only. It will not be saved.{COLOR_RESET}")
# Context for ModlistInstallCLI
context = {
'machineid': tuxborn_machineid,
'modlist_name': tuxborn_modlist_name, # Will be used for shortcut name
'install_dir': install_dir_result, # Pass tuple (path, create_flag) or path
'download_dir': download_dir_result, # Pass tuple (path, create_flag) or path
'nexus_api_key': api_key,
'resolution': None
}
modlist_cli = ModlistInstallCLI(self, getattr(cli_instance, 'steamdeck', False))
# run_discovery_phase will use context_override, display summary, and ask for confirmation.
# If user confirms, it returns the context, otherwise None.
confirmed_context = modlist_cli.run_discovery_phase(context_override=context)
if confirmed_context:
if self.logger:
self.logger.info("Tuxborn discovery confirmed by user. Proceeding to configuration/installation.")
# The modlist_cli instance now holds the confirmed context.
# configuration_phase will use modlist_cli.context
modlist_cli.configuration_phase()
# After configuration_phase, messages about success or next steps are handled within it or by _configure_new_modlist
else:
if self.logger:
self.logger.info("Tuxborn discovery/confirmation cancelled or failed.")
print(f"{COLOR_INFO}Tuxborn installation cancelled or not confirmed.{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}")
return
def _get_directory_path_legacy(self, cli_instance, prompt_message: str, default_path: Optional[Path],
create_if_missing: bool = True, no_header: bool = False) -> Optional[Path]:
"""
LEGACY BRIDGE: Delegate to legacy menu handler until full backend migration
Args:
cli_instance: Reference to main CLI instance
prompt_message: The prompt to show user
default_path: Default path if user presses Enter
create_if_missing: Whether to create directory if it doesn't exist
no_header: Whether to skip header display
Returns:
Path object or None if cancelled
"""
# LEGACY BRIDGE: Use the original menu handler's method
if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'get_directory_path'):
return cli_instance.menu.get_directory_path(
prompt_message=prompt_message,
default_path=default_path,
create_if_missing=create_if_missing,
no_header=no_header
)
else:
# Fallback: simple input for now (will be replaced in future phases)
response = input(prompt_message).strip()
if response.lower() == 'q':
return None
elif response == '':
return default_path
else:
return Path(response)

View File

@@ -0,0 +1,115 @@
"""
Wabbajack Tasks Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler.show_wabbajack_tasks_menu()
"""
import time
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO
)
from jackify.shared.ui_utils import print_jackify_banner, print_section_header
class WabbajackMenuHandler:
"""
Handles the Modlist and Wabbajack Tasks menu
Extracted from legacy MenuHandler class
"""
def __init__(self):
self.logger = None # Will be set by CLI when needed
def _clear_screen(self):
"""Clear the terminal screen"""
import os
os.system('cls' if os.name == 'nt' else 'clear')
def show_wabbajack_tasks_menu(self, cli_instance):
"""Show the Modlist and Wabbajack Tasks menu"""
while True:
self._clear_screen()
print_jackify_banner()
# Use print_section_header for consistency
print_section_header("Modlist and Wabbajack Tasks")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install a Modlist (Automated)")
print(f" {COLOR_ACTION}→ Uses jackify-engine for a full install flow{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure New Modlist (Post-Download)")
print(f" {COLOR_ACTION}→ Modlist .wabbajack file downloaded? Configure it for Steam{COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Configure Existing Modlist (In Steam)")
print(f" {COLOR_ACTION}→ Modlist already in Steam? Re-configure it here{COLOR_RESET}")
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
# print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Wabbajack Application")
# print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via WINE){COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu
continue
if selection == "1":
self._execute_legacy_install_modlist(cli_instance)
elif selection == "2":
self._execute_legacy_configure_new_modlist(cli_instance)
elif selection == "3":
self._execute_legacy_configure_existing_modlist(cli_instance)
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
# elif selection == "4":
# self._execute_legacy_install_wabbajack(cli_instance)
elif selection == "0":
break
else:
print("Invalid selection. Please try again.")
time.sleep(1)
def _execute_legacy_install_modlist(self, cli_instance):
"""LEGACY BRIDGE: Execute modlist installation workflow"""
# Import backend services
from jackify.backend.core.modlist_operations import ModlistInstallCLI
from jackify.backend.handlers.menu_handler import MenuHandler
# Create a proper MenuHandler instance with the required methods
menu_handler = MenuHandler()
# Pass the MenuHandler instance and steamdeck status
steamdeck_status = getattr(cli_instance, 'steamdeck', False)
installer = ModlistInstallCLI(menu_handler, steamdeck_status)
if self.logger:
self.logger.debug("MenuHandler: ModlistInstallCLI instance created for Install a Modlist.")
context = installer.run_discovery_phase()
if context:
if self.logger:
self.logger.info("MenuHandler: Discovery phase complete, proceeding to configuration phase.")
installer.configuration_phase()
else:
if self.logger:
self.logger.info("MenuHandler: Discovery phase did not return context. Skipping configuration.")
input("\nPress Enter to return to the Modlist Tasks menu...") # Standard return prompt
def _execute_legacy_install_wabbajack(self, cli_instance):
"""LEGACY BRIDGE: Execute Wabbajack application installation"""
if self.logger:
self.logger.info("User selected 'Install Wabbajack' from Modlist Tasks menu.")
# Add introductory text before calling the Wabbajack installation workflow
self._clear_screen()
print_jackify_banner()
print_section_header("Install Wabbajack Application")
print(f"{COLOR_INFO}This process will guide you through downloading and setting up\nthe Wabbajack application itself.{COLOR_RESET}")
print("\n") # Spacer
cli_instance._cmd_install_wabbajack(None) # Pass the cli_instance itself
def _execute_legacy_configure_new_modlist(self, cli_instance):
"""LEGACY BRIDGE: Execute new modlist configuration"""
# Import backend service
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
modlist_menu = ModlistMenuHandler(cli_instance.config_handler)
modlist_menu._configure_new_modlist()
def _execute_legacy_configure_existing_modlist(self, cli_instance):
"""LEGACY BRIDGE: Execute existing modlist configuration"""
# Import backend service
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
modlist_menu = ModlistMenuHandler(cli_instance.config_handler)
modlist_menu._configure_existing_modlist()

View File

@@ -0,0 +1,9 @@
"""
CLI UI Components for Jackify Frontend
Shared UI utilities and components for command-line interface
"""
# Currently empty - will be populated with UI helpers as needed
# Examples: input validators, progress indicators, etc.
__all__ = []