feat(help): add invocation-aware path rendering for nested CLI help

- introduce InvocationContext and InvocationSegment for styled invocation paths
- thread invocation_context through command arg resolution and help/tldr rendering
- render CLI and namespace help from routed context instead of static program formatting
- support per-segment styling for nested namespaces and command paths
- rebase help target context for `help -k` so usage matches the target command path
- clean up context module docs and remove old invocation path formatting helper
This commit is contained in:
2026-04-11 20:00:01 -04:00
parent 30cb8b97b5
commit 8ece2a5de6
5 changed files with 344 additions and 132 deletions

View File

@@ -51,7 +51,7 @@ from rich.tree import Tree
from falyx.action.action import Action from falyx.action.action import Action
from falyx.action.base_action import BaseAction from falyx.action.base_action import BaseAction
from falyx.console import console from falyx.console import console
from falyx.context import ExecutionContext from falyx.context import ExecutionContext, InvocationContext
from falyx.debug import register_debug_hooks from falyx.debug import register_debug_hooks
from falyx.exceptions import CommandArgumentError, NotAFalyxError from falyx.exceptions import CommandArgumentError, NotAFalyxError
from falyx.execution_option import ExecutionOption from falyx.execution_option import ExecutionOption
@@ -213,7 +213,10 @@ class Command(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
async def resolve_args( async def resolve_args(
self, raw_args: list[str] | str, from_validate: bool = False self,
raw_args: list[str] | str,
from_validate: bool = False,
invocation_context: InvocationContext | None = None,
) -> tuple[tuple, dict, dict]: ) -> tuple[tuple, dict, dict]:
"""Parse CLI arguments into execution-ready components. """Parse CLI arguments into execution-ready components.
@@ -292,7 +295,9 @@ class Command(BaseModel):
) )
return await self.arg_parser.parse_args_split( return await self.arg_parser.parse_args_split(
raw_args, from_validate=from_validate raw_args,
from_validate=from_validate,
invocation_context=invocation_context,
) )
@field_validator("action", mode="before") @field_validator("action", mode="before")
@@ -483,12 +488,15 @@ class Command(BaseModel):
if not self.arg_parser: if not self.arg_parser:
return "No arguments defined." return "No arguments defined."
command_keys_text = self.arg_parser.get_command_keys_text(plain_text=True) command_keys_text = self.arg_parser.get_command_keys_text()
options_text = self.arg_parser.get_options_text(plain_text=True) options_text = self.arg_parser.get_options_text()
return f" {command_keys_text:<20} {options_text} " return f" {command_keys_text:<20} {options_text} "
@property @property
def help_signature(self) -> tuple[str, str, str]: def help_signature(
self,
invocation_context: InvocationContext | None = None,
) -> tuple[str, str, str]:
"""Return a formatted help signature for display. """Return a formatted help signature for display.
This property provides the core information used to render command help This property provides the core information used to render command help
@@ -519,7 +527,7 @@ class Command(BaseModel):
- Formatting may vary depending on CLI vs menu mode. - Formatting may vary depending on CLI vs menu mode.
""" """
if self.arg_parser and not self.simple_help_signature: if self.arg_parser and not self.simple_help_signature:
usage = self.arg_parser.get_usage() usage = self.arg_parser.get_usage(invocation_context=invocation_context)
description = f"[dim]{self.help_text or self.description}[/dim]" description = f"[dim]{self.help_text or self.description}[/dim]"
if self.tags: if self.tags:
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]" tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
@@ -541,7 +549,7 @@ class Command(BaseModel):
if self._context: if self._context:
self._context.log_summary() self._context.log_summary()
def render_help(self) -> bool: def render_help(self, invocation_context: InvocationContext | None = None) -> bool:
"""Display the help message for the command.""" """Display the help message for the command."""
if callable(self.custom_help): if callable(self.custom_help):
output = self.custom_help() output = self.custom_help()
@@ -549,11 +557,11 @@ class Command(BaseModel):
console.print(output) console.print(output)
return True return True
if isinstance(self.arg_parser, CommandArgumentParser): if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.render_help() self.arg_parser.render_help(invocation_context=invocation_context)
return True return True
return False return False
def render_tldr(self) -> bool: def render_tldr(self, invocation_context: InvocationContext | None = None) -> bool:
"""Display the TLDR message for the command.""" """Display the TLDR message for the command."""
if callable(self.custom_tldr): if callable(self.custom_tldr):
output = self.custom_tldr() output = self.custom_tldr()
@@ -561,7 +569,7 @@ class Command(BaseModel):
console.print(output) console.print(output)
return True return True
if isinstance(self.arg_parser, CommandArgumentParser): if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.render_tldr() self.arg_parser.render_tldr(invocation_context=invocation_context)
return True return True
return False return False

View File

@@ -1,18 +1,24 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""Context management for Falyx CLI. """Context models for Falyx execution and invocation state.
This module defines `ExecutionContext` and `SharedContext`, which are responsible for This module defines the core context objects used throughout Falyx to track both
capturing per-action and cross-action metadata during CLI workflow execution. These runtime execution metadata and routed invocation-path state.
context objects provide structured introspection, result tracking, error recording,
and time-based performance metrics.
- `ExecutionContext`: Captures runtime information for a single action execution, It provides:
including arguments, results, exceptions, timing, and logging. - `ExecutionContext` for per-action execution details such as arguments,
- `SharedContext`: Maintains shared state and result propagation across results, exceptions, timing, and summary logging.
`ChainedAction` or `ActionGroup` executions. - `SharedContext` for transient shared state across grouped or chained
actions, including propagated results, indexed errors, and arbitrary
shared data.
- `InvocationSegment` for representing a single styled token within a
rendered invocation path.
- `InvocationContext` for capturing the current routed command path as an
immutable value object that supports both plain-text and Rich-markup
rendering.
These contexts enable rich introspection, traceability, and workflow coordination, Together, these models support Falyx lifecycle hooks, execution tracing,
supporting hook lifecycles, retries, and structured output generation. history/introspection, and context-aware help and usage rendering across CLI
and menu modes.
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,6 +29,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from rich.console import Console from rich.console import Console
from rich.markup import escape
from falyx.console import console from falyx.console import console
from falyx.mode import FalyxMode from falyx.mode import FalyxMode
@@ -285,28 +292,161 @@ class SharedContext(BaseModel):
) )
class InvocationSegment(BaseModel):
"""Styled path segment used to build an invocation display path.
`InvocationSegment` represents a single token within an `InvocationContext`,
such as a namespace key, command key, or alias. It stores the raw display
text and an optional Rich style so invocation paths can be rendered either
as plain text or styled markup.
Attributes:
text (str): Display text for this path segment.
style (str | None): Optional Rich style applied when rendering this
segment in markup output.
"""
text: str
style: str | None = None
class InvocationContext(BaseModel): class InvocationContext(BaseModel):
"""Immutable invocation-path context for routed Falyx help and execution.
`InvocationContext` captures the current displayable command path as the router
descends through namespaces and commands. It stores both the raw typed path
(`typed_path`) and a styled segment representation (`segments`) so the same
context can be rendered as plain text or Rich markup.
This model is intended to be treated as an immutable value object. Methods such
as `with_path_segment()` and `without_last_path_segment()` return new context
instances rather than mutating the existing one.
Attributes:
program (str): Root program name used in CLI-mode help and usage output.
program_style (str): Rich style applied to the program name when rendering
`markup_path`.
typed_path (list[str]): Raw invocation tokens collected during routing,
excluding the root program name.
segments (list[InvocationSegment]): Styled path segments used to render the
invocation path with Rich markup.
mode (FalyxMode): Active Falyx mode for this invocation context. This is
used to determine whether the path should include the program name.
is_preview (bool): Whether the current invocation is a preview flow rather
than a normal execution flow.
"""
program: str = "" program: str = ""
program_style: str = ""
typed_path: list[str] = Field(default_factory=list) typed_path: list[str] = Field(default_factory=list)
segments: list[InvocationSegment] = Field(default_factory=list)
mode: FalyxMode = FalyxMode.MENU mode: FalyxMode = FalyxMode.MENU
is_preview: bool = False is_preview: bool = False
@property @property
def is_cli_mode(self) -> bool: def is_cli_mode(self) -> bool:
"""Whether this context should render using CLI path semantics.
Returns:
bool: `True` when the invocation is not in menu mode, meaning rendered
paths should include the program name. `False` when in menu mode.
"""
return self.mode != FalyxMode.MENU return self.mode != FalyxMode.MENU
def child(self, token: str) -> InvocationContext: def with_path_segment(
self,
token: str,
*,
style: str | None = None,
) -> InvocationContext:
"""Return a new context with one additional path segment appended.
This method preserves the current context and creates a new
`InvocationContext` with the provided token added to both `typed_path` and
`segments`.
Args:
token (str): Raw path token to append, such as a namespace key,
command key, or alias.
style (str | None): Optional Rich style for the appended segment.
Returns:
InvocationContext: A new context containing the appended path segment.
"""
return InvocationContext( return InvocationContext(
program=self.program, program=self.program,
program_style=self.program_style,
typed_path=[*self.typed_path, token], typed_path=[*self.typed_path, token],
segments=[*self.segments, InvocationSegment(text=token, style=style)],
mode=self.mode, mode=self.mode,
is_preview=self.is_preview, is_preview=self.is_preview,
) )
def display_path(self) -> str: def without_last_path_segment(self) -> InvocationContext:
"""Return a new context with the last path segment removed.
This method preserves the current context and creates a new
`InvocationContext` with the last token removed from both `typed_path` and
`segments`.
Returns:
InvocationContext: A new context with the last path segment removed, or the
current context if no path segments are present.
"""
if not self.typed_path:
return self
return InvocationContext(
program=self.program,
program_style=self.program_style,
typed_path=self.typed_path[:-1],
segments=self.segments[:-1],
mode=self.mode,
is_preview=self.is_preview,
)
@property
def plain_path(self) -> str:
"""Render the invocation path as plain text.
In CLI mode, the rendered path includes the root program name followed by
all collected path segments. In menu mode, only the collected path segments
are rendered.
Returns:
str: Plain-text invocation path suitable for logs, comparisons, or
non-styled help output.
"""
parts = [seg.text for seg in self.segments]
if self.is_cli_mode: if self.is_cli_mode:
return " ".join([self.program, *self.typed_path]).strip() return " ".join([self.program, *parts]).strip()
return " ".join(self.typed_path).strip() return " ".join(parts).strip()
@property
def markup_path(self) -> str:
"""Render the invocation path as escaped Rich markup.
In CLI mode, the root program name is included and styled with
`program_style` when provided. Each path segment is escaped and styled
using its associated `InvocationSegment.style` value when present.
Returns:
str: Rich-markup invocation path suitable for help and usage rendering.
"""
parts: list[str] = []
if self.is_cli_mode and self.program:
if self.program_style:
parts.append(
f"[{self.program_style}]{escape(self.program)}[/{self.program_style}]"
)
else:
parts.append(escape(self.program))
for seg in self.segments:
if seg.style:
parts.append(f"[{seg.style}]{escape(seg.text)}[/{seg.style}]")
else:
parts.append(escape(seg.text))
return " ".join(parts).strip()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -255,17 +255,11 @@ class Falyx:
"""Returns the current invocation context.""" """Returns the current invocation context."""
return InvocationContext( return InvocationContext(
program=self.program, program=self.program,
program_style=self.program_style,
typed_path=[], typed_path=[],
mode=self.options.get("mode"), mode=self.options.get("mode"),
) )
def format_invocation_path(
self, program: str, typed_path: list[str], *, cli_mode: bool
) -> str:
if cli_mode:
return " ".join([program, *typed_path]).strip()
return " ".join(typed_path).strip()
@property @property
def is_cli_mode(self) -> bool: def is_cli_mode(self) -> bool:
"""Checks if the current mode is a CLI mode.""" """Checks if the current mode is a CLI mode."""
@@ -481,14 +475,18 @@ class Falyx:
) )
return choice(tips) return choice(tips)
async def _render_command_tldr(self, command: Command) -> None: async def _render_command_tldr(
self,
command: Command,
context: InvocationContext | None = None,
) -> None:
"""Renders the TLDR examples for a command, if available.""" """Renders the TLDR examples for a command, if available."""
if not isinstance(command, Command): if not isinstance(command, Command):
self.console.print( self.console.print(
f"Entry '{command.key}' is not a command.", style=OneColors.DARK_RED f"Entry '{command.key}' is not a command.", style=OneColors.DARK_RED
) )
return None return None
if command.render_tldr(): if command.render_tldr(invocation_context=context):
if self.enable_help_tips: if self.enable_help_tips:
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
else: else:
@@ -496,7 +494,12 @@ class Falyx:
f"[bold]No TLDR examples available for '{command.description}'.[/bold]" f"[bold]No TLDR examples available for '{command.description}'.[/bold]"
) )
async def _render_command_help(self, command: Command, tldr: bool = False) -> None: async def _render_command_help(
self,
command: Command,
tldr: bool = False,
context: InvocationContext | None = None,
) -> None:
"""Renders the detailed help for a command, if available.""" """Renders the detailed help for a command, if available."""
if not isinstance(command, Command): if not isinstance(command, Command):
self.console.print( self.console.print(
@@ -504,8 +507,8 @@ class Falyx:
) )
return None return None
if tldr: if tldr:
await self._render_command_tldr(command) await self._render_command_tldr(command, context=context)
elif command.render_help(): elif command.render_help(invocation_context=context):
if self.enable_help_tips: if self.enable_help_tips:
self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}") self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}")
else: else:
@@ -588,26 +591,25 @@ class Falyx:
) )
return None return None
async def _render_namespace_tldr_help(self, context: InvocationContext) -> None:
# TODO: Create namespace tldr
console.print(context.markup_path)
async def render_namespace_help( async def render_namespace_help(
self, context: InvocationContext, tldr: bool = False self, context: InvocationContext, tldr: bool = False
) -> None: ) -> None:
if context.mode is FalyxMode.MENU: if tldr:
await self._render_namespace_tldr_help(context)
elif context.mode is FalyxMode.MENU:
await self._render_menu_help() await self._render_menu_help()
else: else:
print( await self._render_cli_help(context)
self.format_invocation_path(
context.program,
context.typed_path,
cli_mode=True,
)
)
await self._render_cli_help()
async def _render_cli_help(self) -> None: async def _render_cli_help(self, context: InvocationContext) -> None:
"""Renders the CLI help menu with all available commands and options.""" """Renders the CLI help menu with all available commands and options."""
usage = self.usage or "[GLOBAL OPTIONS] [COMMAND] [OPTIONS]" usage = self.usage or "[GLOBAL OPTIONS] [COMMAND] [OPTIONS]"
self.console.print( self.console.print(
f"[bold]usage:[/bold] [{self.program_style}]{self.program}[/{self.program_style}] [{self.usage_style}]{usage}[/{self.usage_style}]" f"[bold]usage:[/bold] {context.markup_path} [{self.usage_style}]{usage}[/{self.usage_style}]"
) )
if self.description: if self.description:
self.console.print( self.console.print(
@@ -653,13 +655,27 @@ class Falyx:
if self.enable_help_tips: if self.enable_help_tips:
self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}") self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}")
def _help_target_base_context(self, context: InvocationContext) -> InvocationContext:
if not context.typed_path:
return context
last_token = context.typed_path[-1]
entry, _ = self.resolve_entry(last_token)
if entry is self.help_command:
return context.without_last_path_segment()
return context
async def render_help( async def render_help(
self, self,
tag: str = "", tag: str = "",
key: str | None = None, key: str | None = None,
tldr: bool = False, tldr: bool = False,
invocation_context: InvocationContext | None = None,
) -> None: ) -> None:
"""Renders the help menu with command details, usage examples, and tips.""" """Renders the help menu with command details, usage examples, and tips."""
context = invocation_context or self.get_current_invocation_context()
if key: if key:
entry, suggestions = self.resolve_entry(key) entry, suggestions = self.resolve_entry(key)
if suggestions: if suggestions:
@@ -667,22 +683,38 @@ class Falyx:
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown entry '{key}'. Did you mean:[/]" f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown entry '{key}'. Did you mean:[/]"
f"{', '.join(suggestions)[:10]}" f"{', '.join(suggestions)[:10]}"
) )
elif isinstance(entry, Command): return None
await self._render_command_help(entry, tldr)
base_context = self._help_target_base_context(context)
if isinstance(entry, Command):
await self._render_command_help(
command=entry,
tldr=tldr,
context=base_context.with_path_segment(key, style=entry.style),
)
elif isinstance(entry, FalyxNamespace): elif isinstance(entry, FalyxNamespace):
await entry.namespace.render_namespace_help( await entry.namespace.render_namespace_help(
self.get_current_invocation_context(), tldr context=base_context.with_path_segment(key, style=entry.style),
tldr=tldr,
) )
else: else:
# TODO: Should print something helpful here
self.console.print( self.console.print(
f"[{OneColors.DARK_RED}]❌ No entry found for '{key}'.[/]" f"[{OneColors.DARK_RED}]❌ No entry found for '{key}'.[/]"
) )
elif tldr:
await self._render_command_help(
self.help_command,
tldr,
context=context,
)
elif tag: elif tag:
await self._render_tag_help(tag) await self._render_tag_help(tag)
elif self.options.get("mode") == FalyxMode.MENU: elif self.options.get("mode") == FalyxMode.MENU:
await self._render_menu_help() await self._render_menu_help()
else: else:
await self._render_cli_help() await self._render_cli_help(context)
def _get_help_command(self) -> Command: def _get_help_command(self) -> Command:
"""Returns the help command for the menu.""" """Returns the help command for the menu."""
@@ -693,8 +725,8 @@ class Falyx:
aliases=["HELP", "?"], aliases=["HELP", "?"],
program=self.program, program=self.program,
options_manager=self.options, options_manager=self.options,
_is_help_command=True,
) )
parser.mark_as_help_command()
parser.add_argument( parser.add_argument(
"-t", "-t",
"--tag", "--tag",
@@ -733,12 +765,24 @@ class Falyx:
async def _preview(self, key: str) -> None: async def _preview(self, key: str) -> None:
"""Previews the execution of a command without actually running it.""" """Previews the execution of a command without actually running it."""
command = await self.resolve_command(key) entry, suggestions = self.resolve_entry(key)
if not command: if suggestions:
self.console.print(f"[{OneColors.DARK_RED}]❌ Command '{key}' not found.") self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown entry '{key}'. Did you mean:[/]"
f"{', '.join(suggestions)[:10]}"
)
return None return None
self.console.print(f"Preview of command '{command.key}': {command.description}") if isinstance(entry, FalyxNamespace):
await command.preview() self.console.print(
f"❌ Entry '{key}' is a namespace. Please specify a command to preview.",
style=OneColors.DARK_RED,
)
return None
if not isinstance(entry, Command):
self.console.print(f"[{OneColors.DARK_RED}]❌ No entry found for '{key}'.[/]")
return None
self.console.print(f"Preview of command '{entry.key}': {entry.description}")
await entry.preview()
def _get_preview_command(self) -> Command: def _get_preview_command(self) -> Command:
"""Returns the preview command for Falyx.""" """Returns the preview command for Falyx."""
@@ -987,7 +1031,7 @@ class Falyx:
description: str, description: str,
submenu: Falyx, submenu: Falyx,
*, *,
style: str = OneColors.CYAN, style: str | None = None,
aliases: list[str] | None = None, aliases: list[str] | None = None,
help_text: str = "", help_text: str = "",
) -> None: ) -> None:
@@ -1003,7 +1047,7 @@ class Falyx:
namespace=submenu, namespace=submenu,
aliases=aliases or [], aliases=aliases or [],
help_text=help_text or f"Open the {description} namespace.", help_text=help_text or f"Open the {description} namespace.",
style=style, style=style or submenu.program_style,
) )
self.namespaces[key] = entry self.namespaces[key] = entry
@@ -1289,6 +1333,7 @@ class Falyx:
context = InvocationContext( context = InvocationContext(
program=self.program, program=self.program,
program_style=self.program_style,
typed_path=[], typed_path=[],
mode=mode or self.options.get("mode"), mode=mode or self.options.get("mode"),
is_preview=is_preview, is_preview=is_preview,
@@ -1304,7 +1349,9 @@ class Falyx:
assert route.command is not None assert route.command is not None
try: try:
args, kwargs, execution_args = await route.command.resolve_args( args, kwargs, execution_args = await route.command.resolve_args(
route.leaf_argv, from_validate=from_validate route.leaf_argv,
from_validate=from_validate,
invocation_context=route.context,
) )
except CommandArgumentError as error: except CommandArgumentError as error:
if from_validate: if from_validate:
@@ -1312,7 +1359,7 @@ class Falyx:
cursor_position=len(raw_arguments), message=str(error) cursor_position=len(raw_arguments), message=str(error)
) from error ) from error
else: else:
route.command.render_help() route.command.render_help(invocation_context=route.context)
self.console.print( self.console.print(
f"[{OneColors.DARK_RED}]❌ [{route.command.key}]: {error}" f"[{OneColors.DARK_RED}]❌ [{route.command.key}]: {error}"
) )
@@ -1368,6 +1415,10 @@ class Falyx:
await command.preview() await command.preview()
return None return None
if command is route.namespace.help_command:
kwargs = kwargs or {}
kwargs["invocation_context"] = route.context
logger.debug( logger.debug(
"Executing command '%s' with args=%s, kwargs=%s, execution_args=%s", "Executing command '%s' with args=%s, kwargs=%s, execution_args=%s",
route.command.description, route.command.description,
@@ -1497,20 +1548,17 @@ class Falyx:
suggestions=suggestions, suggestions=suggestions,
) )
child_context = context.child(head) route_context = context.with_path_segment(head, style=entry.style)
# 4. Namespace entry -> recurse with remaining tokens # 4. Namespace entry -> recurse with remaining tokens
if isinstance(entry, FalyxNamespace): if isinstance(entry, FalyxNamespace):
return await entry.namespace.resolve_route( return await entry.namespace.resolve_route(tail, context=route_context)
tail,
context=child_context,
)
# 5. Leaf command -> stop routing; leave tail untouched for leaf parser # 5. Leaf command -> stop routing; leave tail untouched for leaf parser
return RouteResult( return RouteResult(
kind=RouteKind.COMMAND, kind=RouteKind.COMMAND,
namespace=self, namespace=self,
context=child_context, context=route_context,
command=entry, command=entry,
leaf_argv=tail, leaf_argv=tail,
) )
@@ -1694,7 +1742,16 @@ class Falyx:
logger.info("[asyncio.CancelledError]. <- Exiting run.") logger.info("[asyncio.CancelledError]. <- Exiting run.")
sys.exit(1) sys.exit(1)
if route.kind is RouteKind.NAMESPACE_MENU or not always_start_menu: if (
route.kind
in (
RouteKind.NAMESPACE_MENU,
RouteKind.NAMESPACE_TLDR,
RouteKind.NAMESPACE_HELP,
)
or route.command is self.help_command
or not always_start_menu
):
sys.exit(0) sys.exit(0)
await self.menu() await self.menu()

View File

@@ -58,6 +58,7 @@ from rich.panel import Panel
from falyx.action.base_action import BaseAction from falyx.action.base_action import BaseAction
from falyx.console import console from falyx.console import console
from falyx.context import InvocationContext
from falyx.exceptions import CommandArgumentError, NotAFalyxError from falyx.exceptions import CommandArgumentError, NotAFalyxError
from falyx.execution_option import ExecutionOption from falyx.execution_option import ExecutionOption
from falyx.mode import FalyxMode from falyx.mode import FalyxMode
@@ -136,7 +137,6 @@ class CommandArgumentParser:
tldr_examples: list[tuple[str, str]] | None = None, tldr_examples: list[tuple[str, str]] | None = None,
program: str | None = None, program: str | None = None,
options_manager: OptionsManager | None = None, options_manager: OptionsManager | None = None,
_is_help_command: bool = False,
) -> None: ) -> None:
"""Initialize the CommandArgumentParser.""" """Initialize the CommandArgumentParser."""
self.console: Console = console self.console: Console = console
@@ -162,11 +162,15 @@ class CommandArgumentParser:
self._arg_group_by_dest: dict[str, str] = {} self._arg_group_by_dest: dict[str, str] = {}
self._mutex_group_by_dest: dict[str, str] = {} self._mutex_group_by_dest: dict[str, str] = {}
self._tldr_examples: list[TLDRExample] = [] self._tldr_examples: list[TLDRExample] = []
self._is_help_command: bool = _is_help_command self._is_help_command: bool = False
if tldr_examples: if tldr_examples:
self.add_tldr_examples(tldr_examples) self.add_tldr_examples(tldr_examples)
self.options_manager: OptionsManager = options_manager or OptionsManager() self.options_manager: OptionsManager = options_manager or OptionsManager()
def mark_as_help_command(self) -> None:
"""Mark this parser as the help command parser."""
self._is_help_command = True
def set_options_manager(self, options_manager: OptionsManager) -> None: def set_options_manager(self, options_manager: OptionsManager) -> None:
"""Set the options manager for the parser.""" """Set the options manager for the parser."""
if not isinstance(options_manager, OptionsManager): if not isinstance(options_manager, OptionsManager):
@@ -1129,6 +1133,7 @@ class CommandArgumentParser:
consumed_indices: set[int], consumed_indices: set[int],
arg_states: dict[str, ArgumentState], arg_states: dict[str, ArgumentState],
from_validate: bool = False, from_validate: bool = False,
invocation_context: InvocationContext | None = None,
) -> int: ) -> int:
"""Handle a single token in the command line arguments.""" """Handle a single token in the command line arguments."""
if token in self._keyword: if token in self._keyword:
@@ -1137,7 +1142,7 @@ class CommandArgumentParser:
if action == ArgumentAction.HELP: if action == ArgumentAction.HELP:
if not from_validate: if not from_validate:
self.render_help() self.render_help(invocation_context=invocation_context)
arg_states[spec.dest].set_consumed() arg_states[spec.dest].set_consumed()
raise HelpSignal() raise HelpSignal()
elif action == ArgumentAction.TLDR: elif action == ArgumentAction.TLDR:
@@ -1147,7 +1152,7 @@ class CommandArgumentParser:
consumed_indices.add(index) consumed_indices.add(index)
index += 1 index += 1
elif not from_validate: elif not from_validate:
self.render_tldr() self.render_tldr(invocation_context=invocation_context)
arg_states[spec.dest].set_consumed() arg_states[spec.dest].set_consumed()
raise HelpSignal() raise HelpSignal()
else: else:
@@ -1344,7 +1349,10 @@ class CommandArgumentParser:
) )
async def parse_args( async def parse_args(
self, args: list[str] | None = None, from_validate: bool = False self,
args: list[str] | None = None,
from_validate: bool = False,
invocation_context: InvocationContext | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Parse CLI arguments into a resolved mapping of values. """Parse CLI arguments into a resolved mapping of values.
@@ -1416,6 +1424,7 @@ class CommandArgumentParser:
consumed_indices, consumed_indices,
arg_states=arg_states, arg_states=arg_states,
from_validate=from_validate, from_validate=from_validate,
invocation_context=invocation_context,
) )
# Compare length of args with length of required positional arguments to catch missing required positionals # Compare length of args with length of required positional arguments to catch missing required positionals
@@ -1512,7 +1521,10 @@ class CommandArgumentParser:
return result return result
async def parse_args_split( async def parse_args_split(
self, args: list[str], from_validate: bool = False self,
args: list[str],
from_validate: bool = False,
invocation_context: InvocationContext | None = None,
) -> tuple[tuple[Any, ...], dict[str, Any], dict[str, Any]]: ) -> tuple[tuple[Any, ...], dict[str, Any], dict[str, Any]]:
"""Parse arguments and split them into execution-ready components. """Parse arguments and split them into execution-ready components.
@@ -1536,7 +1548,7 @@ class CommandArgumentParser:
- dict[str, Any]: Keyword arguments for execution. - dict[str, Any]: Keyword arguments for execution.
- dict[str, Any]: Execution-specific arguments handled by Falyx. - dict[str, Any]: Execution-specific arguments handled by Falyx.
""" """
parsed = await self.parse_args(args, from_validate) parsed = await self.parse_args(args, from_validate, invocation_context)
args_list = [] args_list = []
kwargs_dict = {} kwargs_dict = {}
execution_dict = {} execution_dict = {}
@@ -1961,7 +1973,7 @@ class CommandArgumentParser:
return sorted(set(suggestions)) return sorted(set(suggestions))
def get_options_text(self, plain_text=False) -> str: def get_options_text(self) -> str:
""" """
Render all defined arguments as a help-style string. Render all defined arguments as a help-style string.
@@ -1983,62 +1995,64 @@ class CommandArgumentParser:
choice_text = arg.get_choice_text() choice_text = arg.get_choice_text()
if isinstance(arg.nargs, int): if isinstance(arg.nargs, int):
choice_text = " ".join([choice_text] * arg.nargs) choice_text = " ".join([choice_text] * arg.nargs)
if plain_text: options_list.append(escape(choice_text))
options_list.append(choice_text)
else:
options_list.append(escape(choice_text))
return " ".join(options_list) return " ".join(options_list)
def get_command_keys_text(self, plain_text=False) -> str: def get_command_keys_text(self) -> str:
""" """Return formatted string showing the command key and aliases.
Return formatted string showing the command key and aliases.
Used in help rendering and introspection. Used in help rendering and introspection.
Returns: Returns:
str: The visual command selector line. str: The visual command selector line.
""" """
if plain_text: command_keys = " | ".join(
command_keys = " | ".join( [f"[{self.command_style}]{self.command_key}[/{self.command_style}]"]
[f"{self.command_key}"] + [f"{alias}" for alias in self.aliases] + [
) f"[{self.command_style}]{alias}[/{self.command_style}]"
else: for alias in self.aliases
command_keys = " | ".join( ]
[f"[{self.command_style}]{self.command_key}[/{self.command_style}]"] )
+ [
f"[{self.command_style}]{alias}[/{self.command_style}]"
for alias in self.aliases
]
)
return command_keys return command_keys
def get_usage(self, plain_text=False) -> str: def _get_invocation_prefix(
""" self,
Render the usage string for this parser. invocation_context: InvocationContext | None = None,
) -> str:
if invocation_context is None:
command_keys = self.get_command_keys_text()
if self.options_manager.get("mode") == FalyxMode.MENU:
return command_keys
program = self.program or "falyx"
program_style = (
self.options_manager.get("program_style") or self.command_style
)
return f"[{program_style}]{program}[/{program_style}] {command_keys}"
if invocation_context.is_cli_mode:
return invocation_context.markup_path
return invocation_context.markup_path
def get_usage(
self,
invocation_context: InvocationContext | None = None,
) -> str:
"""Render the usage string for this parser.
Returns: Returns:
str: A formatted usage line showing syntax and argument structure. str: A formatted usage line showing syntax and argument structure.
""" """
command_keys = self.get_command_keys_text(plain_text) prefix = self._get_invocation_prefix(invocation_context)
options_text = self.get_options_text(plain_text) options_text = self.get_options_text()
if options_text: return f"{prefix} {options_text}".strip() if options_text else prefix
if self.options_manager.get("mode") == FalyxMode.MENU:
return f"{command_keys} {options_text}"
else:
program = self.program or "falyx"
program_style = (
self.options_manager.get("program_style") or self.command_style
)
return f"[{program_style}]{program}[/{program_style}] {command_keys} {options_text}"
return command_keys
def _iter_keyword_help_sections( def _iter_keyword_help_sections(
self, self,
) -> Generator[tuple[str, str, list[Argument]], None, None]: ) -> Generator[tuple[str, str, list[Argument]], None, None]:
""" """Yields (title, description, arguments)"""
Yields (title, description, arguments)
"""
assigned = set() assigned = set()
for group in self._argument_groups.values(): for group in self._argument_groups.values():
@@ -2059,7 +2073,11 @@ class CommandArgumentParser:
if ungrouped: if ungrouped:
yield "options", "", ungrouped yield "options", "", ungrouped
def render_help(self) -> None: def render_help(
self,
*,
invocation_context: InvocationContext | None = None,
) -> None:
"""Render full help output for the command. """Render full help output for the command.
This method displays a complete help view for the command, including This method displays a complete help view for the command, including
@@ -2076,7 +2094,7 @@ class CommandArgumentParser:
- Supports argument grouping and mutually exclusive groups - Supports argument grouping and mutually exclusive groups
- Applies styling based on configured command style - Applies styling based on configured command style
""" """
usage = self.get_usage() usage = self.get_usage(invocation_context)
self.console.print(f"[bold]usage: {usage}[/bold]\n") self.console.print(f"[bold]usage: {usage}[/bold]\n")
if self.help_text: if self.help_text:
@@ -2131,7 +2149,7 @@ class CommandArgumentParser:
if self.help_epilog: if self.help_epilog:
self.console.print("\n" + self.help_epilog, style="dim") self.console.print("\n" + self.help_epilog, style="dim")
def render_tldr(self) -> None: def render_tldr(self, *, invocation_context: InvocationContext | None = None) -> None:
"""Render concise example usage (TLDR) for the command. """Render concise example usage (TLDR) for the command.
This method displays a minimal, example-driven view of how to invoke This method displays a minimal, example-driven view of how to invoke
@@ -2148,18 +2166,8 @@ class CommandArgumentParser:
f"[bold]No TLDR examples available for {self.command_key}.[/bold]" f"[bold]No TLDR examples available for {self.command_key}.[/bold]"
) )
return return
is_cli_mode = self.options_manager.get("mode") != FalyxMode.MENU prefix = self._get_invocation_prefix(invocation_context)
program = self.program or "falyx" usage = self.get_usage(invocation_context)
program_style = self.options_manager.get("program_style") or self.command_style
command = self.aliases[0] if self.aliases else self.command_key
if self._is_help_command and is_cli_mode:
command = f"[{program_style}]{program}[/{program_style}] [{self.command_style}]help[/{self.command_style}]"
elif is_cli_mode:
command = f"[{program_style}]{program}[/{program_style}] [{self.command_style}]{command}[/{self.command_style}]"
else:
command = f"[{self.command_style}]{command}[/{self.command_style}]"
usage = self.get_usage()
self.console.print(f"[bold]usage:[/] {usage}\n") self.console.print(f"[bold]usage:[/] {usage}\n")
if self.help_text: if self.help_text:
@@ -2167,7 +2175,7 @@ class CommandArgumentParser:
self.console.print("[bold]examples:[/bold]") self.console.print("[bold]examples:[/bold]")
for example in self._tldr_examples: for example in self._tldr_examples:
usage = f"{command} {example.usage.strip()}" usage = f"{prefix} {example.usage.strip()}"
description = example.description.strip() description = example.description.strip()
block = f"[bold]{usage}[/bold]" block = f"[bold]{usage}[/bold]"
self.console.print( self.console.print(

View File

@@ -28,6 +28,5 @@ class RouteResult:
command: "Command | None" = None command: "Command | None" = None
namespace_entry: FalyxNamespace | None = None namespace_entry: FalyxNamespace | None = None
leaf_argv: list[str] = field(default_factory=list) leaf_argv: list[str] = field(default_factory=list)
typed_path: list[str] = field(default_factory=list)
suggestions: list[str] = field(default_factory=list) suggestions: list[str] = field(default_factory=list)
is_preview: bool = False is_preview: bool = False