diff --git a/falyx/command.py b/falyx/command.py index a7b1cf1..955272e 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -346,6 +346,7 @@ class Command(BaseModel): FalyxMode.RUN, FalyxMode.PREVIEW, FalyxMode.RUN_ALL, + FalyxMode.HELP, } program = f"{self.program} run " if is_cli_mode else "" diff --git a/falyx/completer.py b/falyx/completer.py index 002b4b7..17c4b7f 100644 --- a/falyx/completer.py +++ b/falyx/completer.py @@ -8,9 +8,13 @@ This completer supports: - Argument flag completion for registered commands (e.g. `--tag`, `--name`) - Context-aware suggestions based on cursor position and argument structure - Interactive value completions (e.g. choices and suggestions defined per argument) +- File/path-friendly behavior, quoting completions with spaces automatically + + +Completions are generated from: +- Registered commands in `Falyx` +- Argument metadata and `suggest_next()` from `CommandArgumentParser` -Completions are sourced from `CommandArgumentParser.suggest_next`, which analyzes -parsed tokens to determine appropriate next arguments, flags, or values. Integrated with the `Falyx.prompt_session` to enhance the interactive experience. """ @@ -42,9 +46,12 @@ class FalyxCompleter(Completer): - Remaining required or optional flags - Flag value suggestions (choices or custom completions) - Next positional argument hints + - Inserts longest common prefix (LCP) completions when applicable + - Handles special cases like quoted strings and spaces + - Supports dynamic argument suggestions (e.g. flags, file paths, etc.) Args: - falyx (Falyx): The Falyx menu instance containing all command mappings and parsers. + falyx (Falyx): The active Falyx instance providing command and parser context. """ def __init__(self, falyx: "Falyx"): @@ -52,14 +59,21 @@ class FalyxCompleter(Completer): def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: """ - Yield completions based on the current document input. + Compute completions for the current user input. + + Analyzes the input buffer, determines whether the user is typing: + • A command key/alias + • A flag/option + • An argument value + + and yields appropriate completions. Args: - document (Document): The prompt_toolkit document containing the input buffer. - complete_event: The completion trigger event (unused). + document (Document): The current Prompt Toolkit document (input buffer & cursor). + complete_event: The triggering event (TAB key, menu display, etc.) — not used here. Yields: - Completion objects matching command keys or argument suggestions. + Completion: One or more completions matching the current stub text. """ text = document.text_before_cursor try: @@ -97,8 +111,11 @@ class FalyxCompleter(Completer): """ Suggest top-level command keys and aliases based on the given prefix. + Filters all known commands (and `exit`, `help`, `history` built-ins) + to only those starting with the given prefix. + Args: - prefix (str): The user input to match against available commands. + prefix (str): The current typed prefix. Yields: Completion: Matching keys or aliases from all registered commands. @@ -121,7 +138,10 @@ class FalyxCompleter(Completer): def _ensure_quote(self, text: str) -> str: """ - Ensure the text is properly quoted for shell commands. + Ensure that a suggestion is shell-safe by quoting if needed. + + Adds quotes around completions containing whitespace so they can + be inserted into the CLI without breaking tokenization. Args: text (str): The input text to quote. @@ -134,6 +154,22 @@ class FalyxCompleter(Completer): return text def _yield_lcp_completions(self, suggestions, stub): + """ + Yield completions for the current stub using longest-common-prefix logic. + + Behavior: + - If only one match → yield it fully. + - If multiple matches share a longer prefix → insert the prefix, but also + display all matches in the menu. + - If no shared prefix → list all matches individually. + + Args: + suggestions (list[str]): The raw suggestions to consider. + stub (str): The currently typed prefix (used to offset insertion). + + Yields: + Completion: Completion objects for the Prompt Toolkit menu. + """ matches = [s for s in suggestions if s.startswith(stub)] if not matches: return diff --git a/falyx/falyx.py b/falyx/falyx.py index 33f9de2..a966b0a 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -207,6 +207,7 @@ class Falyx: FalyxMode.RUN, FalyxMode.PREVIEW, FalyxMode.RUN_ALL, + FalyxMode.HELP, } def validate_options( @@ -359,11 +360,12 @@ class Falyx: f"Use '{self.program} --verbose' to enable debug logging for a menu session.", f"'{self.program} --debug-hooks' will trace every before/after hook in action.", f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.", + "All [COMMAND] keys and aliases are case-insensitive.", ] if self.is_cli_mode: tips.extend( [ - f"Use '{self.program} run ?' to list all commands at any time.", + f"Use '{self.program} help' to list all commands at any time.", f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].", f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.", f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.", @@ -382,7 +384,27 @@ class Falyx: ) return choice(tips) - async def _render_help(self, tag: str = "") -> None: + async def _render_help( + self, tag: str = "", key: str | None = None, tldr: bool = False + ) -> None: + if key: + _, command, args, kwargs = await self.get_command(key) + if command and tldr and command.arg_parser: + command.arg_parser.render_tldr() + return None + elif command and tldr and not command.arg_parser: + self.console.print( + f"[bold]No TLDR examples available for '{command.description}'.[/bold]" + ) + elif command and command.arg_parser: + command.arg_parser.render_help() + return None + elif command and not command.arg_parser: + self.console.print( + f"[bold]No detailed help available for '{command.description}'.[/bold]" + ) + else: + self.console.print(f"[bold]No command found for '{key}'.[/bold]") if tag: tag_lower = tag.lower() self.console.print(f"[bold]{tag_lower}:[/bold]") @@ -451,7 +473,7 @@ class Falyx: command_key="H", command_description="Help", command_style=OneColors.LIGHT_YELLOW, - aliases=["?", "HELP", "LIST"], + aliases=["?", "HELP"], program=self.program, ) parser.add_argument( @@ -463,7 +485,7 @@ class Falyx: ) return Command( key="H", - aliases=["?", "HELP", "LIST"], + aliases=["?", "HELP"], description="Help", help_text="Show this help menu", action=Action("Help", self._render_help), @@ -907,11 +929,7 @@ class Falyx: logger.info("Command '%s' selected.", run_command.key) if is_preview: return True, run_command, args, kwargs - elif self.options.get("mode") in { - FalyxMode.RUN, - FalyxMode.RUN_ALL, - FalyxMode.PREVIEW, - }: + elif self.is_cli_mode: return False, run_command, args, kwargs try: args, kwargs = await run_command.parse_args(input_args, from_validate) @@ -1166,7 +1184,7 @@ class Falyx: This method parses CLI arguments, configures the runtime environment, and dispatches execution to the appropriate command mode: - - list - Show help output, optionally filtered by tag. + - help - Show help output, optionally filtered by tag. - version - Print the program version and exit. - preview - Display a preview of the specified command without executing it. - run - Execute a single command with parsed arguments and lifecycle hooks. @@ -1255,8 +1273,11 @@ class Falyx: logger.debug("Enabling global debug hooks for all commands") self.register_all_with_debug_hooks() - if self.cli_args.command == "list": - await self._render_help(tag=self.cli_args.tag) + if self.cli_args.command == "help": + self.options.set("mode", FalyxMode.HELP) + await self._render_help( + tag=self.cli_args.tag, key=self.cli_args.key, tldr=self.cli_args.tldr + ) sys.exit(0) if self.cli_args.command == "version" or self.cli_args.version: diff --git a/falyx/mode.py b/falyx/mode.py index c6782ce..977e755 100644 --- a/falyx/mode.py +++ b/falyx/mode.py @@ -10,3 +10,4 @@ class FalyxMode(Enum): RUN = "run" PREVIEW = "preview" RUN_ALL = "run-all" + HELP = "help" diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index c495ff8..07ccbf1 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -1192,7 +1192,6 @@ class CommandArgumentParser: ) break - last = args[-1] next_to_last = args[-2] if len(args) > 1 else "" suggestions: list[str] = [] @@ -1399,12 +1398,15 @@ class CommandArgumentParser: Displays brief usage examples with descriptions. """ if not self._tldr_examples: - self.console.print("[bold]No TLDR examples available.[/bold]") + self.console.print( + f"[bold]No TLDR examples available for {self.command_key}.[/bold]" + ) return is_cli_mode = self.options_manager.get("mode") in { FalyxMode.RUN, FalyxMode.PREVIEW, FalyxMode.RUN_ALL, + FalyxMode.HELP, } program = self.program or "falyx" diff --git a/falyx/parser/parsers.py b/falyx/parser/parsers.py index 07744c0..8e3e4fd 100644 --- a/falyx/parser/parsers.py +++ b/falyx/parser/parsers.py @@ -4,7 +4,7 @@ Provides the argument parser infrastructure for the Falyx CLI. This module defines the `FalyxParsers` dataclass and related utilities for building structured CLI interfaces with argparse. It supports top-level CLI commands like -`run`, `run-all`, `preview`, `list`, and `version`, and integrates seamlessly with +`run`, `run-all`, `preview`, `help`, and `version`, and integrates seamlessly with registered `Command` objects for dynamic help, usage generation, and argument handling. Key Components: @@ -39,7 +39,7 @@ class FalyxParsers: run: ArgumentParser run_all: ArgumentParser preview: ArgumentParser - list: ArgumentParser + help: ArgumentParser version: ArgumentParser def parse_args(self, args: Sequence[str] | None = None) -> Namespace: @@ -196,7 +196,7 @@ def get_arg_parsers( This function builds the root parser and all subcommand parsers used for structured CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`, - `preview`, `list`, and `version`, and integrates with registered `Command` objects + `preview`, `help`, and `version`, and integrates with registered `Command` objects to populate dynamic help and usage documentation. Args: @@ -219,7 +219,7 @@ def get_arg_parsers( Returns: FalyxParsers: A structured container of all parsers, including `run`, `run-all`, - `preview`, `list`, `version`, and the root parser. + `preview`, `help`, `version`, and the root parser. Raises: TypeError: If `root_parser` is not an instance of ArgumentParser or @@ -375,12 +375,24 @@ def get_arg_parsers( ) preview_parser.add_argument("name", help="Key, alias, or description of the command") - list_parser = subparsers.add_parser( - "list", help="List all available commands with tags" + help_parser = subparsers.add_parser("help", help="List all available commands") + + help_parser.add_argument( + "-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None ) - list_parser.add_argument( - "-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None + help_parser.add_argument( + "-k", + "--key", + help="Show help for a specific command by its key or alias", + default=None, + ) + + help_parser.add_argument( + "-T", + "--tldr", + action="store_true", + help="Show a simplified TLDR examples of a command if available", ) version_parser = subparsers.add_parser("version", help=f"Show {prog} version") @@ -391,6 +403,6 @@ def get_arg_parsers( run=run_parser, run_all=run_all_parser, preview=preview_parser, - list=list_parser, + help=help_parser, version=version_parser, ) diff --git a/falyx/version.py b/falyx/version.py index 939e02b..e5e0b9d 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.80" +__version__ = "0.1.81" diff --git a/pyproject.toml b/pyproject.toml index 2cbf02c..4d675d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.80" +version = "0.1.81" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_falyx/test_help.py b/tests/test_falyx/test_help.py new file mode 100644 index 0000000..948bead --- /dev/null +++ b/tests/test_falyx/test_help.py @@ -0,0 +1,94 @@ +import pytest + +from falyx import Falyx + + +@pytest.mark.asyncio +async def test_help_command(capsys): + flx = Falyx() + await flx.run_key("H") + + captured = capsys.readouterr() + assert "Show this help menu" in captured.out + + +@pytest.mark.asyncio +async def test_help_command_with_new_command(capsys): + flx = Falyx() + + async def new_command(falyx: Falyx): + pass + + flx.add_command( + "N", + "New Command", + new_command, + aliases=["TEST"], + help_text="This is a new command.", + ) + await flx.run_key("H") + + captured = capsys.readouterr() + assert "This is a new command." in captured.out + assert "TEST" in captured.out and "N" in captured.out + + +@pytest.mark.asyncio +async def test_render_help(capsys): + flx = Falyx() + + async def sample_command(falyx: Falyx): + pass + + flx.add_command( + "S", + "Sample Command", + sample_command, + aliases=["SC"], + help_text="This is a sample command.", + ) + await flx._render_help() + + captured = capsys.readouterr() + assert "This is a sample command." in captured.out + assert "SC" in captured.out and "S" in captured.out + + +@pytest.mark.asyncio +async def test_help_command_by_tag(capsys): + flx = Falyx() + + async def tagged_command(falyx: Falyx): + pass + + flx.add_command( + "T", + "Tagged Command", + tagged_command, + tags=["tag1"], + help_text="This command is tagged.", + ) + await flx.run_key("H", args=("tag1",)) + + captured = capsys.readouterr() + assert "tag1" in captured.out + assert "This command is tagged." in captured.out + assert "HELP" not in captured.out + + +@pytest.mark.asyncio +async def test_help_command_empty_tags(capsys): + flx = Falyx() + + async def untagged_command(falyx: Falyx): + pass + + flx.add_command( + "U", "Untagged Command", untagged_command, help_text="This command has no tags." + ) + await flx.run_key("H", args=("nonexistent_tag",)) + + captured = capsys.readouterr() + print(captured.out) + assert "nonexistent_tag" in captured.out + assert "Nothing to show here" in captured.out diff --git a/tests/test_falyx/test_run.py b/tests/test_falyx/test_run.py new file mode 100644 index 0000000..ac83d30 --- /dev/null +++ b/tests/test_falyx/test_run.py @@ -0,0 +1,19 @@ +import sys + +import pytest + +from falyx import Falyx +from falyx.parser import get_arg_parsers + + +@pytest.mark.asyncio +async def test_run_basic(capsys): + sys.argv = ["falyx", "run", "-h"] + falyx_parsers = get_arg_parsers() + assert falyx_parsers is not None, "Falyx parsers should be initialized" + flx = Falyx() + with pytest.raises(SystemExit): + await flx.run(falyx_parsers) + + captured = capsys.readouterr() + assert "Run a command by its key or alias." in captured.out