diff --git a/falyx/__main__.py b/falyx/__main__.py index b106d5f..2f3e2e6 100644 --- a/falyx/__main__.py +++ b/falyx/__main__.py @@ -14,6 +14,7 @@ from typing import Any from falyx.config import loader from falyx.falyx import Falyx from falyx.parsers import FalyxParsers, get_arg_parsers +from falyx.themes.colors import OneColors def find_falyx_config() -> Path | None: @@ -71,6 +72,7 @@ def run(args: Namespace) -> Any: title="🛠️ Config-Driven CLI", cli_args=args, columns=4, + prompt=[(OneColors.BLUE_b, "FALYX > ")], ) flx.add_commands(loader(bootstrap_path)) return asyncio.run(flx.run()) diff --git a/falyx/command.py b/falyx/command.py index 8ba2826..dccf087 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -272,15 +272,21 @@ class Command(BaseModel): if hasattr(self.action, "preview") and callable(self.action.preview): tree = Tree(label) await self.action.preview(parent=tree) + if self.help_text: + tree.add(f"[dim]💡 {self.help_text}[/dim]") console.print(tree) elif callable(self.action) and not isinstance(self.action, BaseAction): console.print(f"{label}") + if self.help_text: + console.print(f"[dim]💡 {self.help_text}[/dim]") console.print( f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__}" f"[dim](args={self.args}, kwargs={self.kwargs})[/dim]" ) else: console.print(f"{label}") + if self.help_text: + console.print(f"[dim]💡 {self.help_text}[/dim]") console.print( f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]" ) diff --git a/falyx/config.py b/falyx/config.py index 2b028a5..f0b1a21 100644 --- a/falyx/config.py +++ b/falyx/config.py @@ -3,15 +3,21 @@ Configuration loader for Falyx CLI commands.""" import importlib +import sys from pathlib import Path from typing import Any import toml import yaml +from rich.console import Console from falyx.action import Action, BaseAction from falyx.command import Command from falyx.retry import RetryPolicy +from falyx.themes.colors import OneColors +from falyx.utils import logger + +console = Console(color_system="auto") def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: @@ -30,9 +36,28 @@ def import_action(dotted_path: str) -> Any: """Dynamically imports a callable from a dotted path like 'my.module.func'.""" module_path, _, attr = dotted_path.rpartition(".") if not module_path: - raise ValueError(f"Invalid action path: {dotted_path}") - module = importlib.import_module(module_path) - return getattr(module, attr) + console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}") + sys.exit(1) + try: + module = importlib.import_module(module_path) + except ModuleNotFoundError as error: + logger.error("Failed to import module '%s': %s", module_path, error) + console.print( + f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n" + f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable via PYTHONPATH." + ) + sys.exit(1) + try: + action = getattr(module, attr) + except AttributeError as error: + logger.error( + "Module '%s' does not have attribute '%s': %s", module_path, attr, error + ) + console.print( + f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute '{attr}': {error}[/]" + ) + sys.exit(1) + return action def loader(file_path: Path | str) -> list[dict[str, Any]]: diff --git a/falyx/falyx.py b/falyx/falyx.py index 5a0a3bf..9a1489e 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -283,7 +283,7 @@ class Falyx: self.console.print(table, justify="center") if self.mode == FalyxMode.MENU: self.console.print( - f"📦 Tip: Type '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command before running it.\n", + f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command before running it.\n", justify="center", ) @@ -343,7 +343,9 @@ class Falyx: error_message = " ".join(message_lines) def validator(text): - _, choice = self.get_command(text, from_validate=True) + is_preview, choice = self.get_command(text, from_validate=True) + if is_preview and choice is None: + return True return True if choice else False return Validator.from_callable( @@ -694,6 +696,13 @@ class Falyx: ) -> tuple[bool, Command | None]: """Returns the selected command based on user input. Supports keys, aliases, and abbreviations.""" is_preview, choice = self.parse_preview_command(choice) + if is_preview and not choice: + if not from_validate: + self.console.print( + f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode.[/]" + ) + return is_preview, None + choice = choice.upper() name_map = self._name_map @@ -788,12 +797,17 @@ class Falyx: async def run_key(self, command_key: str, return_context: bool = False) -> Any: """Run a command by key without displaying the menu (non-interactive mode).""" self.debug_hooks() - _, selected_command = self.get_command(command_key) + is_preview, selected_command = self.get_command(command_key) self.last_run_command = selected_command if not selected_command: return None + if is_preview: + logger.info(f"Preview command '{selected_command.key}' selected.") + await selected_command.preview() + return None + logger.info( "[run_key] 🚀 Executing: %s — %s", selected_command.key, @@ -943,11 +957,14 @@ class Falyx: if self.cli_args.command == "run": self.mode = FalyxMode.RUN - _, command = self.get_command(self.cli_args.name) + is_preview, command = self.get_command(self.cli_args.name) + if is_preview: + if command is None: + sys.exit(1) + logger.info(f"Preview command '{command.key}' selected.") + await command.preview() + sys.exit(0) if not command: - self.console.print( - f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]" - ) sys.exit(1) self._set_retry_policy(command) try: @@ -955,6 +972,9 @@ class Falyx: except FalyxError as error: self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") sys.exit(1) + + if self.cli_args.summary: + er.summary() sys.exit(0) if self.cli_args.command == "run-all": @@ -976,6 +996,10 @@ class Falyx: for cmd in matching: self._set_retry_policy(cmd) await self.run_key(cmd.key) + + if self.cli_args.summary: + er.summary() + sys.exit(0) await self.menu() diff --git a/falyx/io_action.py b/falyx/io_action.py index ee8dcae..7ec7350 100644 --- a/falyx/io_action.py +++ b/falyx/io_action.py @@ -59,6 +59,7 @@ class BaseIOAction(BaseAction): def __init__( self, name: str, + *, hooks: HookManager | None = None, mode: str = "buffered", logging_hooks: bool = True, diff --git a/falyx/parsers.py b/falyx/parsers.py index 88979f0..4a7a0b5 100644 --- a/falyx/parsers.py +++ b/falyx/parsers.py @@ -36,7 +36,9 @@ def get_arg_parsers( prog: str | None = "falyx", usage: str | None = None, description: str | None = "Falyx CLI - Run structured async command workflows.", - epilog: str | None = None, + epilog: ( + str | None + ) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.", parents: Sequence[ArgumentParser] = [], prefix_chars: str = "-", fromfile_prefix_chars: str | None = None, @@ -79,6 +81,11 @@ def get_arg_parsers( run_parser = subparsers.add_parser("run", help="Run a specific command") run_parser.add_argument("name", help="Key, alias, or description of the command") + run_parser.add_argument( + "--summary", + action="store_true", + help="Print an execution summary after command completes", + ) run_parser.add_argument( "--retries", type=int, help="Number of retries on failure", default=0 ) @@ -111,6 +118,11 @@ def get_arg_parsers( "run-all", help="Run all commands with a given tag" ) run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match") + run_all_parser.add_argument( + "--summary", + action="store_true", + help="Print a summary after all tagged commands run", + ) run_all_parser.add_argument( "--retries", type=int, help="Number of retries on failure", default=0 ) diff --git a/falyx/select_file_action.py b/falyx/select_file_action.py index e531d84..e1ddf8b 100644 --- a/falyx/select_file_action.py +++ b/falyx/select_file_action.py @@ -137,7 +137,9 @@ class SelectFileAction(BaseAction): options = self.get_options(files) - table = render_selection_dict_table(self.title, options, self.columns) + table = render_selection_dict_table( + title=self.title, selections=options, columns=self.columns + ) key = await prompt_for_selection( options.keys(), diff --git a/falyx/selection.py b/falyx/selection.py index db32d9a..071e06a 100644 --- a/falyx/selection.py +++ b/falyx/selection.py @@ -31,6 +31,7 @@ class SelectionOption: def render_table_base( title: str, + *, caption: str = "", columns: int = 4, box_style: box.Box = box.SIMPLE, @@ -71,6 +72,7 @@ def render_table_base( def render_selection_grid( title: str, selections: Sequence[str], + *, columns: int = 4, caption: str = "", box_style: box.Box = box.SIMPLE, @@ -86,19 +88,19 @@ def render_selection_grid( ) -> Table: """Create a selection table with the given parameters.""" table = render_table_base( - title, - caption, - columns, - box_style, - show_lines, - show_header, - show_footer, - style, - header_style, - footer_style, - title_style, - caption_style, - highlight, + title=title, + caption=caption, + columns=columns, + box_style=box_style, + show_lines=show_lines, + show_header=show_header, + show_footer=show_footer, + style=style, + header_style=header_style, + footer_style=footer_style, + title_style=title_style, + caption_style=caption_style, + highlight=highlight, ) for chunk in chunks(selections, columns): @@ -110,6 +112,7 @@ def render_selection_grid( def render_selection_indexed_table( title: str, selections: Sequence[str], + *, columns: int = 4, caption: str = "", box_style: box.Box = box.SIMPLE, @@ -126,19 +129,19 @@ def render_selection_indexed_table( ) -> Table: """Create a selection table with the given parameters.""" table = render_table_base( - title, - caption, - columns, - box_style, - show_lines, - show_header, - show_footer, - style, - header_style, - footer_style, - title_style, - caption_style, - highlight, + title=title, + caption=caption, + columns=columns, + box_style=box_style, + show_lines=show_lines, + show_header=show_header, + show_footer=show_footer, + style=style, + header_style=header_style, + footer_style=footer_style, + title_style=title_style, + caption_style=caption_style, + highlight=highlight, ) for indexes, chunk in zip( @@ -156,6 +159,7 @@ def render_selection_indexed_table( def render_selection_dict_table( title: str, selections: dict[str, SelectionOption], + *, columns: int = 2, caption: str = "", box_style: box.Box = box.SIMPLE, @@ -171,19 +175,19 @@ def render_selection_dict_table( ) -> Table: """Create a selection table with the given parameters.""" table = render_table_base( - title, - caption, - columns, - box_style, - show_lines, - show_header, - show_footer, - style, - header_style, - footer_style, - title_style, - caption_style, - highlight, + title=title, + caption=caption, + columns=columns, + box_style=box_style, + show_lines=show_lines, + show_header=show_header, + show_footer=show_footer, + style=style, + header_style=header_style, + footer_style=footer_style, + title_style=title_style, + caption_style=caption_style, + highlight=highlight, ) for chunk in chunks(selections.items(), columns): @@ -200,6 +204,7 @@ def render_selection_dict_table( async def prompt_for_index( max_index: int, table: Table, + *, min_index: int = 0, default_selection: str = "", console: Console | None = None, @@ -224,6 +229,7 @@ async def prompt_for_index( async def prompt_for_selection( keys: Sequence[str] | KeysView[str], table: Table, + *, default_selection: str = "", console: Console | None = None, prompt_session: PromptSession | None = None, @@ -249,6 +255,7 @@ async def prompt_for_selection( async def select_value_from_list( title: str, selections: Sequence[str], + *, console: Console | None = None, prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", @@ -268,20 +275,20 @@ async def select_value_from_list( ): """Prompt for a selection. Return the selected item.""" table = render_selection_indexed_table( - title, - selections, - columns, - caption, - box_style, - show_lines, - show_header, - show_footer, - style, - header_style, - footer_style, - title_style, - caption_style, - highlight, + title=title, + selections=selections, + columns=columns, + caption=caption, + box_style=box_style, + show_lines=show_lines, + show_header=show_header, + show_footer=show_footer, + style=style, + header_style=header_style, + footer_style=footer_style, + title_style=title_style, + caption_style=caption_style, + highlight=highlight, ) prompt_session = prompt_session or PromptSession() console = console or Console(color_system="auto") @@ -301,6 +308,7 @@ async def select_value_from_list( async def select_key_from_dict( selections: dict[str, SelectionOption], table: Table, + *, console: Console | None = None, prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", @@ -325,6 +333,7 @@ async def select_key_from_dict( async def select_value_from_dict( selections: dict[str, SelectionOption], table: Table, + *, console: Console | None = None, prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", @@ -351,6 +360,7 @@ async def select_value_from_dict( async def get_selection_from_dict_menu( title: str, selections: dict[str, SelectionOption], + *, console: Console | None = None, prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", @@ -363,10 +373,10 @@ async def get_selection_from_dict_menu( ) return await select_value_from_dict( - selections, - table, - console, - prompt_session, - prompt_message, - default_selection, + selections=selections, + table=table, + console=console, + prompt_session=prompt_session, + prompt_message=prompt_message, + default_selection=default_selection, ) diff --git a/falyx/selection_action.py b/falyx/selection_action.py index 70018d8..bc7f30a 100644 --- a/falyx/selection_action.py +++ b/falyx/selection_action.py @@ -1,6 +1,5 @@ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed """selection_action.py""" -from pathlib import Path from typing import Any from prompt_toolkit import PromptSession @@ -117,7 +116,9 @@ class SelectionAction(BaseAction): await self.hooks.trigger(HookType.BEFORE, context) if isinstance(self.selections, list): table = render_selection_indexed_table( - self.title, self.selections, self.columns + title=self.title, + selections=self.selections, + columns=self.columns, ) if not self.never_prompt: index = await prompt_for_index( @@ -134,7 +135,7 @@ class SelectionAction(BaseAction): result = self.selections[int(index)] elif isinstance(self.selections, dict): table = render_selection_dict_table( - self.title, self.selections, self.columns + title=self.title, selections=self.selections, columns=self.columns ) if not self.never_prompt: key = await prompt_for_selection( diff --git a/falyx/version.py b/falyx/version.py index f4bd716..6852ddf 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.21" +__version__ = "0.1.22" diff --git a/pyproject.toml b/pyproject.toml index 8c95b20..62e5543 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.21" +version = "0.1.22" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT"