feat: redesign help command, improve completer UX, and document Falyx CLI

- Renamed CLI subcommand from `list` to `help` for clarity and discoverability.
- Added `--key` and `--tldr` support to the `help` command for detailed and example-based output.
- Introduced `FalyxMode.HELP` to clearly delineate help-related behavior.
- Enhanced `_render_help()` to support:
  - Tag filtering (`--tag`)
  - Per-command help (`--key`)
  - TLDR example rendering (`--tldr`)
- Updated built-in Help command to:
  - Use `FalyxMode.HELP` internally
  - Provide fallback messages for missing help or TLDR data
  - Remove `LIST` alias (replaced by `help`)
- Documented `FalyxCompleter`:
  - Improved docstrings for public methods and completions
  - Updated internal documentation to reflect all supported completion cases
- Updated `CommandArgumentParser.render_tldr()` with fallback message for missing TLDR entries.
- Updated all parser docstrings and variable names to reference `help` (not `list`) as the proper CLI entrypoint.
- Added test coverage:
  - `tests/test_falyx/test_help.py` for CLI `help` command with `tag`, `key`, `tldr`, and fallback scenarios
  - `tests/test_falyx/test_run.py` for basic CLI parser integration
- Bumped version to 0.1.81
This commit is contained in:
2025-08-06 20:33:51 -04:00
parent a25888f316
commit 55d581b870
10 changed files with 220 additions and 34 deletions

View File

@ -346,6 +346,7 @@ class Command(BaseModel):
FalyxMode.RUN, FalyxMode.RUN,
FalyxMode.PREVIEW, FalyxMode.PREVIEW,
FalyxMode.RUN_ALL, FalyxMode.RUN_ALL,
FalyxMode.HELP,
} }
program = f"{self.program} run " if is_cli_mode else "" program = f"{self.program} run " if is_cli_mode else ""

View File

@ -8,9 +8,13 @@ This completer supports:
- Argument flag completion for registered commands (e.g. `--tag`, `--name`) - Argument flag completion for registered commands (e.g. `--tag`, `--name`)
- Context-aware suggestions based on cursor position and argument structure - Context-aware suggestions based on cursor position and argument structure
- Interactive value completions (e.g. choices and suggestions defined per argument) - 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. Integrated with the `Falyx.prompt_session` to enhance the interactive experience.
""" """
@ -42,9 +46,12 @@ class FalyxCompleter(Completer):
- Remaining required or optional flags - Remaining required or optional flags
- Flag value suggestions (choices or custom completions) - Flag value suggestions (choices or custom completions)
- Next positional argument hints - 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: 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"): def __init__(self, falyx: "Falyx"):
@ -52,14 +59,21 @@ class FalyxCompleter(Completer):
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: 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: Args:
document (Document): The prompt_toolkit document containing the input buffer. document (Document): The current Prompt Toolkit document (input buffer & cursor).
complete_event: The completion trigger event (unused). complete_event: The triggering event (TAB key, menu display, etc.) — not used here.
Yields: Yields:
Completion objects matching command keys or argument suggestions. Completion: One or more completions matching the current stub text.
""" """
text = document.text_before_cursor text = document.text_before_cursor
try: try:
@ -97,8 +111,11 @@ class FalyxCompleter(Completer):
""" """
Suggest top-level command keys and aliases based on the given prefix. 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: Args:
prefix (str): The user input to match against available commands. prefix (str): The current typed prefix.
Yields: Yields:
Completion: Matching keys or aliases from all registered commands. Completion: Matching keys or aliases from all registered commands.
@ -121,7 +138,10 @@ class FalyxCompleter(Completer):
def _ensure_quote(self, text: str) -> str: 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: Args:
text (str): The input text to quote. text (str): The input text to quote.
@ -134,6 +154,22 @@ class FalyxCompleter(Completer):
return text return text
def _yield_lcp_completions(self, suggestions, stub): 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)] matches = [s for s in suggestions if s.startswith(stub)]
if not matches: if not matches:
return return

View File

@ -207,6 +207,7 @@ class Falyx:
FalyxMode.RUN, FalyxMode.RUN,
FalyxMode.PREVIEW, FalyxMode.PREVIEW,
FalyxMode.RUN_ALL, FalyxMode.RUN_ALL,
FalyxMode.HELP,
} }
def validate_options( def validate_options(
@ -359,11 +360,12 @@ class Falyx:
f"Use '{self.program} --verbose' to enable debug logging for a menu session.", 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"'{self.program} --debug-hooks' will trace every before/after hook in action.",
f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.", 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: if self.is_cli_mode:
tips.extend( 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} --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 --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.",
f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.", f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.",
@ -382,7 +384,27 @@ class Falyx:
) )
return choice(tips) 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: if tag:
tag_lower = tag.lower() tag_lower = tag.lower()
self.console.print(f"[bold]{tag_lower}:[/bold]") self.console.print(f"[bold]{tag_lower}:[/bold]")
@ -451,7 +473,7 @@ class Falyx:
command_key="H", command_key="H",
command_description="Help", command_description="Help",
command_style=OneColors.LIGHT_YELLOW, command_style=OneColors.LIGHT_YELLOW,
aliases=["?", "HELP", "LIST"], aliases=["?", "HELP"],
program=self.program, program=self.program,
) )
parser.add_argument( parser.add_argument(
@ -463,7 +485,7 @@ class Falyx:
) )
return Command( return Command(
key="H", key="H",
aliases=["?", "HELP", "LIST"], aliases=["?", "HELP"],
description="Help", description="Help",
help_text="Show this help menu", help_text="Show this help menu",
action=Action("Help", self._render_help), action=Action("Help", self._render_help),
@ -907,11 +929,7 @@ class Falyx:
logger.info("Command '%s' selected.", run_command.key) logger.info("Command '%s' selected.", run_command.key)
if is_preview: if is_preview:
return True, run_command, args, kwargs return True, run_command, args, kwargs
elif self.options.get("mode") in { elif self.is_cli_mode:
FalyxMode.RUN,
FalyxMode.RUN_ALL,
FalyxMode.PREVIEW,
}:
return False, run_command, args, kwargs return False, run_command, args, kwargs
try: try:
args, kwargs = await run_command.parse_args(input_args, from_validate) 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 This method parses CLI arguments, configures the runtime environment, and dispatches
execution to the appropriate command mode: 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. - version - Print the program version and exit.
- preview - Display a preview of the specified command without executing it. - preview - Display a preview of the specified command without executing it.
- run - Execute a single command with parsed arguments and lifecycle hooks. - 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") logger.debug("Enabling global debug hooks for all commands")
self.register_all_with_debug_hooks() self.register_all_with_debug_hooks()
if self.cli_args.command == "list": if self.cli_args.command == "help":
await self._render_help(tag=self.cli_args.tag) 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) sys.exit(0)
if self.cli_args.command == "version" or self.cli_args.version: if self.cli_args.command == "version" or self.cli_args.version:

View File

@ -10,3 +10,4 @@ class FalyxMode(Enum):
RUN = "run" RUN = "run"
PREVIEW = "preview" PREVIEW = "preview"
RUN_ALL = "run-all" RUN_ALL = "run-all"
HELP = "help"

View File

@ -1192,7 +1192,6 @@ class CommandArgumentParser:
) )
break break
last = args[-1]
next_to_last = args[-2] if len(args) > 1 else "" next_to_last = args[-2] if len(args) > 1 else ""
suggestions: list[str] = [] suggestions: list[str] = []
@ -1399,12 +1398,15 @@ class CommandArgumentParser:
Displays brief usage examples with descriptions. Displays brief usage examples with descriptions.
""" """
if not self._tldr_examples: 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 return
is_cli_mode = self.options_manager.get("mode") in { is_cli_mode = self.options_manager.get("mode") in {
FalyxMode.RUN, FalyxMode.RUN,
FalyxMode.PREVIEW, FalyxMode.PREVIEW,
FalyxMode.RUN_ALL, FalyxMode.RUN_ALL,
FalyxMode.HELP,
} }
program = self.program or "falyx" program = self.program or "falyx"

View File

@ -4,7 +4,7 @@ Provides the argument parser infrastructure for the Falyx CLI.
This module defines the `FalyxParsers` dataclass and related utilities for building This module defines the `FalyxParsers` dataclass and related utilities for building
structured CLI interfaces with argparse. It supports top-level CLI commands like 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. registered `Command` objects for dynamic help, usage generation, and argument handling.
Key Components: Key Components:
@ -39,7 +39,7 @@ class FalyxParsers:
run: ArgumentParser run: ArgumentParser
run_all: ArgumentParser run_all: ArgumentParser
preview: ArgumentParser preview: ArgumentParser
list: ArgumentParser help: ArgumentParser
version: ArgumentParser version: ArgumentParser
def parse_args(self, args: Sequence[str] | None = None) -> Namespace: 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 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`, 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. to populate dynamic help and usage documentation.
Args: Args:
@ -219,7 +219,7 @@ def get_arg_parsers(
Returns: Returns:
FalyxParsers: A structured container of all parsers, including `run`, `run-all`, 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: Raises:
TypeError: If `root_parser` is not an instance of ArgumentParser or 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") preview_parser.add_argument("name", help="Key, alias, or description of the command")
list_parser = subparsers.add_parser( help_parser = subparsers.add_parser("help", help="List all available commands")
"list", help="List all available commands with tags"
help_parser.add_argument(
"-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None
) )
list_parser.add_argument( help_parser.add_argument(
"-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None "-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") version_parser = subparsers.add_parser("version", help=f"Show {prog} version")
@ -391,6 +403,6 @@ def get_arg_parsers(
run=run_parser, run=run_parser,
run_all=run_all_parser, run_all=run_all_parser,
preview=preview_parser, preview=preview_parser,
list=list_parser, help=help_parser,
version=version_parser, version=version_parser,
) )

View File

@ -1 +1 @@
__version__ = "0.1.80" __version__ = "0.1.81"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.80" version = "0.1.81"
description = "Reliable and introspectable async CLI action framework." description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"] authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT" license = "MIT"

View File

@ -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

View File

@ -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