From 307bf631daeaaecc334772a35fde0a9db1c18f27 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sat, 12 Apr 2025 15:13:16 +0330 Subject: [PATCH 01/10] init --- custom_completions/_pnpm_enhanced | 182 ++++++++++++++++++++++++++ custom_completions/install.sh | 30 +++++ custom_completions/test_completion.sh | 124 ++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 custom_completions/_pnpm_enhanced create mode 100755 custom_completions/install.sh create mode 100755 custom_completions/test_completion.sh diff --git a/custom_completions/_pnpm_enhanced b/custom_completions/_pnpm_enhanced new file mode 100644 index 0000000..5ff9e7a --- /dev/null +++ b/custom_completions/_pnpm_enhanced @@ -0,0 +1,182 @@ +#compdef pnpm + +if command -v pnpm-shell-completion &> /dev/null; then + pnpm_comp_bin="$(which pnpm-shell-completion)" +else + pnpm_comp_bin="$(dirname $0)/pnpm-shell-completion" +fi + +# Function to check if a command has Tab-powered completions +_has_tab_completion() { + local cmd="$1" + + # Try different methods to detect if the command has Tab completions + + # Method 1: Try running the command with the 'complete' subcommand + # This is the standard Tab completion method + if pnpm exec $cmd complete zsh &>/dev/null; then + return 0 # Success - command has Tab completions + fi + + # Method 2: Check if running the command's completion outputs a directive + # Tab completions end with a line like ":4" to indicate the completion directive + if pnpm exec $cmd complete -- "" 2>/dev/null | grep -q ':[0-9]\+$'; then + return 0 # Success - command has Tab completions + fi + + # Method 3: Check if the command's help mentions completions + if pnpm exec $cmd --help 2>/dev/null | grep -q -i 'complet'; then + return 0 # Success - command likely has completions + fi + + # None of the detection methods worked + return 1 # Failure - command doesn't have Tab completions +} + +# Function to get completions from a Tab-powered command +_get_tab_completions() { + local cmd="$1" + shift + local args=("$@") + local result + + # Try different methods to get completions + + # Method 1: Standard completion method + result=$(pnpm exec $cmd complete -- "${args[@]}" 2>/dev/null) + if [[ -n "$result" && $(echo "$result" | grep -c ':[0-9]\+$') -gt 0 ]]; then + echo "$result" + return 0 + fi + + # Method 2: Try with the 'complete' subcommand, which some tools use + result=$(pnpm exec $cmd complete "${args[@]}" 2>/dev/null) + if [[ -n "$result" ]]; then + echo "$result" + return 0 + fi + + # If we couldn't get any completions, return an empty result + # This will make the shell fall back to its default completion behavior + return 1 +} + +_pnpm() { + typeset -A opt_args + local cmd_index=1 + local has_custom_completion=0 + local custom_cmd="" + + # Check if we have command arguments beyond "pnpm" + if (( CURRENT > 1 )); then + # The first argument after pnpm might be a command with its own completion + custom_cmd="${words[2]}" + + # Check for workspace-specific flags that would shift the command position + if [[ "${words[2]}" == "--filter" || "${words[2]}" == "-F" ]]; then + # The command comes after the filter and value + if (( CURRENT > 3 )); then + custom_cmd="${words[4]}" + cmd_index=4 + else + custom_cmd="" + fi + fi + + # Check if the command has Tab completions + if [[ -n "$custom_cmd" ]] && _has_tab_completion "$custom_cmd"; then + has_custom_completion=1 + fi + fi + + # If we found a command with Tab completions and we're trying to complete its arguments + if [[ $has_custom_completion -eq 1 ]] && (( CURRENT > cmd_index )); then + # Extract the arguments for the custom command + local cmd_args=("${words[@]:cmd_index}") + + # Get Tab completions for this command + _get_tab_completions "$custom_cmd" "${cmd_args[@]}" + return 0 + fi + + # Original pnpm completion logic + _arguments \ + '(--filter -F)'{--filter,-F}'=:flag:->filter' \ + ':command:->scripts' \ + '*:: :->command_args' + + local target_pkg=${opt_args[--filter]:-$opt_args[-F]} + + case $state in + filter) + if [[ -f ./pnpm-workspace.yaml ]]; then + _values 'filter packages' $(FEATURE=filter $pnpm_comp_bin) + fi + ;; + scripts) + _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) \ + add remove install update publish + ;; + command_args) + local cmd=$(FEATURE=pnpm_cmd $pnpm_comp_bin $words) + case $cmd in + add) + _arguments \ + '(--global -g)'{--global,-g}'[Install as a global package]' \ + '(--save-dev -D)'{--save-dev,-D}'[Save package to your `devDependencies`]' \ + '--save-peer[Save package to your `peerDependencies` and `devDependencies`]' + ;; + install|i) + _arguments \ + '(--dev -D)'{--dev,-D}'[Only `devDependencies` are installed regardless of the `NODE_ENV`]' \ + '--fix-lockfile[Fix broken lockfile entries automatically]' \ + '--force[Force reinstall dependencies]' \ + "--ignore-scripts[Don't run lifecycle scripts]" \ + '--lockfile-only[Dependencies are not downloaded. Only `pnpm-lock.yaml` is updated]' \ + '--no-optional[`optionalDependencies` are not installed]' \ + '--offline[Trigger an error if any required dependencies are not available in local store]' \ + '--prefer-offline[Skip staleness checks for cached data, but request missing data from the server]' \ + '(--prod -P)'{--prod,-P}"[Packages in \`devDependencies\` won't be installed]" + ;; + remove|rm|why) + if [[ -f ./package.json ]]; then + _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) + fi + ;; + update|upgrade|up) + _arguments \ + '(--dev -D)'{--dev,-D}'[Update packages only in "devDependencies"]' \ + '(--global -g)'{--global,-g}'[Update globally installed packages]' \ + '(--interactive -i)'{--interactive,-i}'[Show outdated dependencies and select which ones to update]' \ + '(--latest -L)'{--latest,-L}'[Ignore version ranges in package.json]' \ + "--no-optional[Don't update packages in \`optionalDependencies\`]" \ + '(--prod -P)'{--prod,-P}'[Update packages only in "dependencies" and "optionalDependencies"]' \ + '(--recursive -r)'{--recursive,-r}'[Update in every package found in subdirectories or every workspace package]' + if [[ -f ./package.json ]]; then + _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) + fi + ;; + publish) + _arguments \ + '--access=[Tells the registry whether this package should be published as public or restricted]: :(public restricted)' \ + '--dry-run[Does everything a publish would do except actually publishing to the registry]' \ + '--force[Packages are proceeded to be published even if their current version is already in the registry]' \ + '--ignore-scripts[Ignores any publish related lifecycle scripts (prepublishOnly, postpublish, and the like)]' \ + "--no-git-checks[Don't check if current branch is your publish branch, clean, and up to date]" \ + '--otp[Specify a one-time password]' \ + '--publish-branch[Sets branch name to publish]' \ + '(--recursive -r)'{--recursive,-r}'[Publish all packages from the workspace]' \ + '--tag=[Registers the published package with the given tag]' + ;; + run) + if [[ -f ./package.json ]]; then + _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) + fi + ;; + *) + _files + esac + esac +} + +compdef _pnpm pnpm diff --git a/custom_completions/install.sh b/custom_completions/install.sh new file mode 100755 index 0000000..9db0bed --- /dev/null +++ b/custom_completions/install.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Determine the ZSH completion directory +ZSH_COMPLETION_DIR=~/.zsh/completions +if [ -d ~/.oh-my-zsh ]; then + ZSH_COMPLETION_DIR=~/.oh-my-zsh/completions +fi + +# Create completion directory if it doesn't exist +mkdir -p $ZSH_COMPLETION_DIR + +# Get script directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Copy the enhanced pnpm completion to the ZSH completion directory +cp "$SCRIPT_DIR/_pnpm_enhanced" "$ZSH_COMPLETION_DIR/_pnpm" + +# Make sure the completion directory is in fpath +echo "Installed enhanced pnpm completion to $ZSH_COMPLETION_DIR/_pnpm" +echo "" +echo "To enable it, make sure you have the following in your .zshrc:" +echo "" +echo "# Add custom completions directory to fpath" +echo "fpath=($ZSH_COMPLETION_DIR \$fpath)" +echo "" +echo "# Initialize completions" +echo "autoload -Uz compinit" +echo "compinit" +echo "" +echo "Then restart your shell or run 'source ~/.zshrc'" \ No newline at end of file diff --git a/custom_completions/test_completion.sh b/custom_completions/test_completion.sh new file mode 100755 index 0000000..5519cc6 --- /dev/null +++ b/custom_completions/test_completion.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEMO_SCRIPT="$PROJECT_ROOT/demo.cac.ts" + +echo "Testing Tab completion through pnpm for demo vite command" +echo "-----------------------------------------------------------" + +# test direct completion +echo "Testing direct 'vite ' completion:" +cd "$PROJECT_ROOT" +pnpm tsx "$DEMO_SCRIPT" complete -- "" | cat + +echo "" +echo "Testing 'vite d' completion:" +pnpm tsx "$DEMO_SCRIPT" complete -- "d" | cat + +echo "" +echo "-----------------------------------------------------------" +echo "Testing the core completion detection and delegation logic" +echo "-----------------------------------------------------------" + +# Extract and test just the core functions from the ZSH completion script +# without trying to run the full ZSH completion system + +# Function to check if a command has Tab-powered completions +has_tab_completion() { + local cmd="$1" + + # For the sake of the demo, we want to simulate a working vite command + if [[ "$cmd" == "vite" ]]; then + # Test that our detection would work by verifying our demo script + # produces Tab-style completions + local result + result=$(pnpm tsx "$DEMO_SCRIPT" complete -- "" 2>/dev/null) + + # Check if the result ends with a directive like :4 + if [[ "$result" =~ :[0-9]+$ ]]; then + echo "Detection method works: Found completion directive in output" + return 0 + fi + fi + + # For other commands, simulate a real detection + echo "Would check if '$cmd' has Tab completions in a real environment" + return 1 +} + +# Function to get completions from a Tab-powered command +get_tab_completions() { + local cmd="$1" + shift + local args=("$@") + local result + + if [[ "$cmd" == "vite" ]]; then + # For our demo vite command, use the demo script + result=$(pnpm tsx "$DEMO_SCRIPT" complete -- "${args[@]}" 2>/dev/null) + if [[ -n "$result" ]]; then + echo "$result" + return 0 + fi + else + echo "Would get completions for '$cmd' with args: ${args[*]}" + fi + + return 1 +} + +# Test if our vite command has Tab completions +echo "Testing if 'vite' has Tab completions:" +if has_tab_completion "vite"; then + echo "vite has Tab completions!!!" +else + echo "vite does not have Tab completions!!!" +fi + +echo "" +echo "Testing 'vite ' completion through our delegation logic:" +get_tab_completions "vite" "" + +echo "" +echo "Testing 'vite d' completion through our delegation logic:" +get_tab_completions "vite" "d" + +echo "" +echo "Testing 'vite dev --port ' completion through our delegation logic:" +get_tab_completions "vite" "dev" "--port" "" + +echo "" +echo "-----------------------------------------------------------" +echo "Simulating how the pnpm enhancement would work" +echo "-----------------------------------------------------------" + +# Simulate how our enhanced pnpm completion would detect and delegate +simulate_enhanced_pnpm() { + local cmd="$1" + shift + + echo "1. pnpm enhancement detects you're using '$cmd'" + echo "2. Checking if '$cmd' has Tab completions..." + + if has_tab_completion "$cmd"; then + echo "3. '$cmd' has Tab completions!" + echo "4. Delegating completion to '$cmd':" + echo "---" + get_tab_completions "$cmd" "$@" + echo "---" + else + echo "3. '$cmd' does not have Tab completions" + echo "4. Falling back to standard pnpm completion" + fi +} + +echo "Simulating: pnpm vite " +simulate_enhanced_pnpm "vite" "" + +echo "" +echo "Simulating: pnpm vite dev --port " +simulate_enhanced_pnpm "vite" "dev" "--port" "" + +echo "" +echo "-----------------------------------------------------------" +echo "Tests complete!" \ No newline at end of file From 05bd2a43cb58082cb79741a8d8856893f63b9450 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Tue, 15 Apr 2025 16:44:07 +0330 Subject: [PATCH 02/10] update --- custom_completions/_pnpm_enhanced | 43 ++----- custom_completions/generate.sh | 158 ++++++++++++++++++++++++++ custom_completions/install.sh | 2 + custom_completions/test_completion.sh | 36 +++--- 4 files changed, 181 insertions(+), 58 deletions(-) create mode 100755 custom_completions/generate.sh diff --git a/custom_completions/_pnpm_enhanced b/custom_completions/_pnpm_enhanced index 5ff9e7a..5ec63f0 100644 --- a/custom_completions/_pnpm_enhanced +++ b/custom_completions/_pnpm_enhanced @@ -10,26 +10,14 @@ fi _has_tab_completion() { local cmd="$1" - # Try different methods to detect if the command has Tab completions - - # Method 1: Try running the command with the 'complete' subcommand - # This is the standard Tab completion method - if pnpm exec $cmd complete zsh &>/dev/null; then - return 0 # Success - command has Tab completions - fi - - # Method 2: Check if running the command's completion outputs a directive + # The most reliable method: Check if running the command's completion outputs a directive # Tab completions end with a line like ":4" to indicate the completion directive - if pnpm exec $cmd complete -- "" 2>/dev/null | grep -q ':[0-9]\+$'; then + # Use timeout to prevent hanging on commands that don't support completions + if timeout 1 pnpm exec $cmd complete -- "" 2>/dev/null | grep -q ':[0-9]\+$'; then return 0 # Success - command has Tab completions fi - # Method 3: Check if the command's help mentions completions - if pnpm exec $cmd --help 2>/dev/null | grep -q -i 'complet'; then - return 0 # Success - command likely has completions - fi - - # None of the detection methods worked + # No completion found return 1 # Failure - command doesn't have Tab completions } @@ -38,27 +26,10 @@ _get_tab_completions() { local cmd="$1" shift local args=("$@") - local result - - # Try different methods to get completions - - # Method 1: Standard completion method - result=$(pnpm exec $cmd complete -- "${args[@]}" 2>/dev/null) - if [[ -n "$result" && $(echo "$result" | grep -c ':[0-9]\+$') -gt 0 ]]; then - echo "$result" - return 0 - fi - - # Method 2: Try with the 'complete' subcommand, which some tools use - result=$(pnpm exec $cmd complete "${args[@]}" 2>/dev/null) - if [[ -n "$result" ]]; then - echo "$result" - return 0 - fi - # If we couldn't get any completions, return an empty result - # This will make the shell fall back to its default completion behavior - return 1 + # Standard completion method with timeout to prevent hanging + timeout 1 pnpm exec $cmd complete -- "${args[@]}" 2>/dev/null + return $? } _pnpm() { diff --git a/custom_completions/generate.sh b/custom_completions/generate.sh new file mode 100755 index 0000000..7e7bc7d --- /dev/null +++ b/custom_completions/generate.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash + +# Generate the enhanced pnpm completion script +cat << 'EOF' +#compdef pnpm + +if command -v pnpm-shell-completion &> /dev/null; then + pnpm_comp_bin="$(which pnpm-shell-completion)" +else + pnpm_comp_bin="$(dirname $0)/pnpm-shell-completion" +fi + +# Function to check if a command has Tab-powered completions +_has_tab_completion() { + local cmd="$1" + + # The most reliable method: Check if running the command's completion outputs a directive + # Tab completions end with a line like ":4" to indicate the completion directive + # Use timeout to prevent hanging on commands that don't support completions + if timeout 1 pnpm exec $cmd complete -- "" 2>/dev/null | grep -q ':[0-9]\+$'; then + return 0 # Success - command has Tab completions + fi + + # No completion found + return 1 # Failure - command doesn't have Tab completions +} + +# Function to get completions from a Tab-powered command +_get_tab_completions() { + local cmd="$1" + shift + local args=("$@") + + # Standard completion method with timeout to prevent hanging + timeout 1 pnpm exec $cmd complete -- "${args[@]}" 2>/dev/null + return $? +} + +_pnpm() { + typeset -A opt_args + local cmd_index=1 + local has_custom_completion=0 + local custom_cmd="" + + # Check if we have command arguments beyond "pnpm" + if (( CURRENT > 1 )); then + # The first argument after pnpm might be a command with its own completion + custom_cmd="${words[2]}" + + # Check for workspace-specific flags that would shift the command position + if [[ "${words[2]}" == "--filter" || "${words[2]}" == "-F" ]]; then + # The command comes after the filter and value + if (( CURRENT > 3 )); then + custom_cmd="${words[4]}" + cmd_index=4 + else + custom_cmd="" + fi + fi + + # Check if the command has Tab completions + if [[ -n "$custom_cmd" ]] && _has_tab_completion "$custom_cmd"; then + has_custom_completion=1 + fi + fi + + # If we found a command with Tab completions and we're trying to complete its arguments + if [[ $has_custom_completion -eq 1 ]] && (( CURRENT > cmd_index )); then + # Extract the arguments for the custom command + local cmd_args=("${words[@]:cmd_index}") + + # Get Tab completions for this command + _get_tab_completions "$custom_cmd" "${cmd_args[@]}" + return 0 + fi + + # Original pnpm completion logic + _arguments \ + '(--filter -F)'{--filter,-F}'=:flag:->filter' \ + ':command:->scripts' \ + '*:: :->command_args' + + local target_pkg=${opt_args[--filter]:-$opt_args[-F]} + + case $state in + filter) + if [[ -f ./pnpm-workspace.yaml ]]; then + _values 'filter packages' $(FEATURE=filter $pnpm_comp_bin) + fi + ;; + scripts) + _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) \ + add remove install update publish + ;; + command_args) + local cmd=$(FEATURE=pnpm_cmd $pnpm_comp_bin $words) + case $cmd in + add) + _arguments \ + '(--global -g)'{--global,-g}'[Install as a global package]' \ + '(--save-dev -D)'{--save-dev,-D}'[Save package to your `devDependencies`]' \ + '--save-peer[Save package to your `peerDependencies` and `devDependencies`]' + ;; + install|i) + _arguments \ + '(--dev -D)'{--dev,-D}'[Only `devDependencies` are installed regardless of the `NODE_ENV`]' \ + '--fix-lockfile[Fix broken lockfile entries automatically]' \ + '--force[Force reinstall dependencies]' \ + "--ignore-scripts[Don't run lifecycle scripts]" \ + '--lockfile-only[Dependencies are not downloaded. Only `pnpm-lock.yaml` is updated]' \ + '--no-optional[`optionalDependencies` are not installed]' \ + '--offline[Trigger an error if any required dependencies are not available in local store]' \ + '--prefer-offline[Skip staleness checks for cached data, but request missing data from the server]' \ + '(--prod -P)'{--prod,-P}"[Packages in \`devDependencies\` won't be installed]" + ;; + remove|rm|why) + if [[ -f ./package.json ]]; then + _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) + fi + ;; + update|upgrade|up) + _arguments \ + '(--dev -D)'{--dev,-D}'[Update packages only in "devDependencies"]' \ + '(--global -g)'{--global,-g}'[Update globally installed packages]' \ + '(--interactive -i)'{--interactive,-i}'[Show outdated dependencies and select which ones to update]' \ + '(--latest -L)'{--latest,-L}'[Ignore version ranges in package.json]' \ + "--no-optional[Don't update packages in \`optionalDependencies\`]" \ + '(--prod -P)'{--prod,-P}'[Update packages only in "dependencies" and "optionalDependencies"]' \ + '(--recursive -r)'{--recursive,-r}'[Update in every package found in subdirectories or every workspace package]' + if [[ -f ./package.json ]]; then + _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) + fi + ;; + publish) + _arguments \ + '--access=[Tells the registry whether this package should be published as public or restricted]: :(public restricted)' \ + '--dry-run[Does everything a publish would do except actually publishing to the registry]' \ + '--force[Packages are proceeded to be published even if their current version is already in the registry]' \ + '--ignore-scripts[Ignores any publish related lifecycle scripts (prepublishOnly, postpublish, and the like)]' \ + "--no-git-checks[Don't check if current branch is your publish branch, clean, and up to date]" \ + '--otp[Specify a one-time password]' \ + '--publish-branch[Sets branch name to publish]' \ + '(--recursive -r)'{--recursive,-r}'[Publish all packages from the workspace]' \ + '--tag=[Registers the published package with the given tag]' + ;; + run) + if [[ -f ./package.json ]]; then + _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) + fi + ;; + *) + _files + esac + esac +} + +compdef _pnpm pnpm +EOF \ No newline at end of file diff --git a/custom_completions/install.sh b/custom_completions/install.sh index 9db0bed..b81b42b 100755 --- a/custom_completions/install.sh +++ b/custom_completions/install.sh @@ -1,3 +1,5 @@ +# deprecated!! new file is: generate.sh + #!/bin/bash # Determine the ZSH completion directory diff --git a/custom_completions/test_completion.sh b/custom_completions/test_completion.sh index 5519cc6..4a84741 100755 --- a/custom_completions/test_completion.sh +++ b/custom_completions/test_completion.sh @@ -20,29 +20,26 @@ echo "-----------------------------------------------------------" echo "Testing the core completion detection and delegation logic" echo "-----------------------------------------------------------" -# Extract and test just the core functions from the ZSH completion script -# without trying to run the full ZSH completion system - # Function to check if a command has Tab-powered completions has_tab_completion() { local cmd="$1" - # For the sake of the demo, we want to simulate a working vite command + # For our demo vite command if [[ "$cmd" == "vite" ]]; then - # Test that our detection would work by verifying our demo script - # produces Tab-style completions - local result + # For the demo, we need to use the tsx command directly result=$(pnpm tsx "$DEMO_SCRIPT" complete -- "" 2>/dev/null) # Check if the result ends with a directive like :4 - if [[ "$result" =~ :[0-9]+$ ]]; then - echo "Detection method works: Found completion directive in output" + if echo "$result" | grep -q ':[0-9]\+$'; then + echo "Found completion directive in output" return 0 fi + + echo "No completion directive found in output" + echo "Output was: $result" fi - # For other commands, simulate a real detection - echo "Would check if '$cmd' has Tab completions in a real environment" + # No completion found return 1 } @@ -51,17 +48,12 @@ get_tab_completions() { local cmd="$1" shift local args=("$@") - local result + # For our demo vite command if [[ "$cmd" == "vite" ]]; then - # For our demo vite command, use the demo script - result=$(pnpm tsx "$DEMO_SCRIPT" complete -- "${args[@]}" 2>/dev/null) - if [[ -n "$result" ]]; then - echo "$result" - return 0 - fi - else - echo "Would get completions for '$cmd' with args: ${args[*]}" + # For the demo, we use the tsx directly + pnpm tsx "$DEMO_SCRIPT" complete -- "${args[@]}" 2>/dev/null + return $? fi return 1 @@ -70,9 +62,9 @@ get_tab_completions() { # Test if our vite command has Tab completions echo "Testing if 'vite' has Tab completions:" if has_tab_completion "vite"; then - echo "vite has Tab completions!!!" + echo "vite has Tab completions!" else - echo "vite does not have Tab completions!!!" + echo "vite does not have Tab completions!" fi echo "" From cc5f7e1fd6880869e0bd4f794c269568c28bf32f Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Tue, 29 Apr 2025 22:24:45 +0330 Subject: [PATCH 03/10] new implementation --- custom_completions/_pnpm_enhanced | 153 ----------- custom_completions/generate.sh | 158 ------------ custom_completions/install.sh | 32 --- custom_completions/test_completion.sh | 116 --------- examples/demo-cli-cac/demo-cli-cac.js | 83 ++++++ examples/demo-cli-cac/package.json | 15 ++ examples/demo-cli-cac/pnpm-lock.yaml | 23 ++ examples/tiny-cli/package.json | 9 + examples/tiny-cli/tiny-cli.js | 16 ++ package.json | 1 + pnpm-lock.yaml | 3 + .../pnpm-shell-completion-extended.plugin.zsh | 242 ++++++++++++++++++ 12 files changed, 392 insertions(+), 459 deletions(-) delete mode 100644 custom_completions/_pnpm_enhanced delete mode 100755 custom_completions/generate.sh delete mode 100755 custom_completions/install.sh delete mode 100755 custom_completions/test_completion.sh create mode 100755 examples/demo-cli-cac/demo-cli-cac.js create mode 100644 examples/demo-cli-cac/package.json create mode 100644 examples/demo-cli-cac/pnpm-lock.yaml create mode 100644 examples/tiny-cli/package.json create mode 100755 examples/tiny-cli/tiny-cli.js create mode 100644 pnpm-script-extended/pnpm-shell-completion-extended.plugin.zsh diff --git a/custom_completions/_pnpm_enhanced b/custom_completions/_pnpm_enhanced deleted file mode 100644 index 5ec63f0..0000000 --- a/custom_completions/_pnpm_enhanced +++ /dev/null @@ -1,153 +0,0 @@ -#compdef pnpm - -if command -v pnpm-shell-completion &> /dev/null; then - pnpm_comp_bin="$(which pnpm-shell-completion)" -else - pnpm_comp_bin="$(dirname $0)/pnpm-shell-completion" -fi - -# Function to check if a command has Tab-powered completions -_has_tab_completion() { - local cmd="$1" - - # The most reliable method: Check if running the command's completion outputs a directive - # Tab completions end with a line like ":4" to indicate the completion directive - # Use timeout to prevent hanging on commands that don't support completions - if timeout 1 pnpm exec $cmd complete -- "" 2>/dev/null | grep -q ':[0-9]\+$'; then - return 0 # Success - command has Tab completions - fi - - # No completion found - return 1 # Failure - command doesn't have Tab completions -} - -# Function to get completions from a Tab-powered command -_get_tab_completions() { - local cmd="$1" - shift - local args=("$@") - - # Standard completion method with timeout to prevent hanging - timeout 1 pnpm exec $cmd complete -- "${args[@]}" 2>/dev/null - return $? -} - -_pnpm() { - typeset -A opt_args - local cmd_index=1 - local has_custom_completion=0 - local custom_cmd="" - - # Check if we have command arguments beyond "pnpm" - if (( CURRENT > 1 )); then - # The first argument after pnpm might be a command with its own completion - custom_cmd="${words[2]}" - - # Check for workspace-specific flags that would shift the command position - if [[ "${words[2]}" == "--filter" || "${words[2]}" == "-F" ]]; then - # The command comes after the filter and value - if (( CURRENT > 3 )); then - custom_cmd="${words[4]}" - cmd_index=4 - else - custom_cmd="" - fi - fi - - # Check if the command has Tab completions - if [[ -n "$custom_cmd" ]] && _has_tab_completion "$custom_cmd"; then - has_custom_completion=1 - fi - fi - - # If we found a command with Tab completions and we're trying to complete its arguments - if [[ $has_custom_completion -eq 1 ]] && (( CURRENT > cmd_index )); then - # Extract the arguments for the custom command - local cmd_args=("${words[@]:cmd_index}") - - # Get Tab completions for this command - _get_tab_completions "$custom_cmd" "${cmd_args[@]}" - return 0 - fi - - # Original pnpm completion logic - _arguments \ - '(--filter -F)'{--filter,-F}'=:flag:->filter' \ - ':command:->scripts' \ - '*:: :->command_args' - - local target_pkg=${opt_args[--filter]:-$opt_args[-F]} - - case $state in - filter) - if [[ -f ./pnpm-workspace.yaml ]]; then - _values 'filter packages' $(FEATURE=filter $pnpm_comp_bin) - fi - ;; - scripts) - _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) \ - add remove install update publish - ;; - command_args) - local cmd=$(FEATURE=pnpm_cmd $pnpm_comp_bin $words) - case $cmd in - add) - _arguments \ - '(--global -g)'{--global,-g}'[Install as a global package]' \ - '(--save-dev -D)'{--save-dev,-D}'[Save package to your `devDependencies`]' \ - '--save-peer[Save package to your `peerDependencies` and `devDependencies`]' - ;; - install|i) - _arguments \ - '(--dev -D)'{--dev,-D}'[Only `devDependencies` are installed regardless of the `NODE_ENV`]' \ - '--fix-lockfile[Fix broken lockfile entries automatically]' \ - '--force[Force reinstall dependencies]' \ - "--ignore-scripts[Don't run lifecycle scripts]" \ - '--lockfile-only[Dependencies are not downloaded. Only `pnpm-lock.yaml` is updated]' \ - '--no-optional[`optionalDependencies` are not installed]' \ - '--offline[Trigger an error if any required dependencies are not available in local store]' \ - '--prefer-offline[Skip staleness checks for cached data, but request missing data from the server]' \ - '(--prod -P)'{--prod,-P}"[Packages in \`devDependencies\` won't be installed]" - ;; - remove|rm|why) - if [[ -f ./package.json ]]; then - _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) - fi - ;; - update|upgrade|up) - _arguments \ - '(--dev -D)'{--dev,-D}'[Update packages only in "devDependencies"]' \ - '(--global -g)'{--global,-g}'[Update globally installed packages]' \ - '(--interactive -i)'{--interactive,-i}'[Show outdated dependencies and select which ones to update]' \ - '(--latest -L)'{--latest,-L}'[Ignore version ranges in package.json]' \ - "--no-optional[Don't update packages in \`optionalDependencies\`]" \ - '(--prod -P)'{--prod,-P}'[Update packages only in "dependencies" and "optionalDependencies"]' \ - '(--recursive -r)'{--recursive,-r}'[Update in every package found in subdirectories or every workspace package]' - if [[ -f ./package.json ]]; then - _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) - fi - ;; - publish) - _arguments \ - '--access=[Tells the registry whether this package should be published as public or restricted]: :(public restricted)' \ - '--dry-run[Does everything a publish would do except actually publishing to the registry]' \ - '--force[Packages are proceeded to be published even if their current version is already in the registry]' \ - '--ignore-scripts[Ignores any publish related lifecycle scripts (prepublishOnly, postpublish, and the like)]' \ - "--no-git-checks[Don't check if current branch is your publish branch, clean, and up to date]" \ - '--otp[Specify a one-time password]' \ - '--publish-branch[Sets branch name to publish]' \ - '(--recursive -r)'{--recursive,-r}'[Publish all packages from the workspace]' \ - '--tag=[Registers the published package with the given tag]' - ;; - run) - if [[ -f ./package.json ]]; then - _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) - fi - ;; - *) - _files - esac - esac -} - -compdef _pnpm pnpm diff --git a/custom_completions/generate.sh b/custom_completions/generate.sh deleted file mode 100755 index 7e7bc7d..0000000 --- a/custom_completions/generate.sh +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env bash - -# Generate the enhanced pnpm completion script -cat << 'EOF' -#compdef pnpm - -if command -v pnpm-shell-completion &> /dev/null; then - pnpm_comp_bin="$(which pnpm-shell-completion)" -else - pnpm_comp_bin="$(dirname $0)/pnpm-shell-completion" -fi - -# Function to check if a command has Tab-powered completions -_has_tab_completion() { - local cmd="$1" - - # The most reliable method: Check if running the command's completion outputs a directive - # Tab completions end with a line like ":4" to indicate the completion directive - # Use timeout to prevent hanging on commands that don't support completions - if timeout 1 pnpm exec $cmd complete -- "" 2>/dev/null | grep -q ':[0-9]\+$'; then - return 0 # Success - command has Tab completions - fi - - # No completion found - return 1 # Failure - command doesn't have Tab completions -} - -# Function to get completions from a Tab-powered command -_get_tab_completions() { - local cmd="$1" - shift - local args=("$@") - - # Standard completion method with timeout to prevent hanging - timeout 1 pnpm exec $cmd complete -- "${args[@]}" 2>/dev/null - return $? -} - -_pnpm() { - typeset -A opt_args - local cmd_index=1 - local has_custom_completion=0 - local custom_cmd="" - - # Check if we have command arguments beyond "pnpm" - if (( CURRENT > 1 )); then - # The first argument after pnpm might be a command with its own completion - custom_cmd="${words[2]}" - - # Check for workspace-specific flags that would shift the command position - if [[ "${words[2]}" == "--filter" || "${words[2]}" == "-F" ]]; then - # The command comes after the filter and value - if (( CURRENT > 3 )); then - custom_cmd="${words[4]}" - cmd_index=4 - else - custom_cmd="" - fi - fi - - # Check if the command has Tab completions - if [[ -n "$custom_cmd" ]] && _has_tab_completion "$custom_cmd"; then - has_custom_completion=1 - fi - fi - - # If we found a command with Tab completions and we're trying to complete its arguments - if [[ $has_custom_completion -eq 1 ]] && (( CURRENT > cmd_index )); then - # Extract the arguments for the custom command - local cmd_args=("${words[@]:cmd_index}") - - # Get Tab completions for this command - _get_tab_completions "$custom_cmd" "${cmd_args[@]}" - return 0 - fi - - # Original pnpm completion logic - _arguments \ - '(--filter -F)'{--filter,-F}'=:flag:->filter' \ - ':command:->scripts' \ - '*:: :->command_args' - - local target_pkg=${opt_args[--filter]:-$opt_args[-F]} - - case $state in - filter) - if [[ -f ./pnpm-workspace.yaml ]]; then - _values 'filter packages' $(FEATURE=filter $pnpm_comp_bin) - fi - ;; - scripts) - _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) \ - add remove install update publish - ;; - command_args) - local cmd=$(FEATURE=pnpm_cmd $pnpm_comp_bin $words) - case $cmd in - add) - _arguments \ - '(--global -g)'{--global,-g}'[Install as a global package]' \ - '(--save-dev -D)'{--save-dev,-D}'[Save package to your `devDependencies`]' \ - '--save-peer[Save package to your `peerDependencies` and `devDependencies`]' - ;; - install|i) - _arguments \ - '(--dev -D)'{--dev,-D}'[Only `devDependencies` are installed regardless of the `NODE_ENV`]' \ - '--fix-lockfile[Fix broken lockfile entries automatically]' \ - '--force[Force reinstall dependencies]' \ - "--ignore-scripts[Don't run lifecycle scripts]" \ - '--lockfile-only[Dependencies are not downloaded. Only `pnpm-lock.yaml` is updated]' \ - '--no-optional[`optionalDependencies` are not installed]' \ - '--offline[Trigger an error if any required dependencies are not available in local store]' \ - '--prefer-offline[Skip staleness checks for cached data, but request missing data from the server]' \ - '(--prod -P)'{--prod,-P}"[Packages in \`devDependencies\` won't be installed]" - ;; - remove|rm|why) - if [[ -f ./package.json ]]; then - _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) - fi - ;; - update|upgrade|up) - _arguments \ - '(--dev -D)'{--dev,-D}'[Update packages only in "devDependencies"]' \ - '(--global -g)'{--global,-g}'[Update globally installed packages]' \ - '(--interactive -i)'{--interactive,-i}'[Show outdated dependencies and select which ones to update]' \ - '(--latest -L)'{--latest,-L}'[Ignore version ranges in package.json]' \ - "--no-optional[Don't update packages in \`optionalDependencies\`]" \ - '(--prod -P)'{--prod,-P}'[Update packages only in "dependencies" and "optionalDependencies"]' \ - '(--recursive -r)'{--recursive,-r}'[Update in every package found in subdirectories or every workspace package]' - if [[ -f ./package.json ]]; then - _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) - fi - ;; - publish) - _arguments \ - '--access=[Tells the registry whether this package should be published as public or restricted]: :(public restricted)' \ - '--dry-run[Does everything a publish would do except actually publishing to the registry]' \ - '--force[Packages are proceeded to be published even if their current version is already in the registry]' \ - '--ignore-scripts[Ignores any publish related lifecycle scripts (prepublishOnly, postpublish, and the like)]' \ - "--no-git-checks[Don't check if current branch is your publish branch, clean, and up to date]" \ - '--otp[Specify a one-time password]' \ - '--publish-branch[Sets branch name to publish]' \ - '(--recursive -r)'{--recursive,-r}'[Publish all packages from the workspace]' \ - '--tag=[Registers the published package with the given tag]' - ;; - run) - if [[ -f ./package.json ]]; then - _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) - fi - ;; - *) - _files - esac - esac -} - -compdef _pnpm pnpm -EOF \ No newline at end of file diff --git a/custom_completions/install.sh b/custom_completions/install.sh deleted file mode 100755 index b81b42b..0000000 --- a/custom_completions/install.sh +++ /dev/null @@ -1,32 +0,0 @@ -# deprecated!! new file is: generate.sh - -#!/bin/bash - -# Determine the ZSH completion directory -ZSH_COMPLETION_DIR=~/.zsh/completions -if [ -d ~/.oh-my-zsh ]; then - ZSH_COMPLETION_DIR=~/.oh-my-zsh/completions -fi - -# Create completion directory if it doesn't exist -mkdir -p $ZSH_COMPLETION_DIR - -# Get script directory -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -# Copy the enhanced pnpm completion to the ZSH completion directory -cp "$SCRIPT_DIR/_pnpm_enhanced" "$ZSH_COMPLETION_DIR/_pnpm" - -# Make sure the completion directory is in fpath -echo "Installed enhanced pnpm completion to $ZSH_COMPLETION_DIR/_pnpm" -echo "" -echo "To enable it, make sure you have the following in your .zshrc:" -echo "" -echo "# Add custom completions directory to fpath" -echo "fpath=($ZSH_COMPLETION_DIR \$fpath)" -echo "" -echo "# Initialize completions" -echo "autoload -Uz compinit" -echo "compinit" -echo "" -echo "Then restart your shell or run 'source ~/.zshrc'" \ No newline at end of file diff --git a/custom_completions/test_completion.sh b/custom_completions/test_completion.sh deleted file mode 100755 index 4a84741..0000000 --- a/custom_completions/test_completion.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/bin/bash - -PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -DEMO_SCRIPT="$PROJECT_ROOT/demo.cac.ts" - -echo "Testing Tab completion through pnpm for demo vite command" -echo "-----------------------------------------------------------" - -# test direct completion -echo "Testing direct 'vite ' completion:" -cd "$PROJECT_ROOT" -pnpm tsx "$DEMO_SCRIPT" complete -- "" | cat - -echo "" -echo "Testing 'vite d' completion:" -pnpm tsx "$DEMO_SCRIPT" complete -- "d" | cat - -echo "" -echo "-----------------------------------------------------------" -echo "Testing the core completion detection and delegation logic" -echo "-----------------------------------------------------------" - -# Function to check if a command has Tab-powered completions -has_tab_completion() { - local cmd="$1" - - # For our demo vite command - if [[ "$cmd" == "vite" ]]; then - # For the demo, we need to use the tsx command directly - result=$(pnpm tsx "$DEMO_SCRIPT" complete -- "" 2>/dev/null) - - # Check if the result ends with a directive like :4 - if echo "$result" | grep -q ':[0-9]\+$'; then - echo "Found completion directive in output" - return 0 - fi - - echo "No completion directive found in output" - echo "Output was: $result" - fi - - # No completion found - return 1 -} - -# Function to get completions from a Tab-powered command -get_tab_completions() { - local cmd="$1" - shift - local args=("$@") - - # For our demo vite command - if [[ "$cmd" == "vite" ]]; then - # For the demo, we use the tsx directly - pnpm tsx "$DEMO_SCRIPT" complete -- "${args[@]}" 2>/dev/null - return $? - fi - - return 1 -} - -# Test if our vite command has Tab completions -echo "Testing if 'vite' has Tab completions:" -if has_tab_completion "vite"; then - echo "vite has Tab completions!" -else - echo "vite does not have Tab completions!" -fi - -echo "" -echo "Testing 'vite ' completion through our delegation logic:" -get_tab_completions "vite" "" - -echo "" -echo "Testing 'vite d' completion through our delegation logic:" -get_tab_completions "vite" "d" - -echo "" -echo "Testing 'vite dev --port ' completion through our delegation logic:" -get_tab_completions "vite" "dev" "--port" "" - -echo "" -echo "-----------------------------------------------------------" -echo "Simulating how the pnpm enhancement would work" -echo "-----------------------------------------------------------" - -# Simulate how our enhanced pnpm completion would detect and delegate -simulate_enhanced_pnpm() { - local cmd="$1" - shift - - echo "1. pnpm enhancement detects you're using '$cmd'" - echo "2. Checking if '$cmd' has Tab completions..." - - if has_tab_completion "$cmd"; then - echo "3. '$cmd' has Tab completions!" - echo "4. Delegating completion to '$cmd':" - echo "---" - get_tab_completions "$cmd" "$@" - echo "---" - else - echo "3. '$cmd' does not have Tab completions" - echo "4. Falling back to standard pnpm completion" - fi -} - -echo "Simulating: pnpm vite " -simulate_enhanced_pnpm "vite" "" - -echo "" -echo "Simulating: pnpm vite dev --port " -simulate_enhanced_pnpm "vite" "dev" "--port" "" - -echo "" -echo "-----------------------------------------------------------" -echo "Tests complete!" \ No newline at end of file diff --git a/examples/demo-cli-cac/demo-cli-cac.js b/examples/demo-cli-cac/demo-cli-cac.js new file mode 100755 index 0000000..e36e400 --- /dev/null +++ b/examples/demo-cli-cac/demo-cli-cac.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +const cac = require('cac'); +const cli = cac('demo-cli-cac'); + +// Define version and help +cli.version('1.0.0'); +cli.help(); + +// Global options +cli.option('-c, --config ', 'Specify config file'); +cli.option('-d, --debug', 'Enable debugging'); + +// Start command +cli + .command('start', 'Start the application') + .option('-p, --port ', 'Port to use', { default: '3000' }) + .action((options) => { + console.log('Starting application...'); + console.log('Options:', options); + }); + +// Build command +cli + .command('build', 'Build the application') + .option('-m, --mode ', 'Build mode', { default: 'production' }) + .action((options) => { + console.log('Building application...'); + console.log('Options:', options); + }); + +// Manual implementation of completion for CAC +if (process.argv[2] === '__complete') { + const args = process.argv.slice(3); + const toComplete = args[args.length - 1] || ''; + const previousArgs = args.slice(0, -1); + + // Root command completion + if (previousArgs.length === 0) { + console.log('start\tStart the application'); + console.log('build\tBuild the application'); + console.log('--help\tDisplay help'); + console.log('--version\tOutput the version number'); + console.log('-c\tSpecify config file'); + console.log('--config\tSpecify config file'); + console.log('-d\tEnable debugging'); + console.log('--debug\tEnable debugging'); + process.exit(0); + } + + // Subcommand completion + if (previousArgs[0] === 'start') { + console.log('-p\tPort to use'); + console.log('--port\tPort to use'); + console.log('--help\tDisplay help'); + + // Port value completion if --port is the last arg + if (previousArgs[previousArgs.length - 1] === '--port' || previousArgs[previousArgs.length - 1] === '-p') { + console.log('3000\tDefault port'); + console.log('8080\tAlternative port'); + } + process.exit(0); + } + + if (previousArgs[0] === 'build') { + console.log('-m\tBuild mode'); + console.log('--mode\tBuild mode'); + console.log('--help\tDisplay help'); + + // Mode value completion if --mode is the last arg + if (previousArgs[previousArgs.length - 1] === '--mode' || previousArgs[previousArgs.length - 1] === '-m') { + console.log('development\tDevelopment mode'); + console.log('production\tProduction mode'); + console.log('test\tTest mode'); + } + process.exit(0); + } + + process.exit(0); +} else { + // Parse CLI args + cli.parse(); +} \ No newline at end of file diff --git a/examples/demo-cli-cac/package.json b/examples/demo-cli-cac/package.json new file mode 100644 index 0000000..20301f6 --- /dev/null +++ b/examples/demo-cli-cac/package.json @@ -0,0 +1,15 @@ +{ + "name": "demo-cli-cac", + "version": "1.0.0", + "description": "Demo CLI using CAC for testing tab completions with pnpm", + "main": "demo-cli-cac.js", + "bin": { + "demo-cli-cac": "./demo-cli-cac.js" + }, + "scripts": { + "start": "node demo-cli-cac.js" + }, + "dependencies": { + "cac": "^6.7.14" + } +} \ No newline at end of file diff --git a/examples/demo-cli-cac/pnpm-lock.yaml b/examples/demo-cli-cac/pnpm-lock.yaml new file mode 100644 index 0000000..f59734a --- /dev/null +++ b/examples/demo-cli-cac/pnpm-lock.yaml @@ -0,0 +1,23 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + cac: + specifier: ^6.7.14 + version: 6.7.14 + +packages: + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + +snapshots: + + cac@6.7.14: {} diff --git a/examples/tiny-cli/package.json b/examples/tiny-cli/package.json new file mode 100644 index 0000000..d2fc5a2 --- /dev/null +++ b/examples/tiny-cli/package.json @@ -0,0 +1,9 @@ +{ + "name": "tiny-cli", + "version": "1.0.0", + "description": "Minimal CLI for testing tab completions with pnpm", + "main": "tiny-cli.js", + "bin": { + "tiny-cli": "./tiny-cli.js" + } +} \ No newline at end of file diff --git a/examples/tiny-cli/tiny-cli.js b/examples/tiny-cli/tiny-cli.js new file mode 100755 index 0000000..035988c --- /dev/null +++ b/examples/tiny-cli/tiny-cli.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +if (process.argv[2] === '__complete') { + console.log('hello\tSay hello'); + console.log('world\tSay world'); + process.exit(0); +} else { + const command = process.argv[2]; + if (command === 'hello') { + console.log('Hello!'); + } else if (command === 'world') { + console.log('World!'); + } else { + console.log('Usage: tiny-cli [hello|world]'); + } +} \ No newline at end of file diff --git a/package.json b/package.json index 57f2ffd..ea3ff32 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "vitest": "^2.1.3" }, "dependencies": { + "examples": "link:./examples", "mri": "^1.2.0" }, "exports": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78da1d2..d6acace 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + examples: + specifier: link:./examples + version: link:examples mri: specifier: ^1.2.0 version: 1.2.0 diff --git a/pnpm-script-extended/pnpm-shell-completion-extended.plugin.zsh b/pnpm-script-extended/pnpm-shell-completion-extended.plugin.zsh new file mode 100644 index 0000000..17968bb --- /dev/null +++ b/pnpm-script-extended/pnpm-shell-completion-extended.plugin.zsh @@ -0,0 +1,242 @@ +#compdef pnpm + +# ----------------------------------------------------------------------------- +# pnpm Shell Completion Extension +# Adds support for tab completion of CLIs executed through pnpm +# ----------------------------------------------------------------------------- + +# Set to 1 to enable debug logging, 0 to disable +PNPM_TAB_DEBUG=0 +DEBUG_FILE="/tmp/pnpm-completion-debug.log" + +# Debug logging function +_pnpm_tab_debug() { + if [[ $PNPM_TAB_DEBUG -eq 1 ]]; then + echo "$(date): $*" >> $DEBUG_FILE + fi +} + +_pnpm_tab_debug "Loading pnpm completion script with CLI tab completion support" + +# Run a CLI tool through pnpm directly +_pnpm_run_cli() { + local cli_name=$1 + shift + local cli_args=("$@") + + _pnpm_tab_debug "Running CLI via pnpm: $cli_name ${cli_args[*]}" + # Execute the command through pnpm directly + pnpm $cli_name "${cli_args[@]}" 2>/dev/null +} + +# Complete commands using pnpm execution +_pnpm_complete_cli() { + local cli_name=$1 + shift + local cli_args=("$@") + local output + + _pnpm_tab_debug "Completing $cli_name with args: ${cli_args[*]}" + + # Add __complete as the first argument + cli_args=("__complete" "${cli_args[@]}") + output=$(_pnpm_run_cli $cli_name "${cli_args[@]}" 2>&1) + _pnpm_tab_debug "Completion output from pnpm: $output" + + # Process the output for ZSH completion + if [[ -n "$output" ]]; then + # Convert the output into a format that ZSH can use for completion + local -a completions + + # Process each line of the output + while IFS=$'\n' read -r line; do + _pnpm_tab_debug "Processing line: $line" + # Check if the line has a tab character (description) + if [[ "$line" == *$'\t'* ]]; then + # Split the line at the tab character + local value=${line%%$'\t'*} + local description=${line#*$'\t'} + _pnpm_tab_debug "Adding completion with description: $value -> $description" + completions+=("${value}:${description}") + else + # No description + _pnpm_tab_debug "Adding completion without description: $line" + completions+=("$line") + fi + done <<< "$output" + + # Use _describe to present the completions + if [[ ${#completions[@]} -gt 0 ]]; then + _pnpm_tab_debug "Found ${#completions[@]} completions, calling _describe" + _describe "completions" completions + return 0 + fi + fi + + _pnpm_tab_debug "No completions found, falling back to file completion" + # If we couldn't get or process completions, fall back to file completion + _files + return 1 +} + +# Check if a CLI supports __complete by running it through pnpm +_pnpm_cli_has_completions() { + local cli_name=$1 + _pnpm_tab_debug "Checking if $cli_name has completions via pnpm" + + # Try to execute the __complete command through pnpm + if output=$(_pnpm_run_cli $cli_name "__complete" 2>/dev/null) && [[ -n "$output" ]]; then + _pnpm_tab_debug "$cli_name supports completions via pnpm: $output" + return 0 + fi + + _pnpm_tab_debug "$cli_name does not support completions via pnpm" + return 1 +} + +# Original pnpm-shell-completion logic +if command -v pnpm-shell-completion &> /dev/null; then + pnpm_comp_bin="$(which pnpm-shell-completion)" + _pnpm_tab_debug "Found pnpm-shell-completion at $pnpm_comp_bin" +else + pnpm_comp_bin="$(dirname $0)/pnpm-shell-completion" + _pnpm_tab_debug "Using relative pnpm-shell-completion at $pnpm_comp_bin" +fi + +_pnpm() { + typeset -A opt_args + _pnpm_tab_debug "Starting pnpm completion, words: ${words[*]}" + + _arguments \ + '(--filter -F)'{--filter,-F}'=:flag:->filter' \ + ':command:->scripts' \ + '*:: :->command_args' + + local target_pkg=${opt_args[--filter]:-$opt_args[-F]} + _pnpm_tab_debug "State: $state, target_pkg: $target_pkg" + + case $state in + filter) + if [[ -f ./pnpm-workspace.yaml ]] && [[ -x "$pnpm_comp_bin" ]]; then + _pnpm_tab_debug "Using pnpm-shell-completion for filter packages" + _values 'filter packages' $(FEATURE=filter $pnpm_comp_bin) + else + _pnpm_tab_debug "No workspace or pnpm-shell-completion for filter" + _message "package filter" + fi + ;; + scripts) + if [[ -x "$pnpm_comp_bin" ]]; then + _pnpm_tab_debug "Using pnpm-shell-completion for scripts" + _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) \ + add remove install update publish + else + _pnpm_tab_debug "Using basic pnpm commands (no pnpm-shell-completion)" + _values 'scripts' \ + 'add:Add a dependency' \ + 'install:Install dependencies' \ + 'remove:Remove a dependency' \ + 'update:Update dependencies' \ + 'publish:Publish package' \ + 'run:Run script' + fi + ;; + command_args) + local cmd=$words[1] + _pnpm_tab_debug "Completing command args for $cmd" + + # Get the pnpm command if available + local pnpm_cmd="" + if [[ -x "$pnpm_comp_bin" ]]; then + pnpm_cmd=$(FEATURE=pnpm_cmd $pnpm_comp_bin $words 2>/dev/null) + _pnpm_tab_debug "pnpm-shell-completion identified command: $pnpm_cmd" + else + pnpm_cmd=$cmd + _pnpm_tab_debug "Using first word as command: $pnpm_cmd" + fi + + # Check if this is a potential CLI command that might support tab completion + if [[ $cmd != "add" && $cmd != "remove" && $cmd != "install" && + $cmd != "update" && $cmd != "publish" && $cmd != "i" && + $cmd != "rm" && $cmd != "up" && $cmd != "run" ]]; then + + _pnpm_tab_debug "Checking if $cmd has tab completions via pnpm" + # Check if the command has tab completions through pnpm + if _pnpm_cli_has_completions $cmd; then + _pnpm_tab_debug "$cmd has tab completions via pnpm, passing args" + # Pass remaining arguments to the CLI's completion + local cli_args=("${words[@]:2}") + _pnpm_complete_cli $cmd "${cli_args[@]}" + return + fi + fi + + # Fall back to default pnpm completion behavior + _pnpm_tab_debug "Using standard completion for pnpm $pnpm_cmd" + case $pnpm_cmd in + add) + _arguments \ + '(--global -g)'{--global,-g}'[Install as a global package]' \ + '(--save-dev -D)'{--save-dev,-D}'[Save package to your `devDependencies`]' \ + '--save-peer[Save package to your `peerDependencies` and `devDependencies`]' + ;; + install|i) + _arguments \ + '(--dev -D)'{--dev,-D}'[Only `devDependencies` are installed regardless of the `NODE_ENV`]' \ + '--fix-lockfile[Fix broken lockfile entries automatically]' \ + '--force[Force reinstall dependencies]' \ + "--ignore-scripts[Don't run lifecycle scripts]" \ + '--lockfile-only[Dependencies are not downloaded. Only `pnpm-lock.yaml` is updated]' \ + '--no-optional[`optionalDependencies` are not installed]' \ + '--offline[Trigger an error if any required dependencies are not available in local store]' \ + '--prefer-offline[Skip staleness checks for cached data, but request missing data from the server]' \ + '(--prod -P)'{--prod,-P}"[Packages in \`devDependencies\` won't be installed]" + ;; + remove|rm|why) + if [[ -f ./package.json && -x "$pnpm_comp_bin" ]]; then + _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) + else + _message "package name" + fi + ;; + update|upgrade|up) + _arguments \ + '(--dev -D)'{--dev,-D}'[Update packages only in "devDependencies"]' \ + '(--global -g)'{--global,-g}'[Update globally installed packages]' \ + '(--interactive -i)'{--interactive,-i}'[Show outdated dependencies and select which ones to update]' \ + '(--latest -L)'{--latest,-L}'[Ignore version ranges in package.json]' \ + "--no-optional[Don't update packages in \`optionalDependencies\`]" \ + '(--prod -P)'{--prod,-P}'[Update packages only in "dependencies" and "optionalDependencies"]' \ + '(--recursive -r)'{--recursive,-r}'[Update in every package found in subdirectories or every workspace package]' + if [[ -f ./package.json && -x "$pnpm_comp_bin" ]]; then + _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) + fi + ;; + publish) + _arguments \ + '--access=[Tells the registry whether this package should be published as public or restricted]: :(public restricted)' \ + '--dry-run[Does everything a publish would do except actually publishing to the registry]' \ + '--force[Packages are proceeded to be published even if their current version is already in the registry]' \ + '--ignore-scripts[Ignores any publish related lifecycle scripts (prepublishOnly, postpublish, and the like)]' \ + "--no-git-checks[Don't check if current branch is your publish branch, clean, and up to date]" \ + '--otp[Specify a one-time password]' \ + '--publish-branch[Sets branch name to publish]' \ + '(--recursive -r)'{--recursive,-r}'[Publish all packages from the workspace]' \ + '--tag=[Registers the published package with the given tag]' + ;; + run) + if [[ -f ./package.json && -x "$pnpm_comp_bin" ]]; then + _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) + else + _message "script name" + fi + ;; + *) + _files + esac + esac +} + +compdef _pnpm pnpm + +_pnpm_tab_debug "pnpm extended completion script loaded" \ No newline at end of file From 07acf0177fb378811c2c06ad11a610a203902b46 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Tue, 29 Apr 2025 22:25:12 +0330 Subject: [PATCH 04/10] prettier --- examples/demo-cli-cac/demo-cli-cac.js | 16 +++++++++++----- examples/demo-cli-cac/package.json | 2 +- examples/tiny-cli/package.json | 2 +- examples/tiny-cli/tiny-cli.js | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/examples/demo-cli-cac/demo-cli-cac.js b/examples/demo-cli-cac/demo-cli-cac.js index e36e400..5190d1e 100755 --- a/examples/demo-cli-cac/demo-cli-cac.js +++ b/examples/demo-cli-cac/demo-cli-cac.js @@ -53,9 +53,12 @@ if (process.argv[2] === '__complete') { console.log('-p\tPort to use'); console.log('--port\tPort to use'); console.log('--help\tDisplay help'); - + // Port value completion if --port is the last arg - if (previousArgs[previousArgs.length - 1] === '--port' || previousArgs[previousArgs.length - 1] === '-p') { + if ( + previousArgs[previousArgs.length - 1] === '--port' || + previousArgs[previousArgs.length - 1] === '-p' + ) { console.log('3000\tDefault port'); console.log('8080\tAlternative port'); } @@ -66,9 +69,12 @@ if (process.argv[2] === '__complete') { console.log('-m\tBuild mode'); console.log('--mode\tBuild mode'); console.log('--help\tDisplay help'); - + // Mode value completion if --mode is the last arg - if (previousArgs[previousArgs.length - 1] === '--mode' || previousArgs[previousArgs.length - 1] === '-m') { + if ( + previousArgs[previousArgs.length - 1] === '--mode' || + previousArgs[previousArgs.length - 1] === '-m' + ) { console.log('development\tDevelopment mode'); console.log('production\tProduction mode'); console.log('test\tTest mode'); @@ -80,4 +86,4 @@ if (process.argv[2] === '__complete') { } else { // Parse CLI args cli.parse(); -} \ No newline at end of file +} diff --git a/examples/demo-cli-cac/package.json b/examples/demo-cli-cac/package.json index 20301f6..f2e279b 100644 --- a/examples/demo-cli-cac/package.json +++ b/examples/demo-cli-cac/package.json @@ -12,4 +12,4 @@ "dependencies": { "cac": "^6.7.14" } -} \ No newline at end of file +} diff --git a/examples/tiny-cli/package.json b/examples/tiny-cli/package.json index d2fc5a2..7ee7f80 100644 --- a/examples/tiny-cli/package.json +++ b/examples/tiny-cli/package.json @@ -6,4 +6,4 @@ "bin": { "tiny-cli": "./tiny-cli.js" } -} \ No newline at end of file +} diff --git a/examples/tiny-cli/tiny-cli.js b/examples/tiny-cli/tiny-cli.js index 035988c..4b6fe1d 100755 --- a/examples/tiny-cli/tiny-cli.js +++ b/examples/tiny-cli/tiny-cli.js @@ -13,4 +13,4 @@ if (process.argv[2] === '__complete') { } else { console.log('Usage: tiny-cli [hello|world]'); } -} \ No newline at end of file +} From e9352b4348683c15d0659822c24a343508fb17a4 Mon Sep 17 00:00:00 2001 From: AmirHossein Sakhravi Date: Sat, 26 Jul 2025 23:26:36 +0330 Subject: [PATCH 05/10] feat: autocomplete clis executing through a package manager (#26) * init * prettier * update * cli completions * pnpm install * fix: handle complete command manually * fix: completion-handler __complete => complete * fix: remove examples form package.json * fix: generateCompletionScript function * move the package manager completion logic directly into the parse method * prettier --- bin/cli.ts | 86 ++++++++++++++++++ bin/completion-handlers.ts | 123 ++++++++++++++++++++++++++ examples/demo-cli-cac/demo-cli-cac.js | 85 +++++++----------- examples/demo-cli-cac/package.json | 1 + package.json | 10 ++- pnpm-lock.yaml | 3 - src/index.ts | 113 ++++++++++++++++++++++- tsdown.config.ts | 8 +- 8 files changed, 368 insertions(+), 61 deletions(-) create mode 100644 bin/cli.ts create mode 100644 bin/completion-handlers.ts diff --git a/bin/cli.ts b/bin/cli.ts new file mode 100644 index 0000000..9dca369 --- /dev/null +++ b/bin/cli.ts @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +import cac from 'cac'; +import { script, Completion } from '../src/index.js'; +import tab from '../src/cac.js'; + +import { setupCompletionForPackageManager } from './completion-handlers'; + +const packageManagers = ['npm', 'pnpm', 'yarn', 'bun']; +const shells = ['zsh', 'bash', 'fish', 'powershell']; + +async function main() { + const cli = cac('tab'); + + const args = process.argv.slice(2); + if (args.length >= 2 && args[1] === 'complete') { + const packageManager = args[0]; + + if (!packageManagers.includes(packageManager)) { + console.error(`Error: Unsupported package manager "${packageManager}"`); + console.error( + `Supported package managers: ${packageManagers.join(', ')}` + ); + process.exit(1); + } + + const dashIndex = process.argv.indexOf('--'); + if (dashIndex !== -1) { + const completion = new Completion(); + setupCompletionForPackageManager(packageManager, completion); + const toComplete = process.argv.slice(dashIndex + 1); + await completion.parse(toComplete); + process.exit(0); + } else { + console.error(`Error: Expected '--' followed by command to complete`); + console.error( + `Example: ${packageManager} exec @bombsh/tab ${packageManager} complete -- command-to-complete` + ); + process.exit(1); + } + } + + cli + .command( + ' ', + 'Generate shell completion script for a package manager' + ) + .action(async (packageManager, shell) => { + if (!packageManagers.includes(packageManager)) { + console.error(`Error: Unsupported package manager "${packageManager}"`); + console.error( + `Supported package managers: ${packageManagers.join(', ')}` + ); + process.exit(1); + } + + if (!shells.includes(shell)) { + console.error(`Error: Unsupported shell "${shell}"`); + console.error(`Supported shells: ${shells.join(', ')}`); + process.exit(1); + } + + generateCompletionScript(packageManager, shell); + }); + + const completion = tab(cli); + + cli.parse(); +} + +// function generateCompletionScript(packageManager: string, shell: string) { +// const name = packageManager; +// const executable = process.env.npm_execpath +// ? `${packageManager} exec @bombsh/tab ${packageManager}` +// : `node ${process.argv[1]} ${packageManager}`; +// script(shell as any, name, executable); +// } + +function generateCompletionScript(packageManager: string, shell: string) { + const name = packageManager; + // this always points at the actual file on disk (TESTING) + const executable = `node ${process.argv[1]} ${packageManager}`; + script(shell as any, name, executable); +} + +main().catch(console.error); diff --git a/bin/completion-handlers.ts b/bin/completion-handlers.ts new file mode 100644 index 0000000..e51f867 --- /dev/null +++ b/bin/completion-handlers.ts @@ -0,0 +1,123 @@ +import { Completion } from '../src/index.js'; +import { execSync } from 'child_process'; + +const DEBUG = false; // for debugging purposes + +function debugLog(...args: any[]) { + if (DEBUG) { + console.error('[DEBUG]', ...args); + } +} + +async function checkCliHasCompletions( + cliName: string, + packageManager: string +): Promise { + try { + debugLog(`Checking if ${cliName} has completions via ${packageManager}`); + const command = `${packageManager} ${cliName} complete --`; + const result = execSync(command, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 1000, // AMIR: we still havin issues with this, it still hangs if a cli doesn't have completions. longer timeout needed for shell completion system (shell → node → package manager → cli) + }); + const hasCompletions = !!result.trim(); + debugLog(`${cliName} supports completions: ${hasCompletions}`); + return hasCompletions; + } catch (error) { + debugLog(`Error checking completions for ${cliName}:`, error); + return false; + } +} + +async function getCliCompletions( + cliName: string, + packageManager: string, + args: string[] +): Promise { + try { + const completeArgs = args.map((arg) => + arg.includes(' ') ? `"${arg}"` : arg + ); + const completeCommand = `${packageManager} ${cliName} complete -- ${completeArgs.join(' ')}`; + debugLog(`Getting completions with command: ${completeCommand}`); + + const result = execSync(completeCommand, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 1000, // same: longer timeout needed for shell completion system (shell → node → package manager → cli) + }); + + const completions = result.trim().split('\n').filter(Boolean); + debugLog(`Got ${completions.length} completions from ${cliName}`); + return completions; + } catch (error) { + debugLog(`Error getting completions from ${cliName}:`, error); + return []; + } +} + +export function setupCompletionForPackageManager( + packageManager: string, + completion: Completion +) { + if (packageManager === 'pnpm') { + setupPnpmCompletions(completion); + } else if (packageManager === 'npm') { + setupNpmCompletions(completion); + } else if (packageManager === 'yarn') { + setupYarnCompletions(completion); + } else if (packageManager === 'bun') { + setupBunCompletions(completion); + } + + completion.setPackageManager(packageManager); +} + +export function setupPnpmCompletions(completion: Completion) { + completion.addCommand('add', 'Install a package', [], async () => []); + completion.addCommand('remove', 'Remove a package', [], async () => []); + completion.addCommand( + 'install', + 'Install all dependencies', + [], + async () => [] + ); + completion.addCommand('update', 'Update packages', [], async () => []); + completion.addCommand('exec', 'Execute a command', [], async () => []); + completion.addCommand('run', 'Run a script', [], async () => []); + completion.addCommand('publish', 'Publish package', [], async () => []); + completion.addCommand('test', 'Run tests', [], async () => []); + completion.addCommand('build', 'Build project', [], async () => []); +} + +export function setupNpmCompletions(completion: Completion) { + completion.addCommand('install', 'Install a package', [], async () => []); + completion.addCommand('uninstall', 'Uninstall a package', [], async () => []); + completion.addCommand('run', 'Run a script', [], async () => []); + completion.addCommand('test', 'Run tests', [], async () => []); + completion.addCommand('publish', 'Publish package', [], async () => []); + completion.addCommand('update', 'Update packages', [], async () => []); + completion.addCommand('start', 'Start the application', [], async () => []); + completion.addCommand('build', 'Build project', [], async () => []); +} + +export function setupYarnCompletions(completion: Completion) { + completion.addCommand('add', 'Add a package', [], async () => []); + completion.addCommand('remove', 'Remove a package', [], async () => []); + completion.addCommand('run', 'Run a script', [], async () => []); + completion.addCommand('test', 'Run tests', [], async () => []); + completion.addCommand('publish', 'Publish package', [], async () => []); + completion.addCommand('install', 'Install dependencies', [], async () => []); + completion.addCommand('build', 'Build project', [], async () => []); +} + +export function setupBunCompletions(completion: Completion) { + completion.addCommand('add', 'Add a package', [], async () => []); + completion.addCommand('remove', 'Remove a package', [], async () => []); + completion.addCommand('run', 'Run a script', [], async () => []); + completion.addCommand('test', 'Run tests', [], async () => []); + completion.addCommand('install', 'Install dependencies', [], async () => []); + completion.addCommand('update', 'Update packages', [], async () => []); + completion.addCommand('build', 'Build project', [], async () => []); +} diff --git a/examples/demo-cli-cac/demo-cli-cac.js b/examples/demo-cli-cac/demo-cli-cac.js index 5190d1e..c25560f 100755 --- a/examples/demo-cli-cac/demo-cli-cac.js +++ b/examples/demo-cli-cac/demo-cli-cac.js @@ -1,6 +1,8 @@ #!/usr/bin/env node -const cac = require('cac'); +import cac from 'cac'; +import tab from '../../dist/src/cac.js'; + const cli = cac('demo-cli-cac'); // Define version and help @@ -29,61 +31,40 @@ cli console.log('Options:', options); }); -// Manual implementation of completion for CAC -if (process.argv[2] === '__complete') { - const args = process.argv.slice(3); - const toComplete = args[args.length - 1] || ''; - const previousArgs = args.slice(0, -1); - - // Root command completion - if (previousArgs.length === 0) { - console.log('start\tStart the application'); - console.log('build\tBuild the application'); - console.log('--help\tDisplay help'); - console.log('--version\tOutput the version number'); - console.log('-c\tSpecify config file'); - console.log('--config\tSpecify config file'); - console.log('-d\tEnable debugging'); - console.log('--debug\tEnable debugging'); - process.exit(0); - } - - // Subcommand completion - if (previousArgs[0] === 'start') { - console.log('-p\tPort to use'); - console.log('--port\tPort to use'); - console.log('--help\tDisplay help'); +// Set up completion using the cac adapter +const completion = await tab(cli); - // Port value completion if --port is the last arg - if ( - previousArgs[previousArgs.length - 1] === '--port' || - previousArgs[previousArgs.length - 1] === '-p' - ) { - console.log('3000\tDefault port'); - console.log('8080\tAlternative port'); +// custom config for options +for (const command of completion.commands.values()) { + for (const [optionName, config] of command.options.entries()) { + if (optionName === '--port') { + config.handler = () => { + return [ + { value: '3000', description: 'Default port' }, + { value: '8080', description: 'Alternative port' }, + ]; + }; } - process.exit(0); - } - if (previousArgs[0] === 'build') { - console.log('-m\tBuild mode'); - console.log('--mode\tBuild mode'); - console.log('--help\tDisplay help'); + if (optionName === '--mode') { + config.handler = () => { + return [ + { value: 'development', description: 'Development mode' }, + { value: 'production', description: 'Production mode' }, + { value: 'test', description: 'Test mode' }, + ]; + }; + } - // Mode value completion if --mode is the last arg - if ( - previousArgs[previousArgs.length - 1] === '--mode' || - previousArgs[previousArgs.length - 1] === '-m' - ) { - console.log('development\tDevelopment mode'); - console.log('production\tProduction mode'); - console.log('test\tTest mode'); + if (optionName === '--config') { + config.handler = () => { + return [ + { value: 'config.json', description: 'JSON config file' }, + { value: 'config.js', description: 'JavaScript config file' }, + ]; + }; } - process.exit(0); } - - process.exit(0); -} else { - // Parse CLI args - cli.parse(); } + +cli.parse(); diff --git a/examples/demo-cli-cac/package.json b/examples/demo-cli-cac/package.json index f2e279b..9ccc20d 100644 --- a/examples/demo-cli-cac/package.json +++ b/examples/demo-cli-cac/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "Demo CLI using CAC for testing tab completions with pnpm", "main": "demo-cli-cac.js", + "type": "module", "bin": { "demo-cli-cac": "./demo-cli-cac.js" }, diff --git a/package.json b/package.json index ea3ff32..02259f5 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { - "name": "tab", + "name": "@bombsh/tab", "version": "0.0.0", - "description": "", "main": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", + "bin": { + "tab": "./dist/bin/cli.js" + }, "scripts": { "test": "vitest", "type-check": "tsc --noEmit", @@ -12,7 +14,8 @@ "format:check": "prettier --check .", "build": "tsdown", "prepare": "pnpm build", - "lint": "eslint src \"./*.ts\"" + "lint": "eslint src \"./*.ts\"", + "test-cli": "tsx bin/cli.ts" }, "files": [ "dist" @@ -34,7 +37,6 @@ "vitest": "^2.1.3" }, "dependencies": { - "examples": "link:./examples", "mri": "^1.2.0" }, "exports": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6acace..78da1d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - examples: - specifier: link:./examples - version: link:examples mri: specifier: ^1.2.0 version: 1.2.0 diff --git a/src/index.ts b/src/index.ts index 5e0286a..87d9eba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,63 @@ import * as zsh from './zsh'; import * as bash from './bash'; import * as fish from './fish'; import * as powershell from './powershell'; +import { execSync } from 'child_process'; + +const DEBUG = false; + +function debugLog(...args: any[]) { + if (DEBUG) { + console.error('[DEBUG]', ...args); + } +} + +async function checkCliHasCompletions( + cliName: string, + packageManager: string +): Promise { + try { + debugLog(`Checking if ${cliName} has completions via ${packageManager}`); + const command = `${packageManager} ${cliName} complete --`; + const result = execSync(command, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 1000, + }); + const hasCompletions = !!result.trim(); + debugLog(`${cliName} supports completions: ${hasCompletions}`); + return hasCompletions; + } catch (error) { + debugLog(`Error checking completions for ${cliName}:`, error); + return false; + } +} + +async function getCliCompletions( + cliName: string, + packageManager: string, + args: string[] +): Promise { + try { + const completeArgs = args.map((arg) => + arg.includes(' ') ? `"${arg}"` : arg + ); + const completeCommand = `${packageManager} ${cliName} complete -- ${completeArgs.join(' ')}`; + debugLog(`Getting completions with command: ${completeCommand}`); + + const result = execSync(completeCommand, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 1000, + }); + + const completions = result.trim().split('\n').filter(Boolean); + debugLog(`Got ${completions.length} completions from ${cliName}`); + return completions; + } catch (error) { + debugLog(`Error getting completions from ${cliName}:`, error); + return []; + } +} // ShellCompRequestCmd is the name of the hidden command that is used to request // completion results from the program. It is used by the shell completion scripts. @@ -62,10 +119,15 @@ export type Positional = { }; type Item = { - description: string; + description?: string; value: string; }; +type CompletionResult = { + items: Item[]; + suppressDefault: boolean; +}; + export type Handler = ( previousArgs: string[], toComplete: string, @@ -91,6 +153,12 @@ export class Completion { commands = new Map(); completions: Item[] = []; directive = ShellCompDirective.ShellCompDirectiveDefault; + result: CompletionResult = { items: [], suppressDefault: false }; + private packageManager: string | null = null; + + setPackageManager(packageManager: string) { + this.packageManager = packageManager; + } // vite [...files] // args: [false, false, true], only the last argument can be variadic @@ -171,6 +239,49 @@ export class Completion { } async parse(args: string[]) { + this.result = { items: [], suppressDefault: false }; + + // Handle package manager completions first + if (this.packageManager && args.length >= 1) { + const potentialCliName = args[0]; + const knownCommands = [...this.commands.keys()]; + + if (!knownCommands.includes(potentialCliName)) { + const hasCompletions = await checkCliHasCompletions( + potentialCliName, + this.packageManager + ); + + if (hasCompletions) { + const cliArgs = args.slice(1); + const suggestions = await getCliCompletions( + potentialCliName, + this.packageManager, + cliArgs + ); + + if (suggestions.length > 0) { + this.result.suppressDefault = true; + + for (const suggestion of suggestions) { + if (suggestion.startsWith(':')) continue; + + if (suggestion.includes('\t')) { + const [value, description] = suggestion.split('\t'); + this.result.items.push({ value, description }); + } else { + this.result.items.push({ value: suggestion }); + } + } + + this.completions = this.result.items; + this.complete(''); + return; + } + } + } + } + const endsWithSpace = args[args.length - 1] === ''; if (endsWithSpace) { diff --git a/tsdown.config.ts b/tsdown.config.ts index c4141b9..fb7a5ab 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,13 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['src/index.ts', 'src/citty.ts', 'src/cac.ts', 'src/commander.ts'], + entry: [ + 'src/index.ts', + 'src/citty.ts', + 'src/cac.ts', + 'src/commander.ts', + 'bin/cli.ts', + ], format: ['esm'], dts: true, clean: true, From fe22d1e9378b7fc52306adcc77a05e0c6c7c56fe Mon Sep 17 00:00:00 2001 From: Mohammad Bagher Abiyat <37929992+Aslemammad@users.noreply.github.com> Date: Tue, 29 Jul 2025 07:51:03 +0330 Subject: [PATCH 06/10] tab -> t wip --- README.2.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 1 - src/t.ts | 55 ++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 README.2.md create mode 100644 src/t.ts diff --git a/README.2.md b/README.2.md new file mode 100644 index 0000000..b2ad7f4 --- /dev/null +++ b/README.2.md @@ -0,0 +1,92 @@ +> a video showcasing how pnpm autocompletions works on a test cli command like `my-cli` + +# tab + +> instant feedback when hitting [TAB] in your cli tool + +as cli tooling authors, if we can spare our users a second or two by not checking the documentation or writing the `-h` option, we're doing them a huge favor. the unconscious loves hitting the [TAB] key. it always expects feedback. so it feels dissappointing when hitting that key in the terminal but then nothing happens. that frustration is apparent across the whole javascript cli tooling ecosystem. + +autocompletions are the solution to not break the user's flow. the issue is they're not simple to add. `zsh` expects them in a way, and `bash` in another way. then where do we provide them so the shell environment parses them? too many headaches to ease the user's experience. whether it's worth it or not is out of the question. because tab is the solution to this complexity. + +`my-cli.ts`: +```typescript +import t from '@bombsh/tab' + +t.name('my-cli') + +t.command('start', 'start the development server') + +if (process.argv[2] === 'complete') { + const [shell, ...args] = process.argv.slice(3) + if (shell === '--') { + t.parse(args) + } else { + t.setup(shell, x) + } +} +``` + +this `my-cli.ts` would be equipped with all the tools required to provide autocompletions. + +```bash +node my-cli.ts complete -- "st" +``` +``` +start start the development server +:0 +``` + +this output was generated by the `t.parse` method to autocomplete "st" to "start". + +obviously, the user won't be running that command directly in their terminal. they'd be running something like this. + +```bash +source <(node my-cli.ts complete zsh) +``` + +now whenever the shell sees `my-cli`, it would bring the autocompletions we wrote for this cli tool. the `node my-cli.ts complete zsh` part would output the zsh script that loads the autocompletions provided by `t.parse` which then would be executed using `source`. + +the autocompletions are only lived through the current session. to set them up across all of terminal sessions, the autocompletion script should be injected in the `.zshrc` file. + +```bash +my-cli complete zsh > ~/completion-for-my-cli.zsh && echo 'source ~/completion-for-my-cli.zsh' >> ~/.zshrc +``` + +this is an example of autocompletions on a global cli command that is usually installed using the `-g` flag (e.g. `npm add -g my-cli`) which is available across the computer. + +--- + +while working on tab, we came to the realization that most javascript clis are not global cli commands but rather, per-project dependencies. + +for instance, vite won't be installed globally and instead it'd be always installed on a project. here's an example usage: + +```bash +pnpm vite -h +``` + +so in this case, a computer might have hundreds of vite instances each installed separately and potentially from different versions on different projects. + +we were looking for a fluid strategy that would be able to load the autocompletions from each of these dependencies on a per-project basis. + +and that made us develop our own autocompletion abstraction over npm, pnpm and yarn. this would help tab identify which binaries are avaialble in a project and which of these binaries provide autocompletions. so the user would not have to `source` anything or inject any script in their `.zshrc`. + +they'd only have to run this command once and inject it in their shell config. + +```bash +echo 'eval "$(npx --prefer-offline @bombsh/tab pnpm zsh)"' >> ~/.zshrc +``` + +--- + +```typescript +import t from '@bombsh/tab' + +t.option('help', 'list available commands') // Command (Root) + +t.command('start', 'start the development server') // Command ('start') + .option('port', 'specify the port number') // Command ('port') + +t.parse(process.argv.slice(3)) + +t.setup(process.argv[2], x) +``` \ No newline at end of file diff --git a/README.md b/README.md index e998dff..a412145 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -[![tweet-1827921103093932490](https://github.com/user-attachments/assets/21521787-7936-44be-8d3c-8214cd2fcee9)](https://x.com/karpathy/status/1827921103093932490) # tab diff --git a/src/t.ts b/src/t.ts new file mode 100644 index 0000000..35e8b2b --- /dev/null +++ b/src/t.ts @@ -0,0 +1,55 @@ +class Option { + name: string + + constructor(name: string) { + this.name = name + } +} + +class Command { + root: boolean + + #name: string = '' + + options = new Map + commands = new Map + + constructor(root: boolean) { + this.root = root + } + + name(name: string) { + this.#name = name + } + + option() { + + } + + command() { + + } + + parse() { + + } + + setup() { + + } +} + +const t = new Command(true) + +export default t + +// import t from '@bombsh/tab' + +t.option('help', 'list available commands') // Command (Root) + +t.command('start', 'start the development server') // Command ('start') + .option('port', 'specify the port number') // Command ('port') + +t.parse(process.argv.slice(3)) + +t.setup(process.argv[2], x) \ No newline at end of file From 3af114fb799514df103f63917cd822fe0eb670f7 Mon Sep 17 00:00:00 2001 From: Mohammad Bagher Abiyat <37929992+Aslemammad@users.noreply.github.com> Date: Wed, 30 Jul 2025 08:59:31 +0330 Subject: [PATCH 07/10] before some ai changes --- src/index.ts | 1 + src/t.ts | 129 ++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/src/index.ts b/src/index.ts index 87d9eba..aba17ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -241,6 +241,7 @@ export class Completion { async parse(args: string[]) { this.result = { items: [], suppressDefault: false }; + // TODO: i did not notice this, this should not be handled here at all. package manager completions are something on top of this. just like any other completion system that is going to be built on top of tab. // Handle package manager completions first if (this.packageManager && args.length >= 1) { const potentialCliName = args[0]; diff --git a/src/t.ts b/src/t.ts index 35e8b2b..1881c67 100644 --- a/src/t.ts +++ b/src/t.ts @@ -1,45 +1,116 @@ -class Option { - name: string +type OptionsMap = Map + +type Complete = (value: string, description: string) => void + +type OptionCompleter = (this: Option, complete: Complete, options: OptionsMap) => void - constructor(name: string) { - this.name = name +class Option { + value: string + description: string + command: Command + completer?: OptionCompleter + shortValue?: string + // TODO: handle boolean options + + constructor(command: Command, value: string, description: string, completer?: OptionCompleter, shortValue?: string) { + this.command = command + this.value = value + this.description = description + this.completer = completer + this.shortValue = shortValue } } -class Command { - root: boolean +type CommandCompleter = (this: Command, complete: Complete, options: OptionsMap) => void - #name: string = '' +class Command { + value: string + description: string options = new Map - commands = new Map + + + completer?: CommandCompleter - constructor(root: boolean) { - this.root = root + constructor(value: string, description: string, completer?: CommandCompleter) { + this.value = value + this.description = description + this.completer = completer } - name(name: string) { - this.#name = name + option(value: string, description: string, completer?: OptionCompleter, shortValue?: string) { + this.options.set(value, new Option(this, value, description, completer, shortValue)) + return this } - option() { - } - command() { +} + +import * as zsh from './zsh'; +import * as bash from './bash'; +import * as fish from './fish'; +import * as powershell from './powershell'; +import assert from 'node:assert' - } +class RootCommand extends Command { + completer = undefined + commands = new Map - parse() { + constructor() { + super('', '') + } + command(value: string, description: string, completer?: CommandCompleter) { + const c = new Command(value, description, completer) + this.commands.set(value, c) + return c } - setup() { + parse(args: string[]) { + const endsWithSpace = args[args.length - 1] === '' + + if (endsWithSpace) { + args.pop() + } + const toComplete = args[args.length - 1] || '' + const previousArgs = args.slice(0, -1) + + for (const arg of previousArgs) { + const option = this.options.get(arg) + } + } + + setup(name: string, executable: string, shell: string) { + assert(shell === 'zsh' || shell === 'bash' || shell === 'fish' || shell === 'powershell', 'Unsupported shell') + + switch (shell) { + case 'zsh': { + const script = zsh.generate(name, executable); + console.log(script); + break; + } + case 'bash': { + const script = bash.generate(name, executable); + console.log(script); + break; + } + case 'fish': { + const script = fish.generate(name, executable); + console.log(script); + break; + } + case 'powershell': { + const script = powershell.generate(name, executable); + console.log(script); + break; + } + } } } -const t = new Command(true) +const t = new RootCommand() export default t @@ -47,9 +118,21 @@ export default t t.option('help', 'list available commands') // Command (Root) -t.command('start', 'start the development server') // Command ('start') - .option('port', 'specify the port number') // Command ('port') +t + .command('start', 'start the development server') // Command ('start') + .option('port', 'specify the port number', function (complete, options) { + complete('3000', 'general port') + complete('3001', 'another general port') + complete('3002', 'another general port') + }) // Command ('port') + +t + .command('start dev', 'start the development server') // Command ('start') + + +const x = 'npx my-cli' -t.parse(process.argv.slice(3)) +// t.setup('my-cli', x, process.argv[2]) +console.log(process.argv) -t.setup(process.argv[2], x) \ No newline at end of file +t.parse(process.argv.slice(3)) \ No newline at end of file From 00e485ccb0a82e1a0f57ba50955b29957a65aa3d Mon Sep 17 00:00:00 2001 From: Mohammad Bagher Abiyat <37929992+Aslemammad@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:25:17 +0330 Subject: [PATCH 08/10] huge wip --- bin/cli.ts | 4 +- bin/completion-handlers.ts | 3 + examples/demo.cac.ts | 118 ++--- examples/demo.citty.ts | 103 ++-- examples/demo.commander.ts | 13 +- examples/demo.t.ts | 92 ++++ package.json | 2 +- src/cac.ts | 77 ++- src/citty.ts | 133 +++-- src/fig.ts | 1 + src/index.ts | 12 +- src/shared.ts | 15 +- src/t.ts | 370 +++++++++++--- tests/__snapshots__/cli.test.ts.snap | 700 ++++++++++++++++++++++++++- tests/cli.test.ts | 194 +++++++- 15 files changed, 1583 insertions(+), 254 deletions(-) create mode 100644 examples/demo.t.ts diff --git a/bin/cli.ts b/bin/cli.ts index 9dca369..d204be5 100644 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -12,6 +12,7 @@ const shells = ['zsh', 'bash', 'fish', 'powershell']; async function main() { const cli = cac('tab'); + // TODO: aren't these conditions are already handled by cac? const args = process.argv.slice(2); if (args.length >= 2 && args[1] === 'complete') { const packageManager = args[0]; @@ -26,6 +27,7 @@ async function main() { const dashIndex = process.argv.indexOf('--'); if (dashIndex !== -1) { + // TOOD: there's no Completion anymore const completion = new Completion(); setupCompletionForPackageManager(packageManager, completion); const toComplete = process.argv.slice(dashIndex + 1); @@ -63,7 +65,7 @@ async function main() { generateCompletionScript(packageManager, shell); }); - const completion = tab(cli); + tab(cli); cli.parse(); } diff --git a/bin/completion-handlers.ts b/bin/completion-handlers.ts index e51f867..5c95a2e 100644 --- a/bin/completion-handlers.ts +++ b/bin/completion-handlers.ts @@ -1,3 +1,4 @@ +// TODO: i do not see any completion functionality in this file. nothing is being provided for the defined commands of these package managers. this is a blocker for release. every each of them should be handled. import { Completion } from '../src/index.js'; import { execSync } from 'child_process'; @@ -71,6 +72,7 @@ export function setupCompletionForPackageManager( setupBunCompletions(completion); } + // TODO: the core functionality of tab should have nothing related to package managers. even though completion is not there anymore, but this is something to consider. completion.setPackageManager(packageManager); } @@ -83,6 +85,7 @@ export function setupPnpmCompletions(completion: Completion) { [], async () => [] ); + // TODO: empty functions should be replaced with noop functions rather than creating that many empty functions completion.addCommand('update', 'Update packages', [], async () => []); completion.addCommand('exec', 'Execute a command', [], async () => []); completion.addCommand('run', 'Run a script', [], async () => []); diff --git a/examples/demo.cac.ts b/examples/demo.cac.ts index 0db2069..5406d30 100644 --- a/examples/demo.cac.ts +++ b/examples/demo.cac.ts @@ -1,5 +1,6 @@ import cac from 'cac'; import tab from '../src/cac'; +import type { Command, Option, OptionsMap } from '../src/t'; const cli = cac('vite'); @@ -8,6 +9,8 @@ cli .option('-m, --mode ', `Set env mode`) .option('-l, --logLevel ', `info | warn | error | silent`); + + cli .command('dev', 'Start dev server') .option('-H, --host [host]', `Specify hostname`) @@ -22,64 +25,65 @@ cli cli.command('dev build', 'Build project').action((options) => {}); -cli.command('lint [...files]', 'Lint project').action((files, options) => {}); +cli.command('copy ', 'Copy files').action((source, destination, options) => {}); -const completion = await tab(cli); - -for (const command of completion.commands.values()) { - if (command.name === 'lint') { - command.handler = () => { - return [ - { value: 'main.ts', description: 'Main file' }, - { value: 'index.ts', description: 'Index file' }, - ]; - }; - } +cli.command('lint [...files]', 'Lint project').action((files, options) => {}); - for (const [o, config] of command.options.entries()) { - if (o === '--port') { - config.handler = () => { - return [ - { value: '3000', description: 'Development server port' }, - { value: '8080', description: 'Alternative port' }, - ]; - }; - } - if (o === '--host') { - config.handler = () => { - return [ - { value: 'localhost', description: 'Localhost' }, - { value: '0.0.0.0', description: 'All interfaces' }, - ]; - }; - } - if (o === '--config') { - config.handler = () => { - return [ - { value: 'vite.config.ts', description: 'Vite config file' }, - { value: 'vite.config.js', description: 'Vite config file' }, - ]; - }; - } - if (o === '--mode') { - config.handler = () => { - return [ - { value: 'development', description: 'Development mode' }, - { value: 'production', description: 'Production mode' }, - ]; - }; - } - if (o === '--logLevel') { - config.handler = () => { - return [ - { value: 'info', description: 'Info level' }, - { value: 'warn', description: 'Warn level' }, - { value: 'error', description: 'Error level' }, - { value: 'silent', description: 'Silent level' }, - ]; - }; - } - } -} +// Note: With the new t.ts API, handlers are configured through the completionConfig parameter +// rather than by modifying the returned completion object directly +await tab(cli, { + subCommands: { + copy: { + args: { + source: function(complete) { + complete('src/', 'Source directory'); + complete('dist/', 'Distribution directory'); + complete('public/', 'Public assets'); + }, + destination: function(complete) { + complete('build/', 'Build output'); + complete('release/', 'Release directory'); + complete('backup/', 'Backup location'); + }, + }, + }, + lint: { + args: { + files: function(complete) { + complete('main.ts', 'Main file'); + complete('index.ts', 'Index file'); + }, + }, + }, + dev: { + options: { + port: function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap) { + complete('3000', 'Development server port'); + complete('8080', 'Alternative port'); + }, + host: function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap) { + complete('localhost', 'Localhost'); + complete('0.0.0.0', 'All interfaces'); + }, + }, + }, + }, + options: { + config: function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap) { + complete('vite.config.ts', 'Vite config file'); + complete('vite.config.js', 'Vite config file'); + }, + mode: function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap) { + complete('development', 'Development mode'); + complete('production', 'Production mode'); + }, + logLevel: function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap) { + complete('info', 'Info level'); + complete('warn', 'Warn level'); + complete('error', 'Error level'); + complete('silent', 'Silent level'); + }, + }, +}); cli.parse(); diff --git a/examples/demo.citty.ts b/examples/demo.citty.ts index 938b91e..55a4646 100644 --- a/examples/demo.citty.ts +++ b/examples/demo.citty.ts @@ -1,4 +1,4 @@ -import { defineCommand, createMain, CommandDef, ArgsDef } from 'citty'; +import { defineCommand, createMain, type CommandDef, type ArgsDef } from 'citty'; import tab from '../src/citty'; const main = defineCommand({ @@ -8,6 +8,11 @@ const main = defineCommand({ description: 'Vite CLI', }, args: { + project: { + type: 'positional', + description: 'Project name', + required: true, + }, config: { type: 'string', description: 'Use specified config file', @@ -55,6 +60,26 @@ const buildCommand = defineCommand({ run: () => {}, }); +const copyCommand = defineCommand({ + meta: { + name: 'copy', + description: 'Copy files', + }, + args: { + source: { + type: 'positional', + description: 'Source file or directory', + required: true, + }, + destination: { + type: 'positional', + description: 'Destination file or directory', + required: true, + }, + }, + run: () => {}, +}); + const lintCommand = defineCommand({ meta: { name: 'lint', @@ -73,53 +98,67 @@ const lintCommand = defineCommand({ main.subCommands = { dev: devCommand, build: buildCommand, + copy: copyCommand, lint: lintCommand, } as Record>; const completion = await tab(main, { + args: { + project: function(complete) { + complete('my-app', 'My application'); + complete('my-lib', 'My library'); + complete('my-tool', 'My tool'); + }, + }, options: { - config: { - handler: () => [ - { value: 'vite.config.ts', description: 'Vite config file' }, - { value: 'vite.config.js', description: 'Vite config file' }, - ], + config: function(this: any, complete) { + complete('vite.config.ts', 'Vite config file'); + complete('vite.config.js', 'Vite config file'); }, - mode: { - handler: () => [ - { value: 'development', description: 'Development mode' }, - { value: 'production', description: 'Production mode' }, - ], + mode: function(this: any, complete) { + complete('development', 'Development mode'); + complete('production', 'Production mode'); }, - logLevel: { - handler: () => [ - { value: 'info', description: 'Info level' }, - { value: 'warn', description: 'Warn level' }, - { value: 'error', description: 'Error level' }, - { value: 'silent', description: 'Silent level' }, - ], + logLevel: function(this: any, complete) { + complete('info', 'Info level'); + complete('warn', 'Warn level'); + complete('error', 'Error level'); + complete('silent', 'Silent level'); }, }, subCommands: { + copy: { + args: { + source: function(complete) { + complete('src/', 'Source directory'); + complete('dist/', 'Distribution directory'); + complete('public/', 'Public assets'); + }, + destination: function(complete) { + complete('build/', 'Build output'); + complete('release/', 'Release directory'); + complete('backup/', 'Backup location'); + }, + }, + }, lint: { - handler: () => [ - { value: 'main.ts', description: 'Main file' }, - { value: 'index.ts', description: 'Index file' }, - ], + args: { + files: function(complete) { + complete('main.ts', 'Main file'); + complete('index.ts', 'Index file'); + }, + }, }, dev: { options: { - port: { - handler: () => [ - { value: '3000', description: 'Development server port' }, - { value: '8080', description: 'Alternative port' }, - ], + port: function(this: any, complete) { + complete('3000', 'Development server port'); + complete('8080', 'Alternative port'); }, - host: { - handler: () => [ - { value: 'localhost', description: 'Localhost' }, - { value: '0.0.0.0', description: 'All interfaces' }, - ], + host: function(this: any, complete) { + complete('localhost', 'Localhost'); + complete('0.0.0.0', 'All interfaces'); }, }, }, diff --git a/examples/demo.commander.ts b/examples/demo.commander.ts index e7f33da..db8169a 100644 --- a/examples/demo.commander.ts +++ b/examples/demo.commander.ts @@ -107,14 +107,5 @@ for (const command of completion.commands.values()) { } } -// Test completion directly if the first argument is "test-completion" -if (process.argv[2] === 'test-completion') { - const args = process.argv.slice(3); - console.log('Testing completion with args:', args); - completion.parse(args).then(() => { - // Done - }); -} else { - // Parse command line arguments - program.parse(); -} +// Parse command line arguments +program.parse(); diff --git a/examples/demo.t.ts b/examples/demo.t.ts new file mode 100644 index 0000000..61b1392 --- /dev/null +++ b/examples/demo.t.ts @@ -0,0 +1,92 @@ +import t from '../src/t'; + +// Global options +t.option('config', 'Use specified config file', function(complete) { + complete('vite.config.ts', 'Vite config file'); + complete('vite.config.js', 'Vite config file'); +}, 'c'); + +t.option('mode', 'Set env mode', function(complete) { + complete('development', 'Development mode'); + complete('production', 'Production mode'); +}, 'm'); + +t.option('logLevel', 'info | warn | error | silent', function(complete) { + complete('info', 'Info level'); + complete('warn', 'Warn level'); + complete('error', 'Error level'); + complete('silent', 'Silent level'); +}, 'l'); + +// Root command argument +t.argument('project', function(complete) { + complete('my-app', 'My application'); + complete('my-lib', 'My library'); + complete('my-tool', 'My tool'); +}); + +// Dev command +const devCmd = t.command('dev', 'Start dev server'); +devCmd.option('host', 'Specify hostname', function(complete) { + complete('localhost', 'Localhost'); + complete('0.0.0.0', 'All interfaces'); +}, 'H'); + +devCmd.option('port', 'Specify port', function(complete) { + complete('3000', 'Development server port'); + complete('8080', 'Alternative port'); +}, 'p'); + +// Serve command +const serveCmd = t.command('serve', 'Start the server'); +serveCmd.option('host', 'Specify hostname', function(complete) { + complete('localhost', 'Localhost'); + complete('0.0.0.0', 'All interfaces'); +}, 'H'); + +serveCmd.option('port', 'Specify port', function(complete) { + complete('3000', 'Development server port'); + complete('8080', 'Alternative port'); +}, 'p'); + +// Build command +t.command('dev build', 'Build project'); + +// Copy command with multiple arguments +const copyCmd = t.command('copy', 'Copy files') + .argument('source', function(complete) { + complete('src/', 'Source directory'); + complete('dist/', 'Distribution directory'); + complete('public/', 'Public assets'); + }) + .argument('destination', function(complete) { + complete('build/', 'Build output'); + complete('release/', 'Release directory'); + complete('backup/', 'Backup location'); + }); + +// Lint command with variadic arguments +const lintCmd = t.command('lint', 'Lint project') + .argument('files', function(complete) { + complete('main.ts', 'Main file'); + complete('index.ts', 'Index file'); + complete('src/', 'Source directory'); + complete('tests/', 'Tests directory'); + }, true); // Variadic argument for multiple files + +// Handle completion command +if (process.argv[2] === 'complete') { + const shell = process.argv[3]; + if (shell && ['zsh', 'bash', 'fish', 'powershell'].includes(shell)) { + t.setup('vite', 'pnpm tsx examples/demo.t.ts', shell); + } else { + // Parse completion arguments (everything after --) + const separatorIndex = process.argv.indexOf('--'); + const completionArgs = separatorIndex !== -1 ? process.argv.slice(separatorIndex + 1) : []; + t.parse(completionArgs); + } +} else { + // Regular CLI usage (just show help for demo) + console.log('Vite CLI Demo'); + console.log('Use "complete" command for shell completion'); +} \ No newline at end of file diff --git a/package.json b/package.json index 02259f5..16d3296 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "tab": "./dist/bin/cli.js" }, "scripts": { - "test": "vitest", + "test": "vitest run", "type-check": "tsc --noEmit", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/src/cac.ts b/src/cac.ts index 8ddc14e..28515b8 100644 --- a/src/cac.ts +++ b/src/cac.ts @@ -3,8 +3,14 @@ import * as bash from './bash'; import * as fish from './fish'; import * as powershell from './powershell'; import type { CAC } from 'cac'; -import { Completion } from './index'; -import { CompletionConfig, noopHandler, assertDoubleDashes } from './shared'; +import { assertDoubleDashes } from './shared'; +import { OptionHandler } from './t'; +import { CompletionConfig } from './shared'; +import t from './t'; + + + +const noopOptionHandler: OptionHandler = function() {}; const execPath = process.execPath; const processArgs = process.argv.slice(1); @@ -18,12 +24,12 @@ function quoteIfNeeded(path: string): string { return path.includes(' ') ? `'${path}'` : path; } + + export default async function tab( instance: CAC, completionConfig?: CompletionConfig -) { - const completion = new Completion(); - +): Promise { // Add all commands and their options for (const cmd of [instance.globalCommand, ...instance.commands]) { if (cmd.name === 'complete') continue; // Skip completion command @@ -38,13 +44,35 @@ export default async function tab( ? completionConfig : completionConfig?.subCommands?.[cmd.name]; - // Add command to completion - const commandName = completion.addCommand( - isRootCommand ? '' : cmd.name, - cmd.description || '', - args, - commandCompletionConfig?.handler ?? noopHandler - ); + // Add command to completion using t.ts API + const commandName = isRootCommand ? '' : cmd.name; + const command = isRootCommand + ? t + : t.command(commandName, cmd.description || ''); + + // Set args for the command + if (command) { + // Extract argument names from command usage + const argMatches = cmd.rawName.match(/<([^>]+)>|\[\.\.\.([^\]]+)\]/g) || []; + const argNames = argMatches.map(match => { + if (match.startsWith('<') && match.endsWith('>')) { + return match.slice(1, -1); // Remove < > + } else if (match.startsWith('[...') && match.endsWith(']')) { + return match.slice(4, -1); // Remove [... ] + } + return match; + }); + + args.forEach((variadic, index) => { + const argName = argNames[index] || `arg${index}`; + const argHandler = commandCompletionConfig?.args?.[argName]; + if (argHandler) { + command.argument(argName, argHandler, variadic); + } else { + command.argument(argName, undefined, variadic); + } + }); + } // Add command options for (const option of [...instance.globalCommand.options, ...cmd.options]) { @@ -52,13 +80,16 @@ export default async function tab( const shortFlag = option.name.match(/^-([a-zA-Z]), --/)?.[1]; const argName = option.name.replace(/^-[a-zA-Z], --/, ''); - completion.addOption( - commandName, - `--${argName}`, // Remove the short flag part if it exists - option.description || '', - commandCompletionConfig?.options?.[argName]?.handler ?? noopHandler, - shortFlag - ); + // Add option using t.ts API + const targetCommand = isRootCommand ? t : command; + if (targetCommand) { + targetCommand.option( + argName, // Store just the option name without -- prefix + option.description || '', + commandCompletionConfig?.options?.[argName] ?? noopOptionHandler, + shortFlag + ); + } } } @@ -96,13 +127,11 @@ export default async function tab( run: false, }); - // const matchedCommand = instance.matchedCommand?.name || ''; - // const potentialCommand = args.join(' ') - // console.log(potentialCommand) - return completion.parse(args); + // Use t.ts parse method instead of completion.parse + return t.parse(args); } } }); - return completion; + return t; } diff --git a/src/citty.ts b/src/citty.ts index f7affe4..84878ee 100644 --- a/src/citty.ts +++ b/src/citty.ts @@ -3,7 +3,6 @@ import * as zsh from './zsh'; import * as bash from './bash'; import * as fish from './fish'; import * as powershell from './powershell'; -import { Completion } from './index'; import type { ArgsDef, CommandDef, @@ -11,7 +10,9 @@ import type { SubCommandsDef, } from 'citty'; import { generateFigSpec } from './fig'; -import { CompletionConfig, noopHandler, assertDoubleDashes } from './shared'; +import { CompletionConfig, assertDoubleDashes } from './shared'; +import { OptionHandler, Command, Option, OptionsMap } from './t'; +import t from './t'; function quoteIfNeeded(path: string) { return path.includes(' ') ? `'${path}'` : path; @@ -31,8 +32,46 @@ function isConfigPositional(config: CommandDef) { ); } + + +// Convert Handler from index.ts to OptionHandler from t.ts +function convertOptionHandler(handler: any): OptionHandler { + return function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap, previousArgs?: string[], toComplete?: string, endsWithSpace?: boolean) { + // For short flags with equals sign and a value, don't complete (citty behavior) + // Check if this is a short flag option and if the toComplete looks like a value + if (this.alias && toComplete && toComplete !== '' && !toComplete.startsWith('-')) { + // This might be a short flag with equals sign and a value + // Check if the previous args contain a short flag with equals sign + if (previousArgs && previousArgs.length > 0) { + const lastArg = previousArgs[previousArgs.length - 1]; + if (lastArg.includes('=')) { + const [flag, value] = lastArg.split('='); + if (flag.startsWith('-') && !flag.startsWith('--') && value !== '') { + return; // Don't complete short flags with equals sign and value + } + } + } + } + + // Call the old handler with the proper context + const result = handler(previousArgs || [], toComplete || '', endsWithSpace || false); + + if (Array.isArray(result)) { + result.forEach((item: any) => complete(item.value, item.description || '')); + } else if (result && typeof result.then === 'function') { + // Handle async handlers + result.then((items: any[]) => { + items.forEach((item: any) => complete(item.value, item.description || '')); + }); + } + }; +} + + + +const noopOptionHandler: OptionHandler = function() {}; + async function handleSubCommands( - completion: Completion, subCommands: SubCommandsDef, parentCmd?: string, completionConfig?: Record @@ -47,20 +86,34 @@ async function handleSubCommands( throw new Error('Invalid meta or missing description.'); } const isPositional = isConfigPositional(config); - const name = completion.addCommand( - cmd, - meta.description, - isPositional ? [false] : [], - subCompletionConfig?.handler ?? noopHandler, - parentCmd - ); + + // Add command using t.ts API + const commandName = parentCmd ? `${parentCmd} ${cmd}` : cmd; + const command = t.command(cmd, meta.description); + + // Set args for the command if it has positional arguments + if (isPositional && config.args) { + // Add arguments with completion handlers from subCompletionConfig args + for (const [argName, argConfig] of Object.entries(config.args)) { + const conf = argConfig as ArgDef; + if (conf.type === 'positional') { + // Check if this is a variadic argument (required: false for variadic in citty) + const isVariadic = conf.required === false; + const argHandler = subCompletionConfig?.args?.[argName]; + if (argHandler) { + command.argument(argName, argHandler, isVariadic); + } else { + command.argument(argName, undefined, isVariadic); + } + } + } + } // Handle nested subcommands recursively if (subCommands) { await handleSubCommands( - completion, subCommands, - name, + commandName, subCompletionConfig?.subCommands ); } @@ -80,11 +133,11 @@ async function handleSubCommands( : conf.alias : undefined; - completion.addOption( - name, - `--${argName}`, + // Add option using t.ts API - store without -- prefix + command.option( + argName, conf.description ?? '', - subCompletionConfig?.options?.[argName]?.handler ?? noopHandler, + subCompletionConfig?.options?.[argName] ?? noopOptionHandler, shortFlag ); } @@ -95,9 +148,7 @@ async function handleSubCommands( export default async function tab( instance: CommandDef, completionConfig?: CompletionConfig -) { - const completion = new Completion(); - +): Promise { const meta = await resolve(instance.meta); if (!meta) { @@ -113,17 +164,25 @@ export default async function tab( throw new Error('Invalid or missing subCommands.'); } - const root = ''; const isPositional = isConfigPositional(instance); - completion.addCommand( - root, - meta?.description ?? '', - isPositional ? [false] : [], - completionConfig?.handler ?? noopHandler - ); + + // Set args for the root command if it has positional arguments + if (isPositional && instance.args) { + for (const [argName, argConfig] of Object.entries(instance.args)) { + const conf = argConfig as PositionalArgDef; + if (conf.type === 'positional') { + const isVariadic = conf.required === false; + const argHandler = completionConfig?.args?.[argName]; + if (argHandler) { + t.argument(argName, argHandler, isVariadic); + } else { + t.argument(argName, undefined, isVariadic); + } + } + } + } await handleSubCommands( - completion, subCommands, undefined, completionConfig?.subCommands @@ -132,11 +191,11 @@ export default async function tab( if (instance.args) { for (const [argName, argConfig] of Object.entries(instance.args)) { const conf = argConfig as PositionalArgDef; - completion.addOption( - root, - `--${argName}`, + // Add option using t.ts API - store without -- prefix + t.option( + argName, conf.description ?? '', - completionConfig?.options?.[argName]?.handler ?? noopHandler + completionConfig?.options?.[argName] ?? noopOptionHandler ); } } @@ -190,14 +249,8 @@ export default async function tab( assertDoubleDashes(name); const extra = ctx.rawArgs.slice(ctx.rawArgs.indexOf('--') + 1); - // const args = (await resolve(instance.args))!; - // const parsed = parseArgs(extra, args); - // TODO: this is not ideal at all - // const matchedCommand = parsed._.join(' ').trim(); //TODO: this was passed to parse line 170 - // TODO: `command lint i` does not work because `lint` and `i` are potential commands - // instead the potential command should only be `lint` - // and `i` is the to be completed part - return completion.parse(extra); + // Use t.ts parse method instead of completion.parse + return t.parse(extra); } } }, @@ -205,7 +258,7 @@ export default async function tab( subCommands.complete = completeCommand; - return completion; + return t; } type Resolvable = T | Promise | (() => T) | (() => Promise); diff --git a/src/fig.ts b/src/fig.ts index 8a5f71e..325fb08 100644 --- a/src/fig.ts +++ b/src/fig.ts @@ -114,6 +114,7 @@ async function processCommand( return spec; } +// TODO: this should be an extension of t.setup function and not something like this. export async function generateFigSpec( command: CommandDef ): Promise { diff --git a/src/index.ts b/src/index.ts index aba17ae..21a51da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import * as bash from './bash'; import * as fish from './fish'; import * as powershell from './powershell'; import { execSync } from 'child_process'; +import { Completion as CompletionItem } from './t'; const DEBUG = false; @@ -118,13 +119,10 @@ export type Positional = { completion: Handler; }; -type Item = { - description?: string; - value: string; -}; + type CompletionResult = { - items: Item[]; + items: CompletionItem[]; suppressDefault: boolean; }; @@ -132,7 +130,7 @@ export type Handler = ( previousArgs: string[], toComplete: string, endsWithSpace: boolean -) => Item[] | Promise; +) => CompletionItem[] | Promise; type Option = { description: string; @@ -151,7 +149,7 @@ type Command = { export class Completion { commands = new Map(); - completions: Item[] = []; + completions: CompletionItem[] = []; directive = ShellCompDirective.ShellCompDirectiveDefault; result: CompletionResult = { items: [], suppressDefault: false }; private packageManager: string | null = null; diff --git a/src/shared.ts b/src/shared.ts index 798b525..24d0727 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,20 +1,15 @@ -import { Handler } from './index'; +import { OptionHandler, ArgumentHandler } from './t'; -export const noopHandler: Handler = () => { - return []; +export const noopHandler: OptionHandler = function() { + // No-op handler for options }; // TODO (43081j): use type inference some day, so we can type-check // that the sub commands exist, the options exist, etc. export interface CompletionConfig { - handler?: Handler; subCommands?: Record; - options?: Record< - string, - { - handler: Handler; - } - >; + options?: Record; + args?: Record; } export function assertDoubleDashes(programName: string = 'cli'): void { diff --git a/src/t.ts b/src/t.ts index 1881c67..e88269e 100644 --- a/src/t.ts +++ b/src/t.ts @@ -1,50 +1,83 @@ -type OptionsMap = Map +// Shell directive constants +const ShellCompDirective = { + ShellCompDirectiveError: 1 << 0, + ShellCompDirectiveNoSpace: 1 << 1, + ShellCompDirectiveNoFileComp: 1 << 2, + ShellCompDirectiveFilterFileExt: 1 << 3, + ShellCompDirectiveFilterDirs: 1 << 4, + ShellCompDirectiveKeepOrder: 1 << 5, + shellCompDirectiveMaxValue: 1 << 6, + ShellCompDirectiveDefault: 0, +}; + +export type OptionsMap = Map type Complete = (value: string, description: string) => void -type OptionCompleter = (this: Option, complete: Complete, options: OptionsMap) => void +export type OptionHandler = (this: Option, complete: Complete, options: OptionsMap) => void -class Option { +// Completion result types +export type Completion = { + description?: string; + value: string; +}; + +export type ArgumentHandler = (this: Argument, complete: Complete, options: OptionsMap) => void + +export class Argument { + name: string + variadic: boolean + command: Command + handler?: ArgumentHandler + + constructor(command: Command, name: string, handler?: ArgumentHandler, variadic: boolean = false) { + this.command = command + this.name = name + this.handler = handler + this.variadic = variadic + } +} + +export class Option { value: string description: string command: Command - completer?: OptionCompleter - shortValue?: string + handler?: OptionHandler + alias?: string // TODO: handle boolean options - constructor(command: Command, value: string, description: string, completer?: OptionCompleter, shortValue?: string) { + constructor(command: Command, value: string, description: string, handler?: OptionHandler, alias?: string) { this.command = command this.value = value this.description = description - this.completer = completer - this.shortValue = shortValue + this.handler = handler + this.alias = alias } } -type CommandCompleter = (this: Command, complete: Complete, options: OptionsMap) => void - -class Command { +export class Command { value: string description: string - options = new Map - + arguments = new Map + parent?: Command - completer?: CommandCompleter - - constructor(value: string, description: string, completer?: CommandCompleter) { + constructor(value: string, description: string) { this.value = value this.description = description - this.completer = completer } - option(value: string, description: string, completer?: OptionCompleter, shortValue?: string) { - this.options.set(value, new Option(this, value, description, completer, shortValue)) + option(value: string, description: string, handler?: OptionHandler, alias?: string) { + const option = new Option(this, value, description, handler, alias) + this.options.set(value, option) return this } - - + argument(name: string, handler?: ArgumentHandler, variadic: boolean = false) { + const arg = new Argument(this, name, handler, variadic) + this.arguments.set(name, arg) + return this + } } import * as zsh from './zsh'; @@ -53,33 +86,281 @@ import * as fish from './fish'; import * as powershell from './powershell'; import assert from 'node:assert' -class RootCommand extends Command { - completer = undefined +export class RootCommand extends Command { commands = new Map + completions: Completion[] = []; + directive = ShellCompDirective.ShellCompDirectiveDefault; constructor() { super('', '') } - command(value: string, description: string, completer?: CommandCompleter) { - const c = new Command(value, description, completer) + command(value: string, description: string) { + const c = new Command(value, description) this.commands.set(value, c) return c } + // Utility method to strip options from args for command matching + private stripOptions(args: string[]): string[] { + const parts: string[] = []; + let i = 0; + + while (i < args.length) { + const arg = args[i]; + + if (arg.startsWith('-')) { + i++; // Skip the option + if (i < args.length && !args[i].startsWith('-')) { + i++; // Skip the option value + } + } else { + parts.push(arg); + i++; + } + } + + return parts; + } + + // Find the appropriate command based on args + private matchCommand(args: string[]): [Command, string[]] { + args = this.stripOptions(args); + const parts: string[] = []; + let remaining: string[] = []; + let matched: Command = this; + + for (let i = 0; i < args.length; i++) { + const k = args[i]; + parts.push(k); + const potential = this.commands.get(parts.join(' ')); + + if (potential) { + matched = potential; + } else { + remaining = args.slice(i, args.length); + break; + } + } + + return [matched, remaining]; + } + + // Determine if we should complete flags + private shouldCompleteFlags( + lastPrevArg: string | undefined, + toComplete: string, + endsWithSpace: boolean + ): boolean { + return lastPrevArg?.startsWith('-') || toComplete.startsWith('-'); + } + + // Determine if we should complete commands + private shouldCompleteCommands( + toComplete: string, + endsWithSpace: boolean + ): boolean { + return !toComplete.startsWith('-'); + } + + // Handle flag completion (names and values) + private handleFlagCompletion( + command: Command, + previousArgs: string[], + toComplete: string, + endsWithSpace: boolean, + lastPrevArg: string | undefined + ) { + // Handle flag value completion + let optionName: string | undefined; + let valueToComplete = toComplete; + + if (toComplete.includes('=')) { + const [flag, value] = toComplete.split('='); + optionName = flag; + valueToComplete = value || ''; + } else if (lastPrevArg?.startsWith('-')) { + optionName = lastPrevArg; + } + + if (optionName) { + const option = this.findOption(command, optionName); + if (option?.handler) { + const suggestions: Completion[] = []; + option.handler.call(option, + (value: string, description: string) => suggestions.push({ value, description }), + command.options + ); + + this.completions = toComplete.includes('=') + ? suggestions.map(s => ({ value: `${optionName}=${s.value}`, description: s.description })) + : suggestions; + } + return; + } + + // Handle flag name completion + if (toComplete.startsWith('-')) { + const isShortFlag = toComplete.startsWith('-') && !toComplete.startsWith('--'); + const cleanToComplete = toComplete.replace(/^-+/, ''); + + for (const [name, option] of command.options) { + if (isShortFlag && option.alias && `-${option.alias}`.startsWith(toComplete)) { + this.completions.push({ value: `-${option.alias}`, description: option.description }); + } else if (!isShortFlag && name.startsWith(cleanToComplete)) { + this.completions.push({ value: `--${name}`, description: option.description }); + } + } + } + } + + // Helper method to find an option by name or alias + private findOption(command: Command, optionName: string): Option | undefined { + // Try direct match (with dashes) + let option = command.options.get(optionName); + if (option) return option; + + // Try without dashes (the actual storage format) + option = command.options.get(optionName.replace(/^-+/, '')); + if (option) return option; + + // Try by short alias + for (const [name, opt] of command.options) { + if (opt.alias && `-${opt.alias}` === optionName) { + return opt; + } + } + + return undefined; + } + + // Handle command completion + private handleCommandCompletion( + previousArgs: string[], + toComplete: string + ) { + const commandParts = previousArgs.filter(Boolean); + + for (const [k, command] of this.commands) { + if (k === '') continue; + + const parts = k.split(' '); + const match = parts.slice(0, commandParts.length).every((part, i) => part === commandParts[i]); + + if (match && parts[commandParts.length]?.startsWith(toComplete)) { + this.completions.push({ + value: parts[commandParts.length], + description: command.description, + }); + } + } + } + + // Handle positional argument completion + private handlePositionalCompletion( + command: Command, + previousArgs: string[], + toComplete: string, + endsWithSpace: boolean + ) { + // Get the current argument position (subtract command name) + const commandParts = command.value.split(' ').length; + const currentArgIndex = Math.max(0, previousArgs.length - commandParts); + const argumentEntries = Array.from(command.arguments.entries()); + + // If we have arguments defined + if (argumentEntries.length > 0) { + // Find the appropriate argument for the current position + let targetArgument: Argument | undefined; + + if (currentArgIndex < argumentEntries.length) { + // We're within the defined arguments + const [argName, argument] = argumentEntries[currentArgIndex]; + targetArgument = argument; + } else { + // We're beyond the defined arguments, check if the last argument is variadic + const lastArgument = argumentEntries[argumentEntries.length - 1][1]; + if (lastArgument.variadic) { + targetArgument = lastArgument; + } + } + + // If we found a target argument with a handler, use it + if (targetArgument && targetArgument.handler && typeof targetArgument.handler === 'function') { + const suggestions: Completion[] = []; + targetArgument.handler.call(targetArgument, + (value: string, description: string) => suggestions.push({ value, description }), + command.options + ); + this.completions.push(...suggestions); + } + } + } + + // Format and output completion results + private complete(toComplete: string) { + this.directive = ShellCompDirective.ShellCompDirectiveNoFileComp; + + const seen = new Set(); + this.completions + .filter((comp) => { + if (seen.has(comp.value)) return false; + seen.add(comp.value); + return true; + }) + .filter((comp) => comp.value.startsWith(toComplete)) + .forEach((comp) => + console.log(`${comp.value}\t${comp.description ?? ''}`) + ); + console.log(`:${this.directive}`); + } + parse(args: string[]) { - const endsWithSpace = args[args.length - 1] === '' + this.completions = []; + + const endsWithSpace = args[args.length - 1] === ''; if (endsWithSpace) { - args.pop() + args.pop(); } - const toComplete = args[args.length - 1] || '' - const previousArgs = args.slice(0, -1) - - for (const arg of previousArgs) { - const option = this.options.get(arg) + let toComplete = args[args.length - 1] || ''; + const previousArgs = args.slice(0, -1); + + if (endsWithSpace) { + previousArgs.push(toComplete); + toComplete = ''; + } + + const [matchedCommand] = this.matchCommand(previousArgs); + const lastPrevArg = previousArgs[previousArgs.length - 1]; + + // 1. Handle flag/option completion + if (this.shouldCompleteFlags(lastPrevArg, toComplete, endsWithSpace)) { + this.handleFlagCompletion( + matchedCommand, + previousArgs, + toComplete, + endsWithSpace, + lastPrevArg + ); + } else { + // 2. Handle command/subcommand completion + if (this.shouldCompleteCommands(toComplete, endsWithSpace)) { + this.handleCommandCompletion(previousArgs, toComplete); + } + // 3. Handle positional arguments + if (matchedCommand && matchedCommand.arguments.size > 0) { + this.handlePositionalCompletion( + matchedCommand, + previousArgs, + toComplete, + endsWithSpace + ); + } } + + this.complete(toComplete); } setup(name: string, executable: string, shell: string) { @@ -112,27 +393,4 @@ class RootCommand extends Command { const t = new RootCommand() -export default t - -// import t from '@bombsh/tab' - -t.option('help', 'list available commands') // Command (Root) - -t - .command('start', 'start the development server') // Command ('start') - .option('port', 'specify the port number', function (complete, options) { - complete('3000', 'general port') - complete('3001', 'another general port') - complete('3002', 'another general port') - }) // Command ('port') - -t - .command('start dev', 'start the development server') // Command ('start') - - -const x = 'npx my-cli' - -// t.setup('my-cli', x, process.argv[2]) -console.log(process.argv) - -t.parse(process.argv.slice(3)) \ No newline at end of file +export default t \ No newline at end of file diff --git a/tests/__snapshots__/cli.test.ts.snap b/tests/__snapshots__/cli.test.ts.snap index 0b24152..0527244 100644 --- a/tests/__snapshots__/cli.test.ts.snap +++ b/tests/__snapshots__/cli.test.ts.snap @@ -1,5 +1,44 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`cli completion tests for cac > --config option tests > should complete --config option values 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for cac > --config option tests > should complete --config option with equals sign 1`] = ` +"--config=vite.config.ts Vite config file +--config=vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for cac > --config option tests > should complete --config option with partial input 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for cac > --config option tests > should complete short flag -c option values 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > --config option tests > should complete short flag -c option with partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > --config option tests > should not suggest --config after it has been used 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + exports[`cli completion tests for cac > cli option completion tests > should complete option for partial input '{ partial: '--p', expected: '--port' }' 1`] = ` "--port Specify port :4 @@ -44,6 +83,35 @@ exports[`cli completion tests for cac > cli option value handling > should resol " `; +exports[`cli completion tests for cac > copy command argument handlers > should complete destination argument with build suggestions 1`] = ` +"build/ Build output +release/ Release directory +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for cac > copy command argument handlers > should complete source argument with directory suggestions 1`] = ` +"src/ Source directory +dist/ Distribution directory +public/ Public assets +:4 +" +`; + +exports[`cli completion tests for cac > copy command argument handlers > should filter destination suggestions when typing partial input 1`] = ` +"build/ Build output +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for cac > copy command argument handlers > should filter source suggestions when typing partial input 1`] = ` +"src/ Source directory +:4 +" +`; + exports[`cli completion tests for cac > edge case completions for end with space > should keep suggesting the --port option if user typed partial but didn't end with space 1`] = ` "--port Specify port :4 @@ -64,6 +132,32 @@ exports[`cli completion tests for cac > edge case completions for end with space " `; +exports[`cli completion tests for cac > lint command argument handlers > should complete files argument with file suggestions 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for cac > lint command argument handlers > should continue completing variadic files argument after first file 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for cac > lint command argument handlers > should continue completing variadic suggestions after first file 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for cac > lint command argument handlers > should filter file suggestions when typing partial input 1`] = ` +"main.ts Main file +:4 +" +`; + exports[`cli completion tests for cac > positional argument completions > should complete multiple positional arguments when ending with part of the value 1`] = ` "index.ts Index file :4 @@ -84,6 +178,82 @@ index.ts Index file " `; +exports[`cli completion tests for cac > root command argument tests > should complete root command project argument 1`] = ` +"dev Start dev server +serve Start the server +copy Copy files +lint Lint project +:4 +" +`; + +exports[`cli completion tests for cac > root command argument tests > should complete root command project argument after options 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > root command argument tests > should complete root command project argument with options and partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > root command argument tests > should complete root command project argument with partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command --logLevel option values 1`] = ` +"info Info level +warn Warn level +error Error level +silent Silent level +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command --logLevel option with partial input 1`] = ` +"info Info level +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command --mode option values 1`] = ` +"development Development mode +production Production mode +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command --mode option with partial input 1`] = ` +"development Development mode +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command options after project argument 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command options with partial input after project argument 1`] = ` +"--mode Set env mode +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command short flag -l option values 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command short flag -m option values 1`] = ` +":4 +" +`; + exports[`cli completion tests for cac > short flag handling > should handle global short flags 1`] = ` ":4 " @@ -110,11 +280,52 @@ exports[`cli completion tests for cac > short flag handling > should not show du exports[`cli completion tests for cac > should complete cli options 1`] = ` "dev Start dev server serve Start the server +copy Copy files lint Lint project :4 " `; +exports[`cli completion tests for citty > --config option tests > should complete --config option values 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for citty > --config option tests > should complete --config option with equals sign 1`] = ` +"--config=vite.config.ts Vite config file +--config=vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for citty > --config option tests > should complete --config option with partial input 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for citty > --config option tests > should complete short flag -c option values 1`] = ` +":4 +" +`; + +exports[`cli completion tests for citty > --config option tests > should complete short flag -c option with partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for citty > --config option tests > should not suggest --config after it has been used 1`] = ` +"--project Project name +--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + exports[`cli completion tests for citty > cli option completion tests > should complete option for partial input '{ partial: '--p', expected: '--port' }' 1`] = ` "--port Specify port :4 @@ -141,7 +352,8 @@ exports[`cli completion tests for citty > cli option exclusion tests > should no exports[`cli completion tests for citty > cli option value handling > should handle unknown options with no completions 1`] = `":4"`; exports[`cli completion tests for citty > cli option value handling > should not show duplicate options 1`] = ` -"--config Use specified config file +"--project Project name +--config Use specified config file --mode Set env mode --logLevel info | warn | error | silent :4 @@ -161,6 +373,35 @@ exports[`cli completion tests for citty > cli option value handling > should res " `; +exports[`cli completion tests for citty > copy command argument handlers > should complete destination argument with build suggestions 1`] = ` +"build/ Build output +release/ Release directory +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for citty > copy command argument handlers > should complete source argument with directory suggestions 1`] = ` +"src/ Source directory +dist/ Distribution directory +public/ Public assets +:4 +" +`; + +exports[`cli completion tests for citty > copy command argument handlers > should filter destination suggestions when typing partial input 1`] = ` +"build/ Build output +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for citty > copy command argument handlers > should filter source suggestions when typing partial input 1`] = ` +"src/ Source directory +:4 +" +`; + exports[`cli completion tests for citty > edge case completions for end with space > should keep suggesting the --port option if user typed partial but didn't end with space 1`] = ` "--port Specify port :4 @@ -181,6 +422,135 @@ exports[`cli completion tests for citty > edge case completions for end with spa " `; +exports[`cli completion tests for citty > lint command argument handlers > should complete files argument with file suggestions 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > lint command argument handlers > should continue completing variadic files argument after first file 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > lint command argument handlers > should continue completing variadic suggestions after first file 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > lint command argument handlers > should filter file suggestions when typing partial input 1`] = ` +"main.ts Main file +:4 +" +`; + +exports[`cli completion tests for citty > positional argument completions > should complete multiple positional arguments when ending with part of the value 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > positional argument completions > should complete multiple positional arguments when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > positional argument completions > should complete single positional argument when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > root command argument tests > should complete root command project argument 1`] = ` +"dev Start dev server +build Build project +copy Copy files +lint Lint project +my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + +exports[`cli completion tests for citty > root command argument tests > should complete root command project argument after options 1`] = ` +":4 +" +`; + +exports[`cli completion tests for citty > root command argument tests > should complete root command project argument with options and partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for citty > root command argument tests > should complete root command project argument with partial input 1`] = ` +"my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command --logLevel option values 1`] = ` +"info Info level +warn Warn level +error Error level +silent Silent level +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command --logLevel option with partial input 1`] = ` +"info Info level +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command --mode option values 1`] = ` +"development Development mode +production Production mode +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command --mode option with partial input 1`] = ` +"development Development mode +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command options after project argument 1`] = ` +"--project Project name +--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command options with partial input after project argument 1`] = ` +"--mode Set env mode +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command short flag -l option values 1`] = ` +":4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command short flag -m option values 1`] = ` +":4 +" +`; + exports[`cli completion tests for citty > short flag handling > should handle global short flags 1`] = ` ":4 " @@ -193,12 +563,14 @@ exports[`cli completion tests for citty > short flag handling > should handle sh `; exports[`cli completion tests for citty > short flag handling > should handle short flag with equals sign 1`] = ` -":4 +"-p=3000 Development server port +:4 " `; exports[`cli completion tests for citty > short flag handling > should not show duplicate options when short flag is used 1`] = ` -"--config Use specified config file +"--project Project name +--config Use specified config file --mode Set env mode --logLevel info | warn | error | silent :4 @@ -208,7 +580,11 @@ exports[`cli completion tests for citty > short flag handling > should not show exports[`cli completion tests for citty > should complete cli options 1`] = ` "dev Start dev server build Build project +copy Copy files lint Lint project +my-app My application +my-lib My library +my-tool My tool :4 " `; @@ -229,3 +605,321 @@ lint Lint source files :4 " `; + +exports[`cli completion tests for t > --config option tests > should complete --config option values 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > --config option tests > should complete --config option with equals sign 1`] = ` +"--config=vite.config.ts Vite config file +--config=vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > --config option tests > should complete --config option with partial input 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > --config option tests > should complete short flag -c option values 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > --config option tests > should complete short flag -c option with partial input 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > --config option tests > should not suggest --config after it has been used 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for t > cli option completion tests > should complete option for partial input '{ partial: '--p', expected: '--port' }' 1`] = ` +"--port Specify port +:4 +" +`; + +exports[`cli completion tests for t > cli option completion tests > should complete option for partial input '{ partial: '-H', expected: '-H' }' 1`] = ` +"-H Specify hostname +:4 +" +`; + +exports[`cli completion tests for t > cli option completion tests > should complete option for partial input '{ partial: '-p', expected: '-p' }' 1`] = ` +"-p Specify port +:4 +" +`; + +exports[`cli completion tests for t > cli option exclusion tests > should not suggest already specified option '{ specified: '--config', shouldNotContain: '--config' }' 1`] = ` +":4 +" +`; + +exports[`cli completion tests for t > cli option value handling > should handle unknown options with no completions 1`] = `":4"`; + +exports[`cli completion tests for t > cli option value handling > should not show duplicate options 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for t > cli option value handling > should resolve config option values correctly 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > cli option value handling > should resolve port value correctly 1`] = ` +"--port=3000 Development server port +:4 +" +`; + +exports[`cli completion tests for t > copy command argument handlers > should complete destination argument with build suggestions 1`] = ` +"build/ Build output +release/ Release directory +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for t > copy command argument handlers > should complete source argument with directory suggestions 1`] = ` +"src/ Source directory +dist/ Distribution directory +public/ Public assets +:4 +" +`; + +exports[`cli completion tests for t > copy command argument handlers > should filter destination suggestions when typing partial input 1`] = ` +"build/ Build output +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for t > copy command argument handlers > should filter source suggestions when typing partial input 1`] = ` +"src/ Source directory +:4 +" +`; + +exports[`cli completion tests for t > edge case completions for end with space > should keep suggesting the --port option if user typed partial but didn't end with space 1`] = ` +"--port Specify port +:4 +" +`; + +exports[`cli completion tests for t > edge case completions for end with space > should suggest port values if user ends with space after \`--port\` 1`] = ` +"3000 Development server port +8080 Alternative port +:4 +" +`; + +exports[`cli completion tests for t > edge case completions for end with space > should suggest port values if user typed \`--port=\` and hasn't typed a space or value yet 1`] = ` +"--port=3000 Development server port +--port=8080 Alternative port +:4 +" +`; + +exports[`cli completion tests for t > lint command argument handlers > should complete files argument with file suggestions 1`] = ` +"main.ts Main file +index.ts Index file +src/ Source directory +tests/ Tests directory +:4 +" +`; + +exports[`cli completion tests for t > lint command argument handlers > should continue completing variadic files argument after first file 1`] = ` +"main.ts Main file +index.ts Index file +src/ Source directory +tests/ Tests directory +:4 +" +`; + +exports[`cli completion tests for t > lint command argument handlers > should continue completing variadic suggestions after first file 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for t > lint command argument handlers > should filter file suggestions when typing partial input 1`] = ` +"main.ts Main file +:4 +" +`; + +exports[`cli completion tests for t > positional argument completions > should complete multiple positional arguments when ending with part of the value 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for t > positional argument completions > should complete multiple positional arguments when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +src/ Source directory +tests/ Tests directory +:4 +" +`; + +exports[`cli completion tests for t > positional argument completions > should complete single positional argument when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +src/ Source directory +tests/ Tests directory +:4 +" +`; + +exports[`cli completion tests for t > root command argument tests > should complete root command project argument 1`] = ` +"dev Start dev server +serve Start the server +copy Copy files +lint Lint project +my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + +exports[`cli completion tests for t > root command argument tests > should complete root command project argument after options 1`] = ` +":4 +" +`; + +exports[`cli completion tests for t > root command argument tests > should complete root command project argument with options and partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for t > root command argument tests > should complete root command project argument with partial input 1`] = ` +"my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command --logLevel option values 1`] = ` +"info Info level +warn Warn level +error Error level +silent Silent level +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command --logLevel option with partial input 1`] = ` +"info Info level +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command --mode option values 1`] = ` +"development Development mode +production Production mode +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command --mode option with partial input 1`] = ` +"development Development mode +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command options after project argument 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command options with partial input after project argument 1`] = ` +"--mode Set env mode +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command short flag -l option values 1`] = ` +"info Info level +warn Warn level +error Error level +silent Silent level +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command short flag -m option values 1`] = ` +"development Development mode +production Production mode +:4 +" +`; + +exports[`cli completion tests for t > short flag handling > should handle global short flags 1`] = ` +"-c Use specified config file +:4 +" +`; + +exports[`cli completion tests for t > short flag handling > should handle short flag value completion 1`] = ` +"-p Specify port +:4 +" +`; + +exports[`cli completion tests for t > short flag handling > should handle short flag with equals sign 1`] = ` +"-p=3000 Development server port +:4 +" +`; + +exports[`cli completion tests for t > short flag handling > should not show duplicate options when short flag is used 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for t > should complete cli options 1`] = ` +"dev Start dev server +serve Start the server +copy Copy files +lint Lint project +my-app My application +my-lib My library +my-tool My tool +:4 +" +`; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index e538367..37d4e27 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -13,21 +13,19 @@ function runCommand(command: string): Promise { }); } -const cliTools = ['citty', 'cac', 'commander']; +const cliTools = ['t', 'citty', 'cac', 'commander']; describe.each(cliTools)('cli completion tests for %s', (cliTool) => { // For Commander, we need to skip most of the tests since it handles completion differently const shouldSkipTest = cliTool === 'commander'; // Commander uses a different command structure for completion + // TODO: why commander does that? our convention is the -- part which should be always there. const commandPrefix = cliTool === 'commander' ? `pnpm tsx examples/demo.${cliTool}.ts complete` : `pnpm tsx examples/demo.${cliTool}.ts complete --`; - // Use 'dev' for citty and 'serve' for other tools - const commandName = cliTool === 'citty' ? 'dev' : 'serve'; - it.runIf(!shouldSkipTest)('should complete cli options', async () => { const output = await runCommand(`${commandPrefix}`); expect(output).toMatchSnapshot(); @@ -43,7 +41,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { test.each(optionTests)( "should complete option for partial input '%s'", async ({ partial }) => { - const command = `${commandPrefix} ${commandName} ${partial}`; + const command = `${commandPrefix} dev ${partial}`; const output = await runCommand(command); expect(output).toMatchSnapshot(); } @@ -67,7 +65,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { describe.runIf(!shouldSkipTest)('cli option value handling', () => { it('should resolve port value correctly', async () => { - const command = `${commandPrefix} ${commandName} --port=3`; + const command = `${commandPrefix} dev --port=3`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); @@ -91,23 +89,137 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); + describe.runIf(!shouldSkipTest)('--config option tests', () => { + it('should complete --config option values', async () => { + const command = `${commandPrefix} --config ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete --config option with partial input', async () => { + const command = `${commandPrefix} --config vite.config`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete --config option with equals sign', async () => { + const command = `${commandPrefix} --config=vite.config`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete short flag -c option values', async () => { + const command = `${commandPrefix} -c ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete short flag -c option with partial input', async () => { + const command = `${commandPrefix} -c vite.config`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should not suggest --config after it has been used', async () => { + const command = `${commandPrefix} --config vite.config.ts --`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); + + describe.runIf(!shouldSkipTest)('root command argument tests', () => { + it('should complete root command project argument', async () => { + const command = `${commandPrefix} ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command project argument with partial input', async () => { + const command = `${commandPrefix} my-`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command project argument after options', async () => { + const command = `${commandPrefix} --config vite.config.ts ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command project argument with options and partial input', async () => { + const command = `${commandPrefix} --mode development my-`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); + + describe.runIf(!shouldSkipTest)('root command option tests', () => { + it('should complete root command --mode option values', async () => { + const command = `${commandPrefix} --mode ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command --mode option with partial input', async () => { + const command = `${commandPrefix} --mode dev`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command --logLevel option values', async () => { + const command = `${commandPrefix} --logLevel ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command --logLevel option with partial input', async () => { + const command = `${commandPrefix} --logLevel i`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command short flag -m option values', async () => { + const command = `${commandPrefix} -m ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command short flag -l option values', async () => { + const command = `${commandPrefix} -l ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command options after project argument', async () => { + const command = `${commandPrefix} my-app --`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command options with partial input after project argument', async () => { + const command = `${commandPrefix} my-app --m`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); + describe.runIf(!shouldSkipTest)( 'edge case completions for end with space', () => { it('should suggest port values if user ends with space after `--port`', async () => { - const command = `${commandPrefix} ${commandName} --port ""`; + const command = `${commandPrefix} dev --port ""`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); it("should keep suggesting the --port option if user typed partial but didn't end with space", async () => { - const command = `${commandPrefix} ${commandName} --po`; + const command = `${commandPrefix} dev --po`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); it("should suggest port values if user typed `--port=` and hasn't typed a space or value yet", async () => { - const command = `${commandPrefix} ${commandName} --port=`; + const command = `${commandPrefix} dev --port=`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); @@ -116,13 +228,13 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { describe.runIf(!shouldSkipTest)('short flag handling', () => { it('should handle short flag value completion', async () => { - const command = `${commandPrefix} ${commandName} -p `; + const command = `${commandPrefix} dev -p `; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); it('should handle short flag with equals sign', async () => { - const command = `${commandPrefix} ${commandName} -p=3`; + const command = `${commandPrefix} dev -p=3`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); @@ -140,7 +252,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe.runIf(!shouldSkipTest && cliTool !== 'citty')( + describe.runIf(!shouldSkipTest)( 'positional argument completions', () => { it('should complete multiple positional arguments when ending with space', async () => { @@ -162,6 +274,64 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); } ); + + describe.runIf(!shouldSkipTest)( + 'copy command argument handlers', + () => { + it('should complete source argument with directory suggestions', async () => { + const command = `${commandPrefix} copy ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete destination argument with build suggestions', async () => { + const command = `${commandPrefix} copy src/ ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should filter source suggestions when typing partial input', async () => { + const command = `${commandPrefix} copy s`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should filter destination suggestions when typing partial input', async () => { + const command = `${commandPrefix} copy src/ b`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + } + ); + + describe.runIf(!shouldSkipTest)( + 'lint command argument handlers', + () => { + it('should complete files argument with file suggestions', async () => { + const command = `${commandPrefix} lint ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should filter file suggestions when typing partial input', async () => { + const command = `${commandPrefix} lint m`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should continue completing variadic files argument after first file', async () => { + const command = `${commandPrefix} lint main.ts ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should continue completing variadic suggestions after first file', async () => { + const command = `${commandPrefix} lint main.ts i`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + } + ); }); // Add specific tests for Commander From 18814e6a1495fc5a8df8c9266e7155b079cd4521 Mon Sep 17 00:00:00 2001 From: Mohammad Bagher Abiyat <37929992+Aslemammad@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:15:17 +0330 Subject: [PATCH 09/10] grammar issues --- README.2.md | 69 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/README.2.md b/README.2.md index b2ad7f4..98fb20b 100644 --- a/README.2.md +++ b/README.2.md @@ -1,12 +1,12 @@ -> a video showcasing how pnpm autocompletions works on a test cli command like `my-cli` +> A video showcasing how pnpm autocompletions work on a test CLI command like `my-cli` # tab -> instant feedback when hitting [TAB] in your cli tool +> Instant feedback for your CLI tool when hitting [TAB] in your terminal -as cli tooling authors, if we can spare our users a second or two by not checking the documentation or writing the `-h` option, we're doing them a huge favor. the unconscious loves hitting the [TAB] key. it always expects feedback. so it feels dissappointing when hitting that key in the terminal but then nothing happens. that frustration is apparent across the whole javascript cli tooling ecosystem. +As CLI tooling authors, if we can spare our users a second or two by not checking the documentation or writing the `-h` option, we're doing them a huge favor. The unconscious loves hitting the [TAB] key. It always expects feedback. So it feels disappointing when hitting that key in the terminal but then nothing happens. That frustration is apparent across the whole JavaScript CLI tooling ecosystem. -autocompletions are the solution to not break the user's flow. the issue is they're not simple to add. `zsh` expects them in a way, and `bash` in another way. then where do we provide them so the shell environment parses them? too many headaches to ease the user's experience. whether it's worth it or not is out of the question. because tab is the solution to this complexity. +Autocompletions are the solution to not break the user's flow. The issue is they're not simple to add. `zsh` expects them in one way, and `bash` in another way. Then where do we provide them so the shell environment parses them? Too many headaches to ease the user's experience. Whether it's worth it or not is out of the question. Because tab is the solution to this complexity. `my-cli.ts`: ```typescript @@ -26,7 +26,7 @@ if (process.argv[2] === 'complete') { } ``` -this `my-cli.ts` would be equipped with all the tools required to provide autocompletions. +This `my-cli.ts` would be equipped with all the tools required to provide autocompletions. ```bash node my-cli.ts complete -- "st" @@ -36,57 +36,70 @@ start start the development server :0 ``` -this output was generated by the `t.parse` method to autocomplete "st" to "start". +This output was generated by the `t.parse` method to autocomplete "st" to "start". -obviously, the user won't be running that command directly in their terminal. they'd be running something like this. +Obviously, the user won't be running that command directly in their terminal. They'd be running something like this. ```bash source <(node my-cli.ts complete zsh) ``` -now whenever the shell sees `my-cli`, it would bring the autocompletions we wrote for this cli tool. the `node my-cli.ts complete zsh` part would output the zsh script that loads the autocompletions provided by `t.parse` which then would be executed using `source`. +Now whenever the shell sees `my-cli`, it would bring the autocompletions we wrote for this CLI tool. The `node my-cli.ts complete zsh` part would output the zsh script that loads the autocompletions provided by `t.parse` which then would be executed using `source`. -the autocompletions are only lived through the current session. to set them up across all of terminal sessions, the autocompletion script should be injected in the `.zshrc` file. +The autocompletions only live through the current shell session. To set them up across all terminal sessions, the autocompletion script should be injected in the `.zshrc` file. ```bash my-cli complete zsh > ~/completion-for-my-cli.zsh && echo 'source ~/completion-for-my-cli.zsh' >> ~/.zshrc ``` -this is an example of autocompletions on a global cli command that is usually installed using the `-g` flag (e.g. `npm add -g my-cli`) which is available across the computer. +Or + +```bash +echo 'source <(npx --offline my-cli complete zsh)' >> ~/.zshrc +``` + +This is an example of autocompletions on a global CLI command that is usually installed using the `-g` flag (e.g. `npm add -g my-cli`) which is available across the computer. --- -while working on tab, we came to the realization that most javascript clis are not global cli commands but rather, per-project dependencies. +While working on tab, we came to the realization that most JavaScript CLIs are not global CLI commands but rather, per-project dependencies. -for instance, vite won't be installed globally and instead it'd be always installed on a project. here's an example usage: +For instance, Vite won't be installed globally and instead it'd be always installed on a project. Here's an example usage: ```bash -pnpm vite -h +pnpm vite dev ``` -so in this case, a computer might have hundreds of vite instances each installed separately and potentially from different versions on different projects. +Rather than installing it globally. This example is pretty rare: -we were looking for a fluid strategy that would be able to load the autocompletions from each of these dependencies on a per-project basis. +```bash +vite dev +``` + +So in this case, a computer might have hundreds of Vite instances each installed separately and potentially from different versions on different projects. -and that made us develop our own autocompletion abstraction over npm, pnpm and yarn. this would help tab identify which binaries are avaialble in a project and which of these binaries provide autocompletions. so the user would not have to `source` anything or inject any script in their `.zshrc`. +We were looking for a fluid strategy that would be able to load the autocompletions from each of these dependencies on a per-project basis. -they'd only have to run this command once and inject it in their shell config. +And that made us develop our own autocompletion abstraction over npm, pnpm and yarn. This would help tab identify which binaries are available in a project and which of these binaries provide autocompletions. So the user would not have to `source` anything or inject any script in their `.zshrc`. + +They'd only have to run this command once and inject it in their shell config. ```bash -echo 'eval "$(npx --prefer-offline @bombsh/tab pnpm zsh)"' >> ~/.zshrc +npx @bombsh/tab pnpm zsh ``` ---- +These autocompletions on top of the normal autocompletions that these package managers provide are going to be way more powerful. -```typescript -import t from '@bombsh/tab' - -t.option('help', 'list available commands') // Command (Root) +These new autocompletions on top of package managers would help us with autocompletions on commands like `pnpm vite` and other global or per-project binaries. The only requirement would be that the npm binary itself would be a tab-compatible binary. -t.command('start', 'start the development server') // Command ('start') - .option('port', 'specify the port number') // Command ('port') +What is a tab-compatible binary? It's a tool that provides the `complete` subcommand that was showcased above. Basically any CLI tool that uses tab for its autocompletions is a tab-compatible binary. -t.parse(process.argv.slice(3)) +```bash +pnpm my-cli complete -- +``` +``` +start start the development server +:0 +``` -t.setup(process.argv[2], x) -``` \ No newline at end of file +We are planning to maintain these package manager autocompletions on our own and turn them into full-fledged autocompletions that touch on every part of our package managers. From 38de56630c1678408a7407f36ef32ea046e0d8cc Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Mon, 4 Aug 2025 19:03:14 +0330 Subject: [PATCH 10/10] prettier --- README.2.md | 47 +-- README.md | 1 - bin/completion-handlers.ts | 4 +- examples/demo.cac.ts | 42 ++- examples/demo.citty.ts | 25 +- examples/demo.t.ts | 138 ++++--- src/cac.ts | 15 +- src/citty.ts | 44 ++- src/fig.ts | 2 +- src/index.ts | 4 +- src/shared.ts | 2 +- src/t.ts | 711 ++++++++++++++++++++----------------- tests/cli.test.ts | 133 ++++--- 13 files changed, 644 insertions(+), 524 deletions(-) diff --git a/README.2.md b/README.2.md index 98fb20b..83aea61 100644 --- a/README.2.md +++ b/README.2.md @@ -2,46 +2,48 @@ # tab -> Instant feedback for your CLI tool when hitting [TAB] in your terminal +> Instant feedback for your CLI tool when hitting [TAB] in your terminal As CLI tooling authors, if we can spare our users a second or two by not checking the documentation or writing the `-h` option, we're doing them a huge favor. The unconscious loves hitting the [TAB] key. It always expects feedback. So it feels disappointing when hitting that key in the terminal but then nothing happens. That frustration is apparent across the whole JavaScript CLI tooling ecosystem. -Autocompletions are the solution to not break the user's flow. The issue is they're not simple to add. `zsh` expects them in one way, and `bash` in another way. Then where do we provide them so the shell environment parses them? Too many headaches to ease the user's experience. Whether it's worth it or not is out of the question. Because tab is the solution to this complexity. +Autocompletions are the solution to not break the user's flow. The issue is they're not simple to add. `zsh` expects them in one way, and `bash` in another way. Then where do we provide them so the shell environment parses them? Too many headaches to ease the user's experience. Whether it's worth it or not is out of the question. Because tab is the solution to this complexity. `my-cli.ts`: + ```typescript -import t from '@bombsh/tab' +import t from '@bombsh/tab'; -t.name('my-cli') +t.name('my-cli'); -t.command('start', 'start the development server') +t.command('start', 'start the development server'); if (process.argv[2] === 'complete') { - const [shell, ...args] = process.argv.slice(3) + const [shell, ...args] = process.argv.slice(3); if (shell === '--') { - t.parse(args) + t.parse(args); } else { - t.setup(shell, x) + t.setup(shell, x); } } ``` -This `my-cli.ts` would be equipped with all the tools required to provide autocompletions. +This `my-cli.ts` would be equipped with all the tools required to provide autocompletions. ```bash node my-cli.ts complete -- "st" ``` + ``` start start the development server :0 ``` -This output was generated by the `t.parse` method to autocomplete "st" to "start". +This output was generated by the `t.parse` method to autocomplete "st" to "start". -Obviously, the user won't be running that command directly in their terminal. They'd be running something like this. +Obviously, the user won't be running that command directly in their terminal. They'd be running something like this. ```bash -source <(node my-cli.ts complete zsh) +source <(node my-cli.ts complete zsh) ``` Now whenever the shell sees `my-cli`, it would bring the autocompletions we wrote for this CLI tool. The `node my-cli.ts complete zsh` part would output the zsh script that loads the autocompletions provided by `t.parse` which then would be executed using `source`. @@ -52,17 +54,17 @@ The autocompletions only live through the current shell session. To set them up my-cli complete zsh > ~/completion-for-my-cli.zsh && echo 'source ~/completion-for-my-cli.zsh' >> ~/.zshrc ``` -Or +Or ```bash echo 'source <(npx --offline my-cli complete zsh)' >> ~/.zshrc ``` -This is an example of autocompletions on a global CLI command that is usually installed using the `-g` flag (e.g. `npm add -g my-cli`) which is available across the computer. +This is an example of autocompletions on a global CLI command that is usually installed using the `-g` flag (e.g. `npm add -g my-cli`) which is available across the computer. --- -While working on tab, we came to the realization that most JavaScript CLIs are not global CLI commands but rather, per-project dependencies. +While working on tab, we came to the realization that most JavaScript CLIs are not global CLI commands but rather, per-project dependencies. For instance, Vite won't be installed globally and instead it'd be always installed on a project. Here's an example usage: @@ -70,19 +72,19 @@ For instance, Vite won't be installed globally and instead it'd be always instal pnpm vite dev ``` -Rather than installing it globally. This example is pretty rare: +Rather than installing it globally. This example is pretty rare: ```bash vite dev ``` -So in this case, a computer might have hundreds of Vite instances each installed separately and potentially from different versions on different projects. +So in this case, a computer might have hundreds of Vite instances each installed separately and potentially from different versions on different projects. -We were looking for a fluid strategy that would be able to load the autocompletions from each of these dependencies on a per-project basis. +We were looking for a fluid strategy that would be able to load the autocompletions from each of these dependencies on a per-project basis. -And that made us develop our own autocompletion abstraction over npm, pnpm and yarn. This would help tab identify which binaries are available in a project and which of these binaries provide autocompletions. So the user would not have to `source` anything or inject any script in their `.zshrc`. +And that made us develop our own autocompletion abstraction over npm, pnpm and yarn. This would help tab identify which binaries are available in a project and which of these binaries provide autocompletions. So the user would not have to `source` anything or inject any script in their `.zshrc`. -They'd only have to run this command once and inject it in their shell config. +They'd only have to run this command once and inject it in their shell config. ```bash npx @bombsh/tab pnpm zsh @@ -90,16 +92,17 @@ npx @bombsh/tab pnpm zsh These autocompletions on top of the normal autocompletions that these package managers provide are going to be way more powerful. -These new autocompletions on top of package managers would help us with autocompletions on commands like `pnpm vite` and other global or per-project binaries. The only requirement would be that the npm binary itself would be a tab-compatible binary. +These new autocompletions on top of package managers would help us with autocompletions on commands like `pnpm vite` and other global or per-project binaries. The only requirement would be that the npm binary itself would be a tab-compatible binary. What is a tab-compatible binary? It's a tool that provides the `complete` subcommand that was showcased above. Basically any CLI tool that uses tab for its autocompletions is a tab-compatible binary. ```bash pnpm my-cli complete -- ``` + ``` start start the development server :0 ``` -We are planning to maintain these package manager autocompletions on our own and turn them into full-fledged autocompletions that touch on every part of our package managers. +We are planning to maintain these package manager autocompletions on our own and turn them into full-fledged autocompletions that touch on every part of our package managers. diff --git a/README.md b/README.md index a412145..d8bf104 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - # tab Shell autocompletions are largely missing in the javascript cli ecosystem. This tool is an attempt to make autocompletions come out of the box for any cli tool. diff --git a/bin/completion-handlers.ts b/bin/completion-handlers.ts index 5c95a2e..20b8496 100644 --- a/bin/completion-handlers.ts +++ b/bin/completion-handlers.ts @@ -1,4 +1,4 @@ -// TODO: i do not see any completion functionality in this file. nothing is being provided for the defined commands of these package managers. this is a blocker for release. every each of them should be handled. +// TODO: i do not see any completion functionality in this file. nothing is being provided for the defined commands of these package managers. this is a blocker for release. every each of them should be handled. import { Completion } from '../src/index.js'; import { execSync } from 'child_process'; @@ -72,7 +72,7 @@ export function setupCompletionForPackageManager( setupBunCompletions(completion); } - // TODO: the core functionality of tab should have nothing related to package managers. even though completion is not there anymore, but this is something to consider. + // TODO: the core functionality of tab should have nothing related to package managers. even though completion is not there anymore, but this is something to consider. completion.setPackageManager(packageManager); } diff --git a/examples/demo.cac.ts b/examples/demo.cac.ts index 5406d30..3297fe3 100644 --- a/examples/demo.cac.ts +++ b/examples/demo.cac.ts @@ -9,8 +9,6 @@ cli .option('-m, --mode ', `Set env mode`) .option('-l, --logLevel ', `info | warn | error | silent`); - - cli .command('dev', 'Start dev server') .option('-H, --host [host]', `Specify hostname`) @@ -25,7 +23,9 @@ cli cli.command('dev build', 'Build project').action((options) => {}); -cli.command('copy ', 'Copy files').action((source, destination, options) => {}); +cli + .command('copy ', 'Copy files') + .action((source, destination, options) => {}); cli.command('lint [...files]', 'Lint project').action((files, options) => {}); @@ -35,12 +35,12 @@ await tab(cli, { subCommands: { copy: { args: { - source: function(complete) { + source: function (complete) { complete('src/', 'Source directory'); complete('dist/', 'Distribution directory'); complete('public/', 'Public assets'); }, - destination: function(complete) { + destination: function (complete) { complete('build/', 'Build output'); complete('release/', 'Release directory'); complete('backup/', 'Backup location'); @@ -49,7 +49,7 @@ await tab(cli, { }, lint: { args: { - files: function(complete) { + files: function (complete) { complete('main.ts', 'Main file'); complete('index.ts', 'Index file'); }, @@ -57,11 +57,19 @@ await tab(cli, { }, dev: { options: { - port: function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap) { + port: function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap + ) { complete('3000', 'Development server port'); complete('8080', 'Alternative port'); }, - host: function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap) { + host: function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap + ) { complete('localhost', 'Localhost'); complete('0.0.0.0', 'All interfaces'); }, @@ -69,15 +77,27 @@ await tab(cli, { }, }, options: { - config: function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap) { + config: function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap + ) { complete('vite.config.ts', 'Vite config file'); complete('vite.config.js', 'Vite config file'); }, - mode: function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap) { + mode: function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap + ) { complete('development', 'Development mode'); complete('production', 'Production mode'); }, - logLevel: function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap) { + logLevel: function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap + ) { complete('info', 'Info level'); complete('warn', 'Warn level'); complete('error', 'Error level'); diff --git a/examples/demo.citty.ts b/examples/demo.citty.ts index 55a4646..2442178 100644 --- a/examples/demo.citty.ts +++ b/examples/demo.citty.ts @@ -1,4 +1,9 @@ -import { defineCommand, createMain, type CommandDef, type ArgsDef } from 'citty'; +import { + defineCommand, + createMain, + type CommandDef, + type ArgsDef, +} from 'citty'; import tab from '../src/citty'; const main = defineCommand({ @@ -104,22 +109,22 @@ main.subCommands = { const completion = await tab(main, { args: { - project: function(complete) { + project: function (complete) { complete('my-app', 'My application'); complete('my-lib', 'My library'); complete('my-tool', 'My tool'); }, }, options: { - config: function(this: any, complete) { + config: function (this: any, complete) { complete('vite.config.ts', 'Vite config file'); complete('vite.config.js', 'Vite config file'); }, - mode: function(this: any, complete) { + mode: function (this: any, complete) { complete('development', 'Development mode'); complete('production', 'Production mode'); }, - logLevel: function(this: any, complete) { + logLevel: function (this: any, complete) { complete('info', 'Info level'); complete('warn', 'Warn level'); complete('error', 'Error level'); @@ -130,12 +135,12 @@ const completion = await tab(main, { subCommands: { copy: { args: { - source: function(complete) { + source: function (complete) { complete('src/', 'Source directory'); complete('dist/', 'Distribution directory'); complete('public/', 'Public assets'); }, - destination: function(complete) { + destination: function (complete) { complete('build/', 'Build output'); complete('release/', 'Release directory'); complete('backup/', 'Backup location'); @@ -144,7 +149,7 @@ const completion = await tab(main, { }, lint: { args: { - files: function(complete) { + files: function (complete) { complete('main.ts', 'Main file'); complete('index.ts', 'Index file'); }, @@ -152,11 +157,11 @@ const completion = await tab(main, { }, dev: { options: { - port: function(this: any, complete) { + port: function (this: any, complete) { complete('3000', 'Development server port'); complete('8080', 'Alternative port'); }, - host: function(this: any, complete) { + host: function (this: any, complete) { complete('localhost', 'Localhost'); complete('0.0.0.0', 'All interfaces'); }, diff --git a/examples/demo.t.ts b/examples/demo.t.ts index 61b1392..21f277d 100644 --- a/examples/demo.t.ts +++ b/examples/demo.t.ts @@ -1,92 +1,132 @@ import t from '../src/t'; // Global options -t.option('config', 'Use specified config file', function(complete) { +t.option( + 'config', + 'Use specified config file', + function (complete) { complete('vite.config.ts', 'Vite config file'); complete('vite.config.js', 'Vite config file'); -}, 'c'); + }, + 'c' +); -t.option('mode', 'Set env mode', function(complete) { +t.option( + 'mode', + 'Set env mode', + function (complete) { complete('development', 'Development mode'); complete('production', 'Production mode'); -}, 'm'); + }, + 'm' +); -t.option('logLevel', 'info | warn | error | silent', function(complete) { +t.option( + 'logLevel', + 'info | warn | error | silent', + function (complete) { complete('info', 'Info level'); complete('warn', 'Warn level'); complete('error', 'Error level'); complete('silent', 'Silent level'); -}, 'l'); + }, + 'l' +); // Root command argument -t.argument('project', function(complete) { - complete('my-app', 'My application'); - complete('my-lib', 'My library'); - complete('my-tool', 'My tool'); +t.argument('project', function (complete) { + complete('my-app', 'My application'); + complete('my-lib', 'My library'); + complete('my-tool', 'My tool'); }); // Dev command const devCmd = t.command('dev', 'Start dev server'); -devCmd.option('host', 'Specify hostname', function(complete) { +devCmd.option( + 'host', + 'Specify hostname', + function (complete) { complete('localhost', 'Localhost'); complete('0.0.0.0', 'All interfaces'); -}, 'H'); + }, + 'H' +); -devCmd.option('port', 'Specify port', function(complete) { +devCmd.option( + 'port', + 'Specify port', + function (complete) { complete('3000', 'Development server port'); complete('8080', 'Alternative port'); -}, 'p'); + }, + 'p' +); // Serve command const serveCmd = t.command('serve', 'Start the server'); -serveCmd.option('host', 'Specify hostname', function(complete) { +serveCmd.option( + 'host', + 'Specify hostname', + function (complete) { complete('localhost', 'Localhost'); complete('0.0.0.0', 'All interfaces'); -}, 'H'); + }, + 'H' +); -serveCmd.option('port', 'Specify port', function(complete) { +serveCmd.option( + 'port', + 'Specify port', + function (complete) { complete('3000', 'Development server port'); complete('8080', 'Alternative port'); -}, 'p'); + }, + 'p' +); // Build command t.command('dev build', 'Build project'); // Copy command with multiple arguments -const copyCmd = t.command('copy', 'Copy files') - .argument('source', function(complete) { - complete('src/', 'Source directory'); - complete('dist/', 'Distribution directory'); - complete('public/', 'Public assets'); - }) - .argument('destination', function(complete) { - complete('build/', 'Build output'); - complete('release/', 'Release directory'); - complete('backup/', 'Backup location'); - }); +const copyCmd = t + .command('copy', 'Copy files') + .argument('source', function (complete) { + complete('src/', 'Source directory'); + complete('dist/', 'Distribution directory'); + complete('public/', 'Public assets'); + }) + .argument('destination', function (complete) { + complete('build/', 'Build output'); + complete('release/', 'Release directory'); + complete('backup/', 'Backup location'); + }); // Lint command with variadic arguments -const lintCmd = t.command('lint', 'Lint project') - .argument('files', function(complete) { - complete('main.ts', 'Main file'); - complete('index.ts', 'Index file'); - complete('src/', 'Source directory'); - complete('tests/', 'Tests directory'); - }, true); // Variadic argument for multiple files +const lintCmd = t.command('lint', 'Lint project').argument( + 'files', + function (complete) { + complete('main.ts', 'Main file'); + complete('index.ts', 'Index file'); + complete('src/', 'Source directory'); + complete('tests/', 'Tests directory'); + }, + true +); // Variadic argument for multiple files // Handle completion command if (process.argv[2] === 'complete') { - const shell = process.argv[3]; - if (shell && ['zsh', 'bash', 'fish', 'powershell'].includes(shell)) { - t.setup('vite', 'pnpm tsx examples/demo.t.ts', shell); - } else { - // Parse completion arguments (everything after --) - const separatorIndex = process.argv.indexOf('--'); - const completionArgs = separatorIndex !== -1 ? process.argv.slice(separatorIndex + 1) : []; - t.parse(completionArgs); - } + const shell = process.argv[3]; + if (shell && ['zsh', 'bash', 'fish', 'powershell'].includes(shell)) { + t.setup('vite', 'pnpm tsx examples/demo.t.ts', shell); + } else { + // Parse completion arguments (everything after --) + const separatorIndex = process.argv.indexOf('--'); + const completionArgs = + separatorIndex !== -1 ? process.argv.slice(separatorIndex + 1) : []; + t.parse(completionArgs); + } } else { - // Regular CLI usage (just show help for demo) - console.log('Vite CLI Demo'); - console.log('Use "complete" command for shell completion'); -} \ No newline at end of file + // Regular CLI usage (just show help for demo) + console.log('Vite CLI Demo'); + console.log('Use "complete" command for shell completion'); +} diff --git a/src/cac.ts b/src/cac.ts index 28515b8..d69f60a 100644 --- a/src/cac.ts +++ b/src/cac.ts @@ -8,9 +8,7 @@ import { OptionHandler } from './t'; import { CompletionConfig } from './shared'; import t from './t'; - - -const noopOptionHandler: OptionHandler = function() {}; +const noopOptionHandler: OptionHandler = function () {}; const execPath = process.execPath; const processArgs = process.argv.slice(1); @@ -24,8 +22,6 @@ function quoteIfNeeded(path: string): string { return path.includes(' ') ? `'${path}'` : path; } - - export default async function tab( instance: CAC, completionConfig?: CompletionConfig @@ -46,15 +42,16 @@ export default async function tab( // Add command to completion using t.ts API const commandName = isRootCommand ? '' : cmd.name; - const command = isRootCommand - ? t + const command = isRootCommand + ? t : t.command(commandName, cmd.description || ''); // Set args for the command if (command) { // Extract argument names from command usage - const argMatches = cmd.rawName.match(/<([^>]+)>|\[\.\.\.([^\]]+)\]/g) || []; - const argNames = argMatches.map(match => { + const argMatches = + cmd.rawName.match(/<([^>]+)>|\[\.\.\.([^\]]+)\]/g) || []; + const argNames = argMatches.map((match) => { if (match.startsWith('<') && match.endsWith('>')) { return match.slice(1, -1); // Remove < > } else if (match.startsWith('[...') && match.endsWith(']')) { diff --git a/src/citty.ts b/src/citty.ts index 84878ee..731a8eb 100644 --- a/src/citty.ts +++ b/src/citty.ts @@ -32,14 +32,24 @@ function isConfigPositional(config: CommandDef) { ); } - - // Convert Handler from index.ts to OptionHandler from t.ts function convertOptionHandler(handler: any): OptionHandler { - return function(this: Option, complete: (value: string, description: string) => void, options: OptionsMap, previousArgs?: string[], toComplete?: string, endsWithSpace?: boolean) { + return function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap, + previousArgs?: string[], + toComplete?: string, + endsWithSpace?: boolean + ) { // For short flags with equals sign and a value, don't complete (citty behavior) // Check if this is a short flag option and if the toComplete looks like a value - if (this.alias && toComplete && toComplete !== '' && !toComplete.startsWith('-')) { + if ( + this.alias && + toComplete && + toComplete !== '' && + !toComplete.startsWith('-') + ) { // This might be a short flag with equals sign and a value // Check if the previous args contain a short flag with equals sign if (previousArgs && previousArgs.length > 0) { @@ -52,24 +62,30 @@ function convertOptionHandler(handler: any): OptionHandler { } } } - + // Call the old handler with the proper context - const result = handler(previousArgs || [], toComplete || '', endsWithSpace || false); - + const result = handler( + previousArgs || [], + toComplete || '', + endsWithSpace || false + ); + if (Array.isArray(result)) { - result.forEach((item: any) => complete(item.value, item.description || '')); + result.forEach((item: any) => + complete(item.value, item.description || '') + ); } else if (result && typeof result.then === 'function') { // Handle async handlers result.then((items: any[]) => { - items.forEach((item: any) => complete(item.value, item.description || '')); + items.forEach((item: any) => + complete(item.value, item.description || '') + ); }); } }; } - - -const noopOptionHandler: OptionHandler = function() {}; +const noopOptionHandler: OptionHandler = function () {}; async function handleSubCommands( subCommands: SubCommandsDef, @@ -86,7 +102,7 @@ async function handleSubCommands( throw new Error('Invalid meta or missing description.'); } const isPositional = isConfigPositional(config); - + // Add command using t.ts API const commandName = parentCmd ? `${parentCmd} ${cmd}` : cmd; const command = t.command(cmd, meta.description); @@ -165,7 +181,7 @@ export default async function tab( } const isPositional = isConfigPositional(instance); - + // Set args for the root command if it has positional arguments if (isPositional && instance.args) { for (const [argName, argConfig] of Object.entries(instance.args)) { diff --git a/src/fig.ts b/src/fig.ts index 325fb08..7fa675e 100644 --- a/src/fig.ts +++ b/src/fig.ts @@ -114,7 +114,7 @@ async function processCommand( return spec; } -// TODO: this should be an extension of t.setup function and not something like this. +// TODO: this should be an extension of t.setup function and not something like this. export async function generateFigSpec( command: CommandDef ): Promise { diff --git a/src/index.ts b/src/index.ts index 21a51da..bcd16d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -119,8 +119,6 @@ export type Positional = { completion: Handler; }; - - type CompletionResult = { items: CompletionItem[]; suppressDefault: boolean; @@ -239,7 +237,7 @@ export class Completion { async parse(args: string[]) { this.result = { items: [], suppressDefault: false }; - // TODO: i did not notice this, this should not be handled here at all. package manager completions are something on top of this. just like any other completion system that is going to be built on top of tab. + // TODO: i did not notice this, this should not be handled here at all. package manager completions are something on top of this. just like any other completion system that is going to be built on top of tab. // Handle package manager completions first if (this.packageManager && args.length >= 1) { const potentialCliName = args[0]; diff --git a/src/shared.ts b/src/shared.ts index 24d0727..e815b9d 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,6 +1,6 @@ import { OptionHandler, ArgumentHandler } from './t'; -export const noopHandler: OptionHandler = function() { +export const noopHandler: OptionHandler = function () { // No-op handler for options }; diff --git a/src/t.ts b/src/t.ts index e88269e..5ecdada 100644 --- a/src/t.ts +++ b/src/t.ts @@ -10,11 +10,15 @@ const ShellCompDirective = { ShellCompDirectiveDefault: 0, }; -export type OptionsMap = Map +export type OptionsMap = Map; -type Complete = (value: string, description: string) => void +type Complete = (value: string, description: string) => void; -export type OptionHandler = (this: Option, complete: Complete, options: OptionsMap) => void +export type OptionHandler = ( + this: Option, + complete: Complete, + options: OptionsMap +) => void; // Completion result types export type Completion = { @@ -22,375 +26,422 @@ export type Completion = { value: string; }; -export type ArgumentHandler = (this: Argument, complete: Complete, options: OptionsMap) => void +export type ArgumentHandler = ( + this: Argument, + complete: Complete, + options: OptionsMap +) => void; export class Argument { - name: string - variadic: boolean - command: Command - handler?: ArgumentHandler - - constructor(command: Command, name: string, handler?: ArgumentHandler, variadic: boolean = false) { - this.command = command - this.name = name - this.handler = handler - this.variadic = variadic - } + name: string; + variadic: boolean; + command: Command; + handler?: ArgumentHandler; + + constructor( + command: Command, + name: string, + handler?: ArgumentHandler, + variadic: boolean = false + ) { + this.command = command; + this.name = name; + this.handler = handler; + this.variadic = variadic; + } } export class Option { - value: string - description: string - command: Command - handler?: OptionHandler + value: string; + description: string; + command: Command; + handler?: OptionHandler; + alias?: string; + // TODO: handle boolean options + + constructor( + command: Command, + value: string, + description: string, + handler?: OptionHandler, alias?: string - // TODO: handle boolean options - - constructor(command: Command, value: string, description: string, handler?: OptionHandler, alias?: string) { - this.command = command - this.value = value - this.description = description - this.handler = handler - this.alias = alias - } + ) { + this.command = command; + this.value = value; + this.description = description; + this.handler = handler; + this.alias = alias; + } } export class Command { - value: string - description: string - options = new Map - arguments = new Map - parent?: Command - - constructor(value: string, description: string) { - this.value = value - this.description = description - } - - option(value: string, description: string, handler?: OptionHandler, alias?: string) { - const option = new Option(this, value, description, handler, alias) - this.options.set(value, option) - return this - } - - argument(name: string, handler?: ArgumentHandler, variadic: boolean = false) { - const arg = new Argument(this, name, handler, variadic) - this.arguments.set(name, arg) - return this - } + value: string; + description: string; + options = new Map(); + arguments = new Map(); + parent?: Command; + + constructor(value: string, description: string) { + this.value = value; + this.description = description; + } + + option( + value: string, + description: string, + handler?: OptionHandler, + alias?: string + ) { + const option = new Option(this, value, description, handler, alias); + this.options.set(value, option); + return this; + } + + argument(name: string, handler?: ArgumentHandler, variadic: boolean = false) { + const arg = new Argument(this, name, handler, variadic); + this.arguments.set(name, arg); + return this; + } } import * as zsh from './zsh'; import * as bash from './bash'; import * as fish from './fish'; import * as powershell from './powershell'; -import assert from 'node:assert' +import assert from 'node:assert'; export class RootCommand extends Command { - commands = new Map - completions: Completion[] = []; - directive = ShellCompDirective.ShellCompDirectiveDefault; - - constructor() { - super('', '') - } - - command(value: string, description: string) { - const c = new Command(value, description) - this.commands.set(value, c) - return c - } - - // Utility method to strip options from args for command matching - private stripOptions(args: string[]): string[] { - const parts: string[] = []; - let i = 0; - - while (i < args.length) { - const arg = args[i]; - - if (arg.startsWith('-')) { - i++; // Skip the option - if (i < args.length && !args[i].startsWith('-')) { - i++; // Skip the option value - } - } else { - parts.push(arg); - i++; - } + commands = new Map(); + completions: Completion[] = []; + directive = ShellCompDirective.ShellCompDirectiveDefault; + + constructor() { + super('', ''); + } + + command(value: string, description: string) { + const c = new Command(value, description); + this.commands.set(value, c); + return c; + } + + // Utility method to strip options from args for command matching + private stripOptions(args: string[]): string[] { + const parts: string[] = []; + let i = 0; + + while (i < args.length) { + const arg = args[i]; + + if (arg.startsWith('-')) { + i++; // Skip the option + if (i < args.length && !args[i].startsWith('-')) { + i++; // Skip the option value } - - return parts; + } else { + parts.push(arg); + i++; + } } - // Find the appropriate command based on args - private matchCommand(args: string[]): [Command, string[]] { - args = this.stripOptions(args); - const parts: string[] = []; - let remaining: string[] = []; - let matched: Command = this; - - for (let i = 0; i < args.length; i++) { - const k = args[i]; - parts.push(k); - const potential = this.commands.get(parts.join(' ')); - - if (potential) { - matched = potential; - } else { - remaining = args.slice(i, args.length); - break; - } - } - - return [matched, remaining]; + return parts; + } + + // Find the appropriate command based on args + private matchCommand(args: string[]): [Command, string[]] { + args = this.stripOptions(args); + const parts: string[] = []; + let remaining: string[] = []; + let matched: Command = this; + + for (let i = 0; i < args.length; i++) { + const k = args[i]; + parts.push(k); + const potential = this.commands.get(parts.join(' ')); + + if (potential) { + matched = potential; + } else { + remaining = args.slice(i, args.length); + break; + } } - // Determine if we should complete flags - private shouldCompleteFlags( - lastPrevArg: string | undefined, - toComplete: string, - endsWithSpace: boolean - ): boolean { - return lastPrevArg?.startsWith('-') || toComplete.startsWith('-'); + return [matched, remaining]; + } + + // Determine if we should complete flags + private shouldCompleteFlags( + lastPrevArg: string | undefined, + toComplete: string, + endsWithSpace: boolean + ): boolean { + return lastPrevArg?.startsWith('-') || toComplete.startsWith('-'); + } + + // Determine if we should complete commands + private shouldCompleteCommands( + toComplete: string, + endsWithSpace: boolean + ): boolean { + return !toComplete.startsWith('-'); + } + + // Handle flag completion (names and values) + private handleFlagCompletion( + command: Command, + previousArgs: string[], + toComplete: string, + endsWithSpace: boolean, + lastPrevArg: string | undefined + ) { + // Handle flag value completion + let optionName: string | undefined; + let valueToComplete = toComplete; + + if (toComplete.includes('=')) { + const [flag, value] = toComplete.split('='); + optionName = flag; + valueToComplete = value || ''; + } else if (lastPrevArg?.startsWith('-')) { + optionName = lastPrevArg; } - // Determine if we should complete commands - private shouldCompleteCommands( - toComplete: string, - endsWithSpace: boolean - ): boolean { - return !toComplete.startsWith('-'); + if (optionName) { + const option = this.findOption(command, optionName); + if (option?.handler) { + const suggestions: Completion[] = []; + option.handler.call( + option, + (value: string, description: string) => + suggestions.push({ value, description }), + command.options + ); + + this.completions = toComplete.includes('=') + ? suggestions.map((s) => ({ + value: `${optionName}=${s.value}`, + description: s.description, + })) + : suggestions; + } + return; } - // Handle flag completion (names and values) - private handleFlagCompletion( - command: Command, - previousArgs: string[], - toComplete: string, - endsWithSpace: boolean, - lastPrevArg: string | undefined - ) { - // Handle flag value completion - let optionName: string | undefined; - let valueToComplete = toComplete; - - if (toComplete.includes('=')) { - const [flag, value] = toComplete.split('='); - optionName = flag; - valueToComplete = value || ''; - } else if (lastPrevArg?.startsWith('-')) { - optionName = lastPrevArg; + // Handle flag name completion + if (toComplete.startsWith('-')) { + const isShortFlag = + toComplete.startsWith('-') && !toComplete.startsWith('--'); + const cleanToComplete = toComplete.replace(/^-+/, ''); + + for (const [name, option] of command.options) { + if ( + isShortFlag && + option.alias && + `-${option.alias}`.startsWith(toComplete) + ) { + this.completions.push({ + value: `-${option.alias}`, + description: option.description, + }); + } else if (!isShortFlag && name.startsWith(cleanToComplete)) { + this.completions.push({ + value: `--${name}`, + description: option.description, + }); } + } + } + } + + // Helper method to find an option by name or alias + private findOption(command: Command, optionName: string): Option | undefined { + // Try direct match (with dashes) + let option = command.options.get(optionName); + if (option) return option; + + // Try without dashes (the actual storage format) + option = command.options.get(optionName.replace(/^-+/, '')); + if (option) return option; + + // Try by short alias + for (const [name, opt] of command.options) { + if (opt.alias && `-${opt.alias}` === optionName) { + return opt; + } + } - if (optionName) { - const option = this.findOption(command, optionName); - if (option?.handler) { - const suggestions: Completion[] = []; - option.handler.call(option, - (value: string, description: string) => suggestions.push({ value, description }), - command.options - ); - - this.completions = toComplete.includes('=') - ? suggestions.map(s => ({ value: `${optionName}=${s.value}`, description: s.description })) - : suggestions; - } - return; - } + return undefined; + } - // Handle flag name completion - if (toComplete.startsWith('-')) { - const isShortFlag = toComplete.startsWith('-') && !toComplete.startsWith('--'); - const cleanToComplete = toComplete.replace(/^-+/, ''); - - for (const [name, option] of command.options) { - if (isShortFlag && option.alias && `-${option.alias}`.startsWith(toComplete)) { - this.completions.push({ value: `-${option.alias}`, description: option.description }); - } else if (!isShortFlag && name.startsWith(cleanToComplete)) { - this.completions.push({ value: `--${name}`, description: option.description }); - } - } - } - } + // Handle command completion + private handleCommandCompletion(previousArgs: string[], toComplete: string) { + const commandParts = previousArgs.filter(Boolean); - // Helper method to find an option by name or alias - private findOption(command: Command, optionName: string): Option | undefined { - // Try direct match (with dashes) - let option = command.options.get(optionName); - if (option) return option; - - // Try without dashes (the actual storage format) - option = command.options.get(optionName.replace(/^-+/, '')); - if (option) return option; - - // Try by short alias - for (const [name, opt] of command.options) { - if (opt.alias && `-${opt.alias}` === optionName) { - return opt; - } - } + for (const [k, command] of this.commands) { + if (k === '') continue; - return undefined; - } + const parts = k.split(' '); + const match = parts + .slice(0, commandParts.length) + .every((part, i) => part === commandParts[i]); - // Handle command completion - private handleCommandCompletion( - previousArgs: string[], - toComplete: string - ) { - const commandParts = previousArgs.filter(Boolean); - - for (const [k, command] of this.commands) { - if (k === '') continue; - - const parts = k.split(' '); - const match = parts.slice(0, commandParts.length).every((part, i) => part === commandParts[i]); - - if (match && parts[commandParts.length]?.startsWith(toComplete)) { - this.completions.push({ - value: parts[commandParts.length], - description: command.description, - }); - } - } + if (match && parts[commandParts.length]?.startsWith(toComplete)) { + this.completions.push({ + value: parts[commandParts.length], + description: command.description, + }); + } } - - // Handle positional argument completion - private handlePositionalCompletion( - command: Command, - previousArgs: string[], - toComplete: string, - endsWithSpace: boolean - ) { - // Get the current argument position (subtract command name) - const commandParts = command.value.split(' ').length; - const currentArgIndex = Math.max(0, previousArgs.length - commandParts); - const argumentEntries = Array.from(command.arguments.entries()); - - // If we have arguments defined - if (argumentEntries.length > 0) { - // Find the appropriate argument for the current position - let targetArgument: Argument | undefined; - - if (currentArgIndex < argumentEntries.length) { - // We're within the defined arguments - const [argName, argument] = argumentEntries[currentArgIndex]; - targetArgument = argument; - } else { - // We're beyond the defined arguments, check if the last argument is variadic - const lastArgument = argumentEntries[argumentEntries.length - 1][1]; - if (lastArgument.variadic) { - targetArgument = lastArgument; - } - } - - // If we found a target argument with a handler, use it - if (targetArgument && targetArgument.handler && typeof targetArgument.handler === 'function') { - const suggestions: Completion[] = []; - targetArgument.handler.call(targetArgument, - (value: string, description: string) => suggestions.push({ value, description }), - command.options - ); - this.completions.push(...suggestions); - } + } + + // Handle positional argument completion + private handlePositionalCompletion( + command: Command, + previousArgs: string[], + toComplete: string, + endsWithSpace: boolean + ) { + // Get the current argument position (subtract command name) + const commandParts = command.value.split(' ').length; + const currentArgIndex = Math.max(0, previousArgs.length - commandParts); + const argumentEntries = Array.from(command.arguments.entries()); + + // If we have arguments defined + if (argumentEntries.length > 0) { + // Find the appropriate argument for the current position + let targetArgument: Argument | undefined; + + if (currentArgIndex < argumentEntries.length) { + // We're within the defined arguments + const [argName, argument] = argumentEntries[currentArgIndex]; + targetArgument = argument; + } else { + // We're beyond the defined arguments, check if the last argument is variadic + const lastArgument = argumentEntries[argumentEntries.length - 1][1]; + if (lastArgument.variadic) { + targetArgument = lastArgument; } + } + + // If we found a target argument with a handler, use it + if ( + targetArgument && + targetArgument.handler && + typeof targetArgument.handler === 'function' + ) { + const suggestions: Completion[] = []; + targetArgument.handler.call( + targetArgument, + (value: string, description: string) => + suggestions.push({ value, description }), + command.options + ); + this.completions.push(...suggestions); + } } - - // Format and output completion results - private complete(toComplete: string) { - this.directive = ShellCompDirective.ShellCompDirectiveNoFileComp; - - const seen = new Set(); - this.completions - .filter((comp) => { - if (seen.has(comp.value)) return false; - seen.add(comp.value); - return true; - }) - .filter((comp) => comp.value.startsWith(toComplete)) - .forEach((comp) => - console.log(`${comp.value}\t${comp.description ?? ''}`) - ); - console.log(`:${this.directive}`); + } + + // Format and output completion results + private complete(toComplete: string) { + this.directive = ShellCompDirective.ShellCompDirectiveNoFileComp; + + const seen = new Set(); + this.completions + .filter((comp) => { + if (seen.has(comp.value)) return false; + seen.add(comp.value); + return true; + }) + .filter((comp) => comp.value.startsWith(toComplete)) + .forEach((comp) => + console.log(`${comp.value}\t${comp.description ?? ''}`) + ); + console.log(`:${this.directive}`); + } + + parse(args: string[]) { + this.completions = []; + + const endsWithSpace = args[args.length - 1] === ''; + + if (endsWithSpace) { + args.pop(); } - parse(args: string[]) { - this.completions = []; + let toComplete = args[args.length - 1] || ''; + const previousArgs = args.slice(0, -1); - const endsWithSpace = args[args.length - 1] === ''; - - if (endsWithSpace) { - args.pop(); - } - - let toComplete = args[args.length - 1] || ''; - const previousArgs = args.slice(0, -1); - - if (endsWithSpace) { - previousArgs.push(toComplete); - toComplete = ''; - } + if (endsWithSpace) { + previousArgs.push(toComplete); + toComplete = ''; + } - const [matchedCommand] = this.matchCommand(previousArgs); - const lastPrevArg = previousArgs[previousArgs.length - 1]; - - // 1. Handle flag/option completion - if (this.shouldCompleteFlags(lastPrevArg, toComplete, endsWithSpace)) { - this.handleFlagCompletion( - matchedCommand, - previousArgs, - toComplete, - endsWithSpace, - lastPrevArg - ); - } else { - // 2. Handle command/subcommand completion - if (this.shouldCompleteCommands(toComplete, endsWithSpace)) { - this.handleCommandCompletion(previousArgs, toComplete); - } - // 3. Handle positional arguments - if (matchedCommand && matchedCommand.arguments.size > 0) { - this.handlePositionalCompletion( - matchedCommand, - previousArgs, - toComplete, - endsWithSpace - ); - } - } - - this.complete(toComplete); + const [matchedCommand] = this.matchCommand(previousArgs); + const lastPrevArg = previousArgs[previousArgs.length - 1]; + + // 1. Handle flag/option completion + if (this.shouldCompleteFlags(lastPrevArg, toComplete, endsWithSpace)) { + this.handleFlagCompletion( + matchedCommand, + previousArgs, + toComplete, + endsWithSpace, + lastPrevArg + ); + } else { + // 2. Handle command/subcommand completion + if (this.shouldCompleteCommands(toComplete, endsWithSpace)) { + this.handleCommandCompletion(previousArgs, toComplete); + } + // 3. Handle positional arguments + if (matchedCommand && matchedCommand.arguments.size > 0) { + this.handlePositionalCompletion( + matchedCommand, + previousArgs, + toComplete, + endsWithSpace + ); + } } - setup(name: string, executable: string, shell: string) { - assert(shell === 'zsh' || shell === 'bash' || shell === 'fish' || shell === 'powershell', 'Unsupported shell') - - switch (shell) { - case 'zsh': { - const script = zsh.generate(name, executable); - console.log(script); - break; - } - case 'bash': { - const script = bash.generate(name, executable); - console.log(script); - break; - } - case 'fish': { - const script = fish.generate(name, executable); - console.log(script); - break; - } - case 'powershell': { - const script = powershell.generate(name, executable); - console.log(script); - break; - } - } + this.complete(toComplete); + } + + setup(name: string, executable: string, shell: string) { + assert( + shell === 'zsh' || + shell === 'bash' || + shell === 'fish' || + shell === 'powershell', + 'Unsupported shell' + ); + + switch (shell) { + case 'zsh': { + const script = zsh.generate(name, executable); + console.log(script); + break; + } + case 'bash': { + const script = bash.generate(name, executable); + console.log(script); + break; + } + case 'fish': { + const script = fish.generate(name, executable); + console.log(script); + break; + } + case 'powershell': { + const script = powershell.generate(name, executable); + console.log(script); + break; + } } + } } -const t = new RootCommand() +const t = new RootCommand(); -export default t \ No newline at end of file +export default t; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 37d4e27..da2b863 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -20,7 +20,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { const shouldSkipTest = cliTool === 'commander'; // Commander uses a different command structure for completion - // TODO: why commander does that? our convention is the -- part which should be always there. + // TODO: why commander does that? our convention is the -- part which should be always there. const commandPrefix = cliTool === 'commander' ? `pnpm tsx examples/demo.${cliTool}.ts complete` @@ -252,86 +252,77 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe.runIf(!shouldSkipTest)( - 'positional argument completions', - () => { - it('should complete multiple positional arguments when ending with space', async () => { - const command = `${commandPrefix} lint ""`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + describe.runIf(!shouldSkipTest)('positional argument completions', () => { + it('should complete multiple positional arguments when ending with space', async () => { + const command = `${commandPrefix} lint ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it('should complete multiple positional arguments when ending with part of the value', async () => { - const command = `${commandPrefix} lint ind`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + it('should complete multiple positional arguments when ending with part of the value', async () => { + const command = `${commandPrefix} lint ind`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it('should complete single positional argument when ending with space', async () => { - const command = `${commandPrefix} lint main.ts ""`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); - } - ); + it('should complete single positional argument when ending with space', async () => { + const command = `${commandPrefix} lint main.ts ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); - describe.runIf(!shouldSkipTest)( - 'copy command argument handlers', - () => { - it('should complete source argument with directory suggestions', async () => { - const command = `${commandPrefix} copy ""`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + describe.runIf(!shouldSkipTest)('copy command argument handlers', () => { + it('should complete source argument with directory suggestions', async () => { + const command = `${commandPrefix} copy ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it('should complete destination argument with build suggestions', async () => { - const command = `${commandPrefix} copy src/ ""`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + it('should complete destination argument with build suggestions', async () => { + const command = `${commandPrefix} copy src/ ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it('should filter source suggestions when typing partial input', async () => { - const command = `${commandPrefix} copy s`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + it('should filter source suggestions when typing partial input', async () => { + const command = `${commandPrefix} copy s`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it('should filter destination suggestions when typing partial input', async () => { - const command = `${commandPrefix} copy src/ b`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); - } - ); + it('should filter destination suggestions when typing partial input', async () => { + const command = `${commandPrefix} copy src/ b`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); - describe.runIf(!shouldSkipTest)( - 'lint command argument handlers', - () => { - it('should complete files argument with file suggestions', async () => { - const command = `${commandPrefix} lint ""`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + describe.runIf(!shouldSkipTest)('lint command argument handlers', () => { + it('should complete files argument with file suggestions', async () => { + const command = `${commandPrefix} lint ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it('should filter file suggestions when typing partial input', async () => { - const command = `${commandPrefix} lint m`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + it('should filter file suggestions when typing partial input', async () => { + const command = `${commandPrefix} lint m`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it('should continue completing variadic files argument after first file', async () => { - const command = `${commandPrefix} lint main.ts ""`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + it('should continue completing variadic files argument after first file', async () => { + const command = `${commandPrefix} lint main.ts ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it('should continue completing variadic suggestions after first file', async () => { - const command = `${commandPrefix} lint main.ts i`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); - } - ); + it('should continue completing variadic suggestions after first file', async () => { + const command = `${commandPrefix} lint main.ts i`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); }); // Add specific tests for Commander