Skip to content

feat(cli): add prefix-based command matching and autocomplete module#644

Open
shreejaykurhade wants to merge 3 commits intoQuantConnect:masterfrom
shreejaykurhade:feat/autocomplete-aliasing
Open

feat(cli): add prefix-based command matching and autocomplete module#644
shreejaykurhade wants to merge 3 commits intoQuantConnect:masterfrom
shreejaykurhade:feat/autocomplete-aliasing

Conversation

@shreejaykurhade
Copy link
Copy Markdown

@shreejaykurhade shreejaykurhade commented Mar 29, 2026

GitHub Pull Request: Advanced Autocomplete & Shorthand Command Execution

Summary

This Pull Request introduces significant usability improvements to the Lean CLI. It implements Prefix-based Command Matching (allowing shorthand like lean cl instead of lean cloud) and a Native Autocomplete Management Module that supports interactive, visual predictions in PowerShell and traditional completion in Bash/Zsh/Fish.


Files Changed & Practical Reason

1. lean/main.py

Reason: To resolve "Silent Crashes" during automated tasks (like autocomplete script generation).
Excerpt:

    # Ensure Click's Exit signals are not treated as errors
    except click.exceptions.Exit as e:
        exit(e.exit_code)
    except Exception as exception:
        # ...

Logic: Prevents Click's clean exit signals from being swallowed by the general exception handler and logged as errors.


2. lean/components/util/click_aliased_command_group.py

Reason: To enable the "Shorthand" feature where typing lean cl resolves and runs lean cloud.
Excerpt:

def get_command(self, ctx, cmd_name):
    # Try exact match first
    rv = super().get_command(ctx, cmd_name)
    if rv is not None:
        return rv

    # Perform prefix search if no exact match
    matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
    if not matches:
        return None
    elif len(matches) == 1:
        return super().get_command(ctx, matches[0])

    ctx.fail(f"Too many matches: {', '.join(sorted(matches))}")

Logic: Overrides the standard command lookup. If no exact match is found, it performs a prefix search on all available subcommands.


3. lean/commands/__init__.py

Reason: Registering the new autocomplete command group to the main CLI.
Excerpt:

from lean.commands.autocomplete import autocomplete
# ...
lean.add_command(autocomplete)

4. lean/commands/<group>/__init__.py (Multiple)

Reason: Activating prefix-matching on individual command groups (Cloud, Config, Data, etc.).
Excerpt:

from lean.components.util.click_aliased_command_group import AliasedCommandGroup
# ...
@group(cls=AliasedCommandGroup)
def cloud():
    # ...

5. lean/commands/autocomplete.py (Full Module)

Reason: A comprehensive management tool for shell-native autocomplete integration.
Key Features:

  • Automatic Shell Detection: Detects your environment (PowerShell, Bash, Zsh, Fish).
  • Visual Predictions: Enables PowerShell 7+ "Ghost Text" (IntelliSense) via PSReadLine.
  • Permanent Profile Integration: Provides enable and disable commands to modify $PROFILE or .bashrc.

[FULL CODE Logic Snippet]

# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation.

import os
import subprocess
from pathlib import Path
from platform import system
from click import group, argument, Choice, echo, option, command
from lean.components.util.click_aliased_command_group import AliasedCommandGroup


def get_all_commands(grp, path=''):
    import click
    res = []
    if isinstance(grp, click.Group):
        for name, sub in grp.commands.items():
            full_path = (path + name).strip()
            res.append(full_path)  # always add the command/group itself
            if isinstance(sub, click.Group):
                res.extend(get_all_commands(sub, path + name + ' '))  # drill into subcommands
    return res


def detect_shell() -> str:
    """Auto-detect the current shell environment."""
    if system() == 'Windows':
        # On Windows, default to powershell
        parent = os.environ.get('PSModulePath', '')
        if parent:
            return 'powershell'
        return 'powershell'  # CMD falls back to powershell
    else:
        # Unix: check $SHELL env var
        shell_path = os.environ.get('SHELL', '/bin/bash')
        shell_name = Path(shell_path).name.lower()
        if 'zsh' in shell_name:
            return 'zsh'
        elif 'fish' in shell_name:
            return 'fish'
        return 'bash'


def get_powershell_script():
    from lean.commands.lean import lean
    commands_list = get_all_commands(lean)
    commands_csv = ','.join(commands_list)
    script = rf"""
Register-ArgumentCompleter -Native -CommandName lean -ScriptBlock {{
    param($wordToComplete, $commandAst, $cursorPosition)

    $lean_commands = '{commands_csv}' -split ','

    $cmdLine = $commandAst.ToString().TrimStart()
    $cmdLine = $cmdLine -replace '^(lean)\s*', ''

    if (-not $wordToComplete) {{
        $prefix = $cmdLine
    }} else {{
        if ($cmdLine.EndsWith($wordToComplete)) {{
            $prefix = $cmdLine.Substring(0, $cmdLine.Length - $wordToComplete.Length).TrimEnd()
        }} else {{
            $prefix = $cmdLine
        }}
    }}

    $possible = @()
    if (-not $prefix) {{
        $possible = $lean_commands | Where-Object {{ $_ -notmatch ' ' }}
    }} else {{
        $possible = $lean_commands | Where-Object {{ $_.StartsWith($prefix + ' ') }} | ForEach-Object {{
            $suffix = $_.Substring($prefix.Length + 1)
            $suffix.Split(' ')[0]
        }}
    }}

    $validPossible = $possible | Select-Object -Unique
    if ($wordToComplete) {{
        $validPossible = $validPossible | Where-Object {{ $_.StartsWith($wordToComplete) }}
    }}

    $validPossible | ForEach-Object {{
        [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
    }}
}}

try {{
    Set-PSReadLineOption -PredictionSource HistoryAndPlugin -ErrorAction SilentlyContinue
    Set-PSReadLineOption -PredictionViewStyle InlineView -ErrorAction SilentlyContinue
}} catch {{}}

try {{
    Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete -ErrorAction SilentlyContinue
}} catch {{}}
"""
    return script.strip()


def get_bash_zsh_script(shell: str) -> str:
    from lean.commands.lean import lean
    commands_list = get_all_commands(lean)
    commands_csv = ' '.join(commands_list)

    script = f"""
# lean CLI autocomplete
_lean_complete() {{
    local IFS=$'\\n'
    local LEAN_COMMANDS=({commands_csv})
    local cur="${{COMP_WORDS[*]:1:${{#COMP_WORDS[@]}}-1}}"
    cur="${{cur% }}"  # strip trailing space
    local word="${{COMP_WORDS[$COMP_CWORD]}}"
    local prefix="${{cur% $word}}"

    local possible=()
    if [ -z "$prefix" ]; then
        for cmd in "${{LEAN_COMMANDS[@]}}"; do
            if [[ "$cmd" != *" "* ]]; then
                possible+=("$cmd")
            fi
        done
    else
        for cmd in "${{LEAN_COMMANDS[@]}}"; do
            if [[ "$cmd" == "$prefix "* ]]; then
                local suffix="${{cmd#$prefix }}"
                local next_word="${{suffix%% *}}"
                possible+=("$next_word")
            fi
        done
    fi

    local filtered=()
    for p in "${{possible[@]}}"; do
        if [[ "$p" == "$word"* ]]; then
            filtered+=("$p")
        fi
    done

    COMPREPLY=("${{filtered[@]}}")
}}
complete -F _lean_complete lean
"""
    return script.strip()


def get_fish_script() -> str:
    from lean.commands.lean import lean
    commands_list = get_all_commands(lean)
    lines = []
    for cmd in commands_list:
        parts = cmd.split(' ')
        if len(parts) == 1:
            lines.append(f"complete -c lean -f -n '__fish_use_subcommand' -a '{cmd}'")
        elif len(parts) == 2:
            lines.append(f"complete -c lean -f -n '__fish_seen_subcommand_from {parts[0]}' -a '{parts[1]}'")
    return '\n'.join(lines)


def get_script_for_shell(shell: str) -> str:
    if shell == 'powershell':
        return get_powershell_script()
    elif shell == 'fish':
        return get_fish_script()
    else:
        return get_bash_zsh_script(shell)


def get_profile_path(shell: str) -> Path:
    if shell == 'powershell':
        try:
            path = subprocess.check_output(
                ['powershell', '-NoProfile', '-Command', 'Write-Host $PROFILE'],
                stderr=subprocess.DEVNULL
            ).decode('utf-8').strip()
            return Path(path)
        except Exception:
            return Path(os.path.expanduser(r'~\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1'))
    elif shell == 'zsh':
        return Path(os.path.expanduser('~/.zshrc'))
    elif shell == 'fish':
        return Path(os.path.expanduser('~/.config/fish/completions/lean.fish'))
    else:
        return Path(os.path.expanduser('~/.bashrc'))


def manage_profile(shell: str, action: str):
    marker_start = "# >>> lean autocomplete >>>\n"
    marker_end = "# <<< lean autocomplete <<<\n"

    profile_path = get_profile_path(shell)
    script_content = get_script_for_shell(shell) + "\n"

    content = ""
    if profile_path.exists():
        content = profile_path.read_text(encoding='utf-8')

    if action == "install":
        if marker_start in content:
            echo(f"Autocomplete is already installed in {profile_path}.")
            return

        profile_path.parent.mkdir(parents=True, exist_ok=True)
        block = f"\n{marker_start}{script_content}{marker_end}"
        with profile_path.open('a', encoding='utf-8') as f:
            f.write(block)
        echo(f"✓ Installed autocomplete to {profile_path}")
        echo("  Restart your terminal (or open a new window) for changes to take effect.")

    elif action == "uninstall":
        if marker_start not in content:
            echo(f"Autocomplete is not installed in {profile_path}.")
            return

        start_idx = content.find(marker_start)
        end_idx = content.find(marker_end) + len(marker_end)
        new_content = content[:start_idx].rstrip('\n') + "\n" + content[end_idx:].lstrip('\n')

        profile_path.write_text(new_content, encoding='utf-8')
        echo(f"✓ Uninstalled autocomplete from {profile_path}")


@group(name="autocomplete", cls=AliasedCommandGroup)
def autocomplete() -> None:
    """Manage shell autocomplete for Lean CLI.

    Auto-detects your shell. Supports: powershell, bash, zsh, fish.

    \b
    Enable autocomplete (auto-detects shell):
        lean enable-autocomplete

    \b
    Enable for a specific shell:
        lean enable-autocomplete --shell bash

    \b
    Disable autocomplete:
        lean disable-autocomplete
    """
    pass


SHELL_OPTION = option(
    '--shell', '-s',
    type=Choice(['powershell', 'bash', 'zsh', 'fish'], case_sensitive=False),
    default=None,
    help='Target shell. Auto-detected if not specified.'
)


@autocomplete.command(name="show", help="Print the autocomplete script for your shell")
@SHELL_OPTION
def show(shell: str) -> None:
    shell = shell or detect_shell()
    echo(get_script_for_shell(shell))


@command(name="enable-autocomplete", help="Install autocomplete into your shell profile")
@SHELL_OPTION
def enable_autocomplete(shell: str) -> None:
    shell = shell or detect_shell()
    echo(f"Detected shell: {shell}")
    manage_profile(shell, "install")


@command(name="disable-autocomplete", help="Remove autocomplete from your shell profile")
@SHELL_OPTION
def disable_autocomplete(shell: str) -> None:
    shell = shell or detect_shell()
    echo(f"Detected shell: {shell}")
    manage_profile(shell, "uninstall")

Verification

  • lean cl successfully executes lean cloud.
  • Type lean clo + Tab performs ghost-text prediction.
  • New interactive enable command successfully injects code into PowerShell profile.
  • All Click Exit codes are handled cleanly without logging errors.

Autocomplete Visual Behavior

🔹 Top-Level Command List

Typing lean followed by a Tab will now trigger a full scan of the command tree and present a visual menu of every available root-level command (e.g., backtest, cloud, config, data).

🔹 Prefix Matching & Expansion

Typing a partial command such as lean cl and pressing Tab will:

  1. Strictly Filter: Narrow the results to commands starting with cl.
  2. Auto-Expand: Materialize the ghost-text into the full command (e.g., cloud).
  3. Visual Cycling: If multiple matches exist, you can hit Tab to cycle through them in a dropdown menu.

🔹 Deep-Level Discovery

The behavior is recursive. Typing lean cloud + Tab will correctly discover and list only the sub-commands relevant to the cloud group (pull, push, backtest, etc.).

@jaredbroad
Copy link
Copy Markdown
Member

Interesting thank you @shreejaykurhade , we'll review and get back to you next week when MM's back.

@shreejaykurhade
Copy link
Copy Markdown
Author

Sure

Copy link
Copy Markdown
Member

@Martin-Molinero Martin-Molinero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @shreejaykurhade!
Thanks for the contribution 🙌
Overall I think it's promising, I'm not convinced installing files on the users machine is a good pattern for the autocomplete though, seems click has a dynamic way of doing this? Think we should double check what does click suggest as autocomplete approach and what are other popular libraries doing. Should a nice test suite too, there are no tests 😱 .
Also I think ideally it's enabled by default? Again would double check what click and popular libraries suggest, finally I would double check performance implications though, what's the overhead cost we might determine not worth enabling by default

elif len(matches) == 1:
return super().get_command(ctx, matches[0])

ctx.fail(f"Too many matches: {', '.join(sorted(matches))}")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love it! Seems to me this could be it's own PR with it's own tests @shreejaykurhade

Comment thread lean/main.py Outdated
temp_manager = container.temp_manager
if temp_manager.delete_temporary_directories_when_done:
temp_manager.delete_temporary_directories()
except click.exceptions.Exit as e:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For speed we avoid global imports like import click or even avoid imports completely unless required, see the except clause bellow where we import just what we need if we need it, should follow the pattern with Exit too

@shreejaykurhade
Copy link
Copy Markdown
Author

shreejaykurhade commented Apr 15, 2026

@Martin-Molinero create and delete the cmd profile using "lean completion on" and "lean completion off"
This PR improves Lean CLI usability in two ways :

  1. adds prefix-based command matching, so unique prefixes like lean cl resolve to lean cloud
  2. adds Click-native shell completion, including PowerShell support, without modifying user shell profiles

The completion flow now follows Click’s native _LEAN_COMPLETE mechanism instead of installing scripts directly into profile files. Users can opt in by running lean completion --shell and evaluating the generated script in their shell.

Changes

  1. add unique prefix resolution for command groups
  2. add lean completion command for powershell, bash, zsh, and fish
  3. add a PowerShell shell-completion adapter using Click’s native shell completion API
  4. keep ambiguous prefixes failing with a clear error message
  5. fix click.Exit handling in lean/main.py using a local import pattern

Examples

  1. lean cl -> lean cloud
  2. lean cloud st -> lean cloud status
  3. PowerShell: lean completion --shell powershell | Out-String | Invoke-Expression
  4. Bash/Zsh: eval "$(lean completion --shell bash)"
  5. Fish: lean completion --shell fish | source

Tests

Added test coverage for:

  1. unique prefix matching
  2. ambiguous prefix errors
  3. top-level and nested shorthand execution
  4. completion script generation
  5. Click native completion flow for PowerShell and Bash

Verified with:
pytest tests/test_completion.py tests/test_click_aliased_command_group.py tests/test_main.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants