From 20a427766ac7aace6edd81e9a4995f2afe84b7a1 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Mon, 13 Oct 2025 15:39:07 -0600 Subject: [PATCH 01/16] fix: first commit on custom autocomplete --- package.json | 2 +- src/autocomplete/bash-spaces.ts | 32 +++- src/autocomplete/bash.ts | 40 ++++- src/autocomplete/powershell.ts | 33 +++- src/autocomplete/zsh.ts | 36 +++-- src/commands/autocomplete/options.ts | 176 +++++++++++++++++++++ test/autocomplete/bash.test.ts | 120 +++++++++++--- test/autocomplete/powershell.test.ts | 111 +++++++++++-- test/autocomplete/zsh.test.ts | 18 +-- test/commands/autocomplete/create.test.ts | 72 +++++++-- test/commands/autocomplete/options.test.ts | 56 +++++++ yarn.lock | 24 +++ 12 files changed, 642 insertions(+), 78 deletions(-) create mode 100644 src/commands/autocomplete/options.ts create mode 100644 test/commands/autocomplete/options.test.ts diff --git a/package.json b/package.json index 51992768..b526918c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "author": "Salesforce", "bugs": "https://github.com/oclif/plugin-autocomplete/issues", "dependencies": { - "@oclif/core": "^4", + "@oclif/core": "autocomplete", "ansis": "^3.16.0", "debug": "^4.4.1", "ejs": "^3.1.10" diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 2bd3ad98..df50833f 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -62,13 +62,33 @@ __autocomplete() else # Flag - # The full CLI command separated by colons (e.g. "mycli command subcommand --fl" -> "command:subcommand") - # This needs to be defined with $COMP_CWORD-1 as opposed to above because the current "word" on the command line is a flag and the command is everything before the flag - normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 1)}")" )" + # Check if we're completing a flag value (previous word is a flag) + local prev="\${COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + # The full CLI command separated by colons + normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 2)}")" )" + local flagName="\${prev#--}" + + # Try to get dynamic completions + local dynamicOpts=$( autocomplete options "\${normalizedCommand}" "\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + opts="$dynamicOpts" + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "\${cur}")) + return 0 + fi + else + # The full CLI command separated by colons (e.g. "mycli command subcommand --fl" -> "command:subcommand") + # This needs to be defined with $COMP_CWORD-1 as opposed to above because the current "word" on the command line is a flag and the command is everything before the flag + normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 1)}")" )" - # The line below finds the command in $commands using grep - # Then, using sed, it removes everything from the found command before the --flags (e.g. "command:subcommand:subsubcom --flag1 --flag2" -> "--flag1 --flag2") - opts=$(printf "%s " "\${commands[@]}" | grep "\${normalizedCommand}" | sed -n "s/^\${normalizedCommand} //p") + # The line below finds the command in $commands using grep + # Then, using sed, it removes everything from the found command before the --flags (e.g. "command:subcommand:subsubcom --flag1 --flag2" -> "--flag1 --flag2") + opts=$(printf "%s " "\${commands[@]}" | grep "\${normalizedCommand}" | sed -n "s/^\${normalizedCommand} //p") + fi fi COMPREPLY=($(compgen -W "$opts" -- "\${cur}")) diff --git a/src/autocomplete/bash.ts b/src/autocomplete/bash.ts index a1de4d90..1bec77a4 100644 --- a/src/autocomplete/bash.ts +++ b/src/autocomplete/bash.ts @@ -13,15 +13,41 @@ __autocomplete() if [[ "$cur" != "-"* ]]; then opts=$(printf "$commands" | grep -Eo '^[a-zA-Z0-9:_-]+') else - local __COMP_WORDS - if [[ \${COMP_WORDS[2]} == ":" ]]; then - #subcommand - __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + # Check if we're completing a flag value (previous word is a flag) + local prev="\${COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + local __COMP_WORDS + if [[ \${COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + fi + + local flagName="\${prev#--}" + # Try to get dynamic completions + local dynamicOpts=$( autocomplete:options "\${__COMP_WORDS}" "\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + opts="$dynamicOpts" + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "\${cur}")) + return 0 + fi else - #simple command - __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + local __COMP_WORDS + if [[ \${COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + fi + opts=$(printf "$commands" | grep "\${__COMP_WORDS}" | sed -n "s/^\${__COMP_WORDS} //p") fi - opts=$(printf "$commands" | grep "\${__COMP_WORDS}" | sed -n "s/^\${__COMP_WORDS} //p") fi _get_comp_words_by_ref -n : cur COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) ) diff --git a/src/autocomplete/powershell.ts b/src/autocomplete/powershell.ts index 46b8b582..f03c98d6 100644 --- a/src/autocomplete/powershell.ts +++ b/src/autocomplete/powershell.ts @@ -206,9 +206,35 @@ $scriptblock = { # Start completing command. if ($NextArg._command -ne $null) { - # Complete flags - # \`cli config list -\` - if ($WordToComplete -like '-*') { + # Check if we're completing a flag value + $PrevWord = if ($CurrentLine.Count -gt 0) { $CurrentLine[-1] } else { "" } + $IsCompletingFlagValue = $PrevWord -like '--*' -and $WordToComplete -notlike '-*' + + if ($IsCompletingFlagValue) { + # Try dynamic flag value completion + $FlagName = $PrevWord.TrimStart('-') + $CommandId = $NextArg._command.id + $CurrentLineStr = $CommandAst.ToString() + + try { + $DynamicOptions = & ${this.config.bin} autocomplete options $CommandId $FlagName --current-line="$CurrentLineStr" 2>$null + if ($DynamicOptions) { + $DynamicOptions | Where-Object { + $_.StartsWith("$WordToComplete") + } | ForEach-Object { + New-Object -Type CompletionResult -ArgumentList \` + $($Mode -eq "MenuComplete" ? "$_ " : "$_"), + $_, + "ParameterValue", + " " + } + } + } catch { + # Fall back to no completions if dynamic completion fails + } + } elseif ($WordToComplete -like '-*') { + # Complete flags + # \`cli config list -\` $NextArg._command.flags.GetEnumerator() | Sort-Object -Property key | Where-Object { # Filter out already used flags (unless \`flag.multiple = true\`). @@ -301,6 +327,7 @@ Register-ArgumentCompleter -Native -CommandName ${ } const cmdHashtable = `@{ + "id" = "${cmd.id}" "summary" = "${cmd.summary}" "flags" = @{ ${flaghHashtables.join('\n')} diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index 115dca9f..814e74de 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -82,7 +82,7 @@ export default class ZshCompWithSpaces { // if it's a command and has flags, inline flag completion statement. // skip it from the args statement if it doesn't accept any flag. if (Object.keys(cmd.flags).length > 0) { - caseBlock += `${arg.id})\n${this.genZshFlagArgumentsBlock(cmd.flags)} ;; \n` + caseBlock += `${arg.id})\n${this.genZshFlagArgumentsBlock(cmd.flags, cmd.id)} ;; \n` } } else { // it's a topic, redirect to its completion function. @@ -121,7 +121,7 @@ _${this.config.bin} ` } - private genZshFlagArgumentsBlock(flags?: CommandFlags): string { + private genZshFlagArgumentsBlock(flags?: CommandFlags, commandId?: string): string { // if a command doesn't have flags make it only complete files // also add comp for the global `--help` flag. if (!flags) return '_arguments -S \\\n --help"[Show help for command]" "*: :_files' @@ -129,9 +129,9 @@ _${this.config.bin} const flagNames = Object.keys(flags) // `-S`: - // Do not complete flags after a ‘--’ appearing on the line, and ignore the ‘--’. For example, with -S, in the line: + // Do not complete flags after a '--' appearing on the line, and ignore the '--'. For example, with -S, in the line: // foobar -x -- -y - // the ‘-x’ is considered a flag, the ‘-y’ is considered an argument, and the ‘--’ is considered to be neither. + // the '-x' is considered a flag, the '-y' is considered an argument, and the '--' is considered to be neither. let argumentsBlock = '_arguments -S \\\n' for (const flagName of flagNames) { @@ -145,6 +145,10 @@ _${this.config.bin} let flagSpec = '' if (f.type === 'option') { + // Always try dynamic completion for option flags + // The autocomplete:options command will check if the flag actually has a completion function + const hasCompletion = true + if (f.char) { // eslint-disable-next-line unicorn/prefer-ternary if (f.multiple) { @@ -156,7 +160,14 @@ _${this.config.bin} flagSpec += `"[${flagSummary}]` - flagSpec += f.options ? `:${f.name} options:(${f.options?.join(' ')})"` : ':file:_files"' + if (hasCompletion && commandId) { + // Use dynamic completion + flagSpec += `:${f.name}:(\`${this.config.bin} autocomplete${this.config.topicSeparator}options ${commandId} ${f.name} --current-line="$words" 2>/dev/null\`)"` + } else if (f.options) { + flagSpec += `:${f.name} options:(${f.options?.join(' ')})"` + } else { + flagSpec += ':file:_files"' + } } else { if (f.multiple) { // this flag can be present multiple times on the line @@ -165,7 +176,14 @@ _${this.config.bin} flagSpec += `--${f.name}"[${flagSummary}]:` - flagSpec += f.options ? `${f.name} options:(${f.options.join(' ')})"` : 'file:_files"' + if (hasCompletion && commandId) { + // Use dynamic completion + flagSpec += `${f.name}:(\`${this.config.bin} autocomplete${this.config.topicSeparator}options ${commandId} ${f.name} --current-line="$words" 2>/dev/null\`)"` + } else if (f.options) { + flagSpec += `${f.name} options:(${f.options.join(' ')})"` + } else { + flagSpec += 'file:_files"' + } } } else if (f.char) { // Flag.Boolean @@ -213,7 +231,7 @@ _${this.config.bin} local context state state_descr line typeset -A opt_args - ${this.genZshFlagArgumentsBlock(this.commands.find((c) => c.id === id)?.flags)} + ${this.genZshFlagArgumentsBlock(this.commands.find((c) => c.id === id)?.flags, id)} } local context state state_descr line @@ -266,7 +284,7 @@ _${this.config.bin} summary: c.summary, }) - argsBlock += format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags)) + argsBlock += format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags, c.id)) } return format(coTopicCompFunc, this.genZshValuesBlock(subArgs), argsBlock) @@ -295,7 +313,7 @@ _${this.config.bin} summary: c.summary, }) - argsBlock += format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags)) + argsBlock += format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags, c.id)) } const topicCompFunc = `_${this.config.bin}_${underscoreSepId}() { diff --git a/src/commands/autocomplete/options.ts b/src/commands/autocomplete/options.ts new file mode 100644 index 00000000..09ad0707 --- /dev/null +++ b/src/commands/autocomplete/options.ts @@ -0,0 +1,176 @@ +import {Args, Command, Flags, Interfaces} from '@oclif/core' +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import path from 'node:path' + +export default class Options extends Command { + static args = { + command: Args.string({ + description: 'Command name or ID', + required: true, + }), + flag: Args.string({ + description: 'Flag name', + required: true, + }), + } + static description = 'Display dynamic flag value completions' + static flags = { + 'current-line': Flags.string({ + description: 'Current command line being completed', + }), + } + static hidden = true + + private get cacheDir(): string { + return path.join(this.config.cacheDir, 'autocomplete', 'completions') + } + + // Cache duration in seconds - can be configured via env var + private get cacheDuration(): number { + const envDuration = process.env.OCLIF_AUTOCOMPLETE_CACHE_DURATION + return envDuration ? Number.parseInt(envDuration, 10) : 60 * 60 * 24 // Default: 24 hours + } + + async run(): Promise { + const {args, flags} = await this.parse(Options) + const commandId = args.command + const flagName = args.flag + + try { + // Find the command + const command = this.config.findCommand(commandId) + if (!command) { + this.log('') + return + } + + // Load the actual command class to get the completion function + // The manifest doesn't include functions, so we need to load the command class + const CommandClass = await command.load() + + // Get the flag definition from the loaded command class + const flagDef = CommandClass.flags?.[flagName] as Interfaces.OptionFlag + if (!flagDef || !flagDef.completion) { + this.log('') + return + } + + // Parse the current command line to extract context + const currentLine = flags['current-line'] || '' + const context = this.parseCommandLine(currentLine) + + // Generate cache key + const cacheKey = `${commandId}:${flagName}` + + // Check cache + const cached = this.getFromCache(cacheKey) + if (cached) { + this.log(cached.join('\n')) + return + } + + // Execute the completion function + const completionContext: Interfaces.CompletionContext = { + args: context.args, + argv: context.argv, + config: this.config, + flags: context.flags, + } + + const options = await flagDef.completion.options(completionContext) + + // Cache the results + this.saveToCache(cacheKey, options) + + // Output the options + this.log(options.join('\n')) + } catch { + // Silently fail and return empty completions + this.log('') + } + } + + private getCachePath(key: string): string { + return path.join(this.cacheDir, `${Buffer.from(key).toString('base64')}.json`) + } + + private getFromCache(key: string): null | string[] { + const cachePath = this.getCachePath(key) + if (!existsSync(cachePath)) return null + + try { + const {options, timestamp} = JSON.parse(readFileSync(cachePath, 'utf8')) + const age = Date.now() - timestamp + const maxAge = this.cacheDuration * 1000 + + if (age < maxAge) { + return options + } + + return null + } catch { + return null + } + } + + private parseCommandLine(line: string): { + args: {[name: string]: string} + argv: string[] + flags: {[name: string]: string} + } { + const args: {[name: string]: string} = {} + const flags: {[name: string]: string} = {} + const argv: string[] = [] + const parts = line.split(/\s+/).filter(Boolean) + + let i = 0 + // Skip the CLI bin name + if (parts.length > 0) i++ + + while (i < parts.length) { + const part = parts[i] + if (part.startsWith('--')) { + const flagName = part.slice(2) + if (i + 1 < parts.length && !parts[i + 1].startsWith('-')) { + flags[flagName] = parts[i + 1] + i += 2 + } else { + flags[flagName] = 'true' + i++ + } + } else if (part.startsWith('-') && part.length === 2) { + const flagChar = part.slice(1) + if (i + 1 < parts.length && !parts[i + 1].startsWith('-')) { + flags[flagChar] = parts[i + 1] + i += 2 + } else { + flags[flagChar] = 'true' + i++ + } + } else { + argv.push(part) + i++ + } + } + + return {args, argv, flags} + } + + private saveToCache(key: string, options: string[]): void { + const cachePath = this.getCachePath(key) + const {cacheDir} = this + + try { + mkdirSync(cacheDir, {recursive: true}) + writeFileSync( + cachePath, + JSON.stringify({ + options, + timestamp: Date.now(), + }), + ) + } catch { + // Silently fail if we can't write to cache + } + } +} diff --git a/test/autocomplete/bash.test.ts b/test/autocomplete/bash.test.ts index 7c93b680..60204162 100644 --- a/test/autocomplete/bash.test.ts +++ b/test/autocomplete/bash.test.ts @@ -204,15 +204,41 @@ ${'app:execute:code '} if [[ "$cur" != "-"* ]]; then opts=$(printf "$commands" | grep -Eo '^[a-zA-Z0-9:_-]+') else - local __COMP_WORDS - if [[ $\{COMP_WORDS[2]} == ":" ]]; then - #subcommand - __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + # Check if we're completing a flag value (previous word is a flag) + local prev="$\{COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + + local flagName="$\{prev#--}" + # Try to get dynamic completions + local dynamicOpts=$(test-cli autocomplete:options "$\{__COMP_WORDS}" "$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + opts="$dynamicOpts" + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "$\{cur}")) + return 0 + fi else - #simple command - __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi - opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi _get_comp_words_by_ref -n : cur COMPREPLY=( $(compgen -W "$\{opts}" -- $\{cur}) ) @@ -248,15 +274,41 @@ ${'app:execute:code '} if [[ "$cur" != "-"* ]]; then opts=$(printf "$commands" | grep -Eo '^[a-zA-Z0-9:_-]+') else - local __COMP_WORDS - if [[ $\{COMP_WORDS[2]} == ":" ]]; then - #subcommand - __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + # Check if we're completing a flag value (previous word is a flag) + local prev="$\{COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + + local flagName="$\{prev#--}" + # Try to get dynamic completions + local dynamicOpts=$(test-cli autocomplete:options "$\{__COMP_WORDS}" "$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + opts="$dynamicOpts" + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "$\{cur}")) + return 0 + fi else - #simple command - __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi - opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi _get_comp_words_by_ref -n : cur COMPREPLY=( $(compgen -W "$\{opts}" -- $\{cur}) ) @@ -293,15 +345,41 @@ ${'app:execute:code '} if [[ "$cur" != "-"* ]]; then opts=$(printf "$commands" | grep -Eo '^[a-zA-Z0-9:_-]+') else - local __COMP_WORDS - if [[ $\{COMP_WORDS[2]} == ":" ]]; then - #subcommand - __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + # Check if we're completing a flag value (previous word is a flag) + local prev="$\{COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + + local flagName="$\{prev#--}" + # Try to get dynamic completions + local dynamicOpts=$(test-cli autocomplete:options "$\{__COMP_WORDS}" "$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + opts="$dynamicOpts" + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "$\{cur}")) + return 0 + fi else - #simple command - __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi - opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi _get_comp_words_by_ref -n : cur COMPREPLY=( $(compgen -W "$\{opts}" -- $\{cur}) ) diff --git a/test/autocomplete/powershell.test.ts b/test/autocomplete/powershell.test.ts index 25283d0e..e6d28342 100644 --- a/test/autocomplete/powershell.test.ts +++ b/test/autocomplete/powershell.test.ts @@ -212,6 +212,7 @@ $scriptblock = { "_summary" = "execute code" "code" = @{ "_command" = @{ + "id" = "app:execute:code" "summary" = "execute code" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -225,6 +226,7 @@ $scriptblock = { "deploy" = @{ "_command" = @{ + "id" = "deploy" "summary" = "Deploy a project" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -239,6 +241,7 @@ $scriptblock = { } "functions" = @{ "_command" = @{ + "id" = "deploy:functions" "summary" = "Deploy a function." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -250,6 +253,7 @@ $scriptblock = { "autocomplete" = @{ "_command" = @{ + "id" = "autocomplete" "summary" = "Display autocomplete installation instructions." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -260,6 +264,7 @@ $scriptblock = { "search" = @{ "_command" = @{ + "id" = "search" "summary" = "Search for a command" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -329,9 +334,35 @@ $scriptblock = { # Start completing command. if ($NextArg._command -ne $null) { - # Complete flags - # \`cli config list -\` - if ($WordToComplete -like '-*') { + # Check if we're completing a flag value + $PrevWord = if ($CurrentLine.Count -gt 0) { $CurrentLine[-1] } else { "" } + $IsCompletingFlagValue = $PrevWord -like '--*' -and $WordToComplete -notlike '-*' + + if ($IsCompletingFlagValue) { + # Try dynamic flag value completion + $FlagName = $PrevWord.TrimStart('-') + $CommandId = $NextArg._command.id + $CurrentLineStr = $CommandAst.ToString() + + try { + $DynamicOptions = & test-cli autocomplete options $CommandId $FlagName --current-line="$CurrentLineStr" 2>$null + if ($DynamicOptions) { + $DynamicOptions | Where-Object { + $_.StartsWith("$WordToComplete") + } | ForEach-Object { + New-Object -Type CompletionResult -ArgumentList \` + $($Mode -eq "MenuComplete" ? "$_ " : "$_"), + $_, + "ParameterValue", + " " + } + } + } catch { + # Fall back to no completions if dynamic completion fails + } + } elseif ($WordToComplete -like '-*') { + # Complete flags + # \`cli config list -\` $NextArg._command.flags.GetEnumerator() | Sort-Object -Property key | Where-Object { # Filter out already used flags (unless \`flag.multiple = true\`). @@ -405,6 +436,7 @@ $scriptblock = { "_summary" = "execute code" "code" = @{ "_command" = @{ + "id" = "app:execute:code" "summary" = "execute code" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -418,6 +450,7 @@ $scriptblock = { "deploy" = @{ "_command" = @{ + "id" = "deploy" "summary" = "Deploy a project" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -432,6 +465,7 @@ $scriptblock = { } "functions" = @{ "_command" = @{ + "id" = "deploy:functions" "summary" = "Deploy a function." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -443,6 +477,7 @@ $scriptblock = { "autocomplete" = @{ "_command" = @{ + "id" = "autocomplete" "summary" = "Display autocomplete installation instructions." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -453,6 +488,7 @@ $scriptblock = { "search" = @{ "_command" = @{ + "id" = "search" "summary" = "Search for a command" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -522,9 +558,35 @@ $scriptblock = { # Start completing command. if ($NextArg._command -ne $null) { - # Complete flags - # \`cli config list -\` - if ($WordToComplete -like '-*') { + # Check if we're completing a flag value + $PrevWord = if ($CurrentLine.Count -gt 0) { $CurrentLine[-1] } else { "" } + $IsCompletingFlagValue = $PrevWord -like '--*' -and $WordToComplete -notlike '-*' + + if ($IsCompletingFlagValue) { + # Try dynamic flag value completion + $FlagName = $PrevWord.TrimStart('-') + $CommandId = $NextArg._command.id + $CurrentLineStr = $CommandAst.ToString() + + try { + $DynamicOptions = & test-cli autocomplete options $CommandId $FlagName --current-line="$CurrentLineStr" 2>$null + if ($DynamicOptions) { + $DynamicOptions | Where-Object { + $_.StartsWith("$WordToComplete") + } | ForEach-Object { + New-Object -Type CompletionResult -ArgumentList \` + $($Mode -eq "MenuComplete" ? "$_ " : "$_"), + $_, + "ParameterValue", + " " + } + } + } catch { + # Fall back to no completions if dynamic completion fails + } + } elseif ($WordToComplete -like '-*') { + # Complete flags + # \`cli config list -\` $NextArg._command.flags.GetEnumerator() | Sort-Object -Property key | Where-Object { # Filter out already used flags (unless \`flag.multiple = true\`). @@ -598,6 +660,7 @@ $scriptblock = { "_summary" = "execute code" "code" = @{ "_command" = @{ + "id" = "app:execute:code" "summary" = "execute code" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -611,6 +674,7 @@ $scriptblock = { "deploy" = @{ "_command" = @{ + "id" = "deploy" "summary" = "Deploy a project" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -625,6 +689,7 @@ $scriptblock = { } "functions" = @{ "_command" = @{ + "id" = "deploy:functions" "summary" = "Deploy a function." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -636,6 +701,7 @@ $scriptblock = { "autocomplete" = @{ "_command" = @{ + "id" = "autocomplete" "summary" = "Display autocomplete installation instructions." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -646,6 +712,7 @@ $scriptblock = { "search" = @{ "_command" = @{ + "id" = "search" "summary" = "Search for a command" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -715,9 +782,35 @@ $scriptblock = { # Start completing command. if ($NextArg._command -ne $null) { - # Complete flags - # \`cli config list -\` - if ($WordToComplete -like '-*') { + # Check if we're completing a flag value + $PrevWord = if ($CurrentLine.Count -gt 0) { $CurrentLine[-1] } else { "" } + $IsCompletingFlagValue = $PrevWord -like '--*' -and $WordToComplete -notlike '-*' + + if ($IsCompletingFlagValue) { + # Try dynamic flag value completion + $FlagName = $PrevWord.TrimStart('-') + $CommandId = $NextArg._command.id + $CurrentLineStr = $CommandAst.ToString() + + try { + $DynamicOptions = & test-cli autocomplete options $CommandId $FlagName --current-line="$CurrentLineStr" 2>$null + if ($DynamicOptions) { + $DynamicOptions | Where-Object { + $_.StartsWith("$WordToComplete") + } | ForEach-Object { + New-Object -Type CompletionResult -ArgumentList \` + $($Mode -eq "MenuComplete" ? "$_ " : "$_"), + $_, + "ParameterValue", + " " + } + } + } catch { + # Fall back to no completions if dynamic completion fails + } + } elseif ($WordToComplete -like '-*') { + # Complete flags + # \`cli config list -\` $NextArg._command.flags.GetEnumerator() | Sort-Object -Property key | Where-Object { # Filter out already used flags (unless \`flag.multiple = true\`). diff --git a/test/autocomplete/zsh.test.ts b/test/autocomplete/zsh.test.ts index df92261a..034062fa 100644 --- a/test/autocomplete/zsh.test.ts +++ b/test/autocomplete/zsh.test.ts @@ -260,10 +260,10 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]:file:_files" \\ +"(-a --api-version)"{-a,--api-version}"[]:api-version:(\`test-cli autocomplete:options deploy api-version --current-line="$words" 2>/dev/null\`)" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ --json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]:file:_files" \\ +"*"{-m,--metadata}"[]:metadata:(\`test-cli autocomplete:options deploy metadata --current-line="$words" 2>/dev/null\`)" \\ --help"[Show help for command]" \\ "*: :_files" } @@ -287,7 +287,7 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]:file:_files" \\ +"(-b --branch)"{-b,--branch}"[]:branch:(\`test-cli autocomplete:options deploy:functions branch --current-line="$words" 2>/dev/null\`)" \\ --help"[Show help for command]" \\ "*: :_files" ;; @@ -394,10 +394,10 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]:file:_files" \\ +"(-a --api-version)"{-a,--api-version}"[]:api-version:(\`test-cli autocomplete:options deploy api-version --current-line="$words" 2>/dev/null\`)" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ --json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]:file:_files" \\ +"*"{-m,--metadata}"[]:metadata:(\`test-cli autocomplete:options deploy metadata --current-line="$words" 2>/dev/null\`)" \\ --help"[Show help for command]" \\ "*: :_files" } @@ -421,7 +421,7 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]:file:_files" \\ +"(-b --branch)"{-b,--branch}"[]:branch:(\`test-cli autocomplete:options deploy:functions branch --current-line="$words" 2>/dev/null\`)" \\ --help"[Show help for command]" \\ "*: :_files" ;; @@ -529,10 +529,10 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]:file:_files" \\ +"(-a --api-version)"{-a,--api-version}"[]:api-version:(\`test-cli autocomplete:options deploy api-version --current-line="$words" 2>/dev/null\`)" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ --json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]:file:_files" \\ +"*"{-m,--metadata}"[]:metadata:(\`test-cli autocomplete:options deploy metadata --current-line="$words" 2>/dev/null\`)" \\ --help"[Show help for command]" \\ "*: :_files" } @@ -556,7 +556,7 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]:file:_files" \\ +"(-b --branch)"{-b,--branch}"[]:branch:(\`test-cli autocomplete:options deploy:functions branch --current-line="$words" 2>/dev/null\`)" \\ --help"[Show help for command]" \\ "*: :_files" ;; diff --git a/test/commands/autocomplete/create.test.ts b/test/commands/autocomplete/create.test.ts index c4455ce3..a309dcf7 100644 --- a/test/commands/autocomplete/create.test.ts +++ b/test/commands/autocomplete/create.test.ts @@ -85,15 +85,41 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json if [[ "$cur" != "-"* ]]; then opts=$(printf "$commands" | grep -Eo '^[a-zA-Z0-9:_-]+') else - local __COMP_WORDS - if [[ \${COMP_WORDS[2]} == ":" ]]; then - #subcommand - __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + # Check if we're completing a flag value (previous word is a flag) + local prev="\${COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + local __COMP_WORDS + if [[ \${COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + fi + + local flagName="\${prev#--}" + # Try to get dynamic completions + local dynamicOpts=$(oclif-example autocomplete:options "\${__COMP_WORDS}" "\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + opts="$dynamicOpts" + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "\${cur}")) + return 0 + fi else - #simple command - __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + local __COMP_WORDS + if [[ \${COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + fi + opts=$(printf "$commands" | grep "\${__COMP_WORDS}" | sed -n "s/^\${__COMP_WORDS} //p") fi - opts=$(printf "$commands" | grep "\${__COMP_WORDS}" | sed -n "s/^\${__COMP_WORDS} //p") fi _get_comp_words_by_ref -n : cur COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) ) @@ -184,13 +210,33 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json ${'else '} # Flag - # The full CLI command separated by colons (e.g. "mycli command subcommand --fl" -> "command:subcommand") - # This needs to be defined with $COMP_CWORD-1 as opposed to above because the current "word" on the command line is a flag and the command is everything before the flag - normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 1)}")" )" + # Check if we're completing a flag value (previous word is a flag) + local prev="\${COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + # The full CLI command separated by colons + normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 2)}")" )" + local flagName="\${prev#--}" + + # Try to get dynamic completions + local dynamicOpts=$(oclif-example autocomplete options "\${normalizedCommand}" "\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) - # The line below finds the command in $commands using grep - # Then, using sed, it removes everything from the found command before the --flags (e.g. "command:subcommand:subsubcom --flag1 --flag2" -> "--flag1 --flag2") - opts=$(printf "%s " "\${commands[@]}" | grep "\${normalizedCommand}" | sed -n "s/^\${normalizedCommand} //p") + if [[ -n "$dynamicOpts" ]]; then + opts="$dynamicOpts" + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "\${cur}")) + return 0 + fi + else + # The full CLI command separated by colons (e.g. "mycli command subcommand --fl" -> "command:subcommand") + # This needs to be defined with $COMP_CWORD-1 as opposed to above because the current "word" on the command line is a flag and the command is everything before the flag + normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 1)}")" )" + + # The line below finds the command in $commands using grep + # Then, using sed, it removes everything from the found command before the --flags (e.g. "command:subcommand:subsubcom --flag1 --flag2" -> "--flag1 --flag2") + opts=$(printf "%s " "\${commands[@]}" | grep "\${normalizedCommand}" | sed -n "s/^\${normalizedCommand} //p") + fi fi COMPREPLY=($(compgen -W "$opts" -- "\${cur}")) diff --git a/test/commands/autocomplete/options.test.ts b/test/commands/autocomplete/options.test.ts new file mode 100644 index 00000000..bb0ed4f7 --- /dev/null +++ b/test/commands/autocomplete/options.test.ts @@ -0,0 +1,56 @@ +import {Config} from '@oclif/core' +import {runCommand} from '@oclif/test' +import {expect} from 'chai' + +describe('autocomplete:options', () => { + let config: Config + + before(async () => { + config = await Config.load() + }) + + it('returns empty string for non-existent command', async () => { + const {stdout} = await runCommand<{name: string}>(['autocomplete:options', 'nonexistent', 'someflag'], config) + expect(stdout).to.equal('') + }) + + it('returns empty string for command without flag', async () => { + const {stdout} = await runCommand<{name: string}>( + ['autocomplete:options', 'autocomplete', 'nonexistentflag'], + config, + ) + expect(stdout).to.equal('') + }) + + it('returns empty string for flag without completion', async () => { + const {stdout} = await runCommand<{name: string}>(['autocomplete:options', 'autocomplete', 'refresh-cache'], config) + expect(stdout).to.equal('') + }) + + it('handles errors gracefully', async () => { + // Test with invalid arguments - should return empty string + const {stdout} = await runCommand<{name: string}>( + ['autocomplete:options', 'invalid:command:that:does:not:exist', 'flag'], + config, + ) + // Should return empty string on error + expect(stdout).to.equal('') + }) + + it('accepts current-line flag', async () => { + // Should accept the current-line flag without error + const {stdout} = await runCommand<{name: string}>( + ['autocomplete:options', 'autocomplete', 'shell', '--current-line', 'mycli autocomplete --shell'], + config, + ) + // Should return empty since shell arg doesn't have completion + expect(stdout).to.equal('') + }) + + // Note: We can't easily test actual completion results without creating a test command + // with a completion function. The test manifest doesn't include commands with dynamic completions. + // In a real scenario, you would: + // 1. Create a command with a completion function in your plugin + // 2. Add it to the test manifest + // 3. Test that calling options returns the expected results +}) diff --git a/yarn.lock b/yarn.lock index aac20a3f..694ed8cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1858,6 +1858,30 @@ wordwrap "^1.0.0" wrap-ansi "^7.0.0" +"@oclif/core@autocomplete": + version "4.5.6-autocomplete.0" + resolved "https://registry.yarnpkg.com/@oclif/core/-/core-4.5.6-autocomplete.0.tgz#749f8a5fbdfc62f57e32aef37b6affefb045cdb0" + integrity sha512-PTYvKFHMR/rECFvzAvZqfrq31I8BpIM15qz9+X4BMf3lx9gbYJqPpRi4bOR8ullb6ddUVjDGRENRZQlBznsEgw== + dependencies: + ansi-escapes "^4.3.2" + ansis "^3.17.0" + clean-stack "^3.0.1" + cli-spinners "^2.9.2" + debug "^4.4.3" + ejs "^3.1.10" + get-package-type "^0.1.0" + indent-string "^4.0.0" + is-wsl "^2.2.0" + lilconfig "^3.1.3" + minimatch "^9.0.5" + semver "^7.7.3" + string-width "^4.2.3" + supports-color "^8" + tinyglobby "^0.2.14" + widest-line "^3.1.0" + wordwrap "^1.0.0" + wrap-ansi "^7.0.0" + "@oclif/plugin-help@^6", "@oclif/plugin-help@^6.2.33": version "6.2.33" resolved "https://registry.yarnpkg.com/@oclif/plugin-help/-/plugin-help-6.2.33.tgz#931dc79b09e11ba50186a9846a2cf5a42a99e1ea" From 17f72680060c88e2c9148b5d1247f689eb1c9965 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 14 Oct 2025 10:20:49 -0600 Subject: [PATCH 02/16] chore: caching 'complete' --- src/autocomplete/zsh.ts | 211 ++++++++++++++++++------- src/commands/autocomplete/options.ts | 97 +++--------- test/autocomplete/zsh.test.ts | 228 +++++++++++++++++++++++++-- 3 files changed, 395 insertions(+), 141 deletions(-) diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index 814e74de..62053890 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -99,6 +99,9 @@ export default class ZshCompWithSpaces { return `#compdef ${this.config.bin} ${this.config.binAliases?.map((a) => `compdef ${a}=${this.config.bin}`).join('\n') ?? ''} + +${this.genDynamicCompletionHelper()} + ${this.topics.map((t) => this.genZshTopicCompFun(t.name)).join('\n')} _${this.config.bin}() { @@ -121,6 +124,106 @@ _${this.config.bin} ` } + private genDynamicCompletionHelper(): string { + // Using ${'$'} instead of \$ to avoid linter errors + return `# Dynamic completion helper with timestamp-based caching +_${this.config.bin}_dynamic_comp() { + local cmd_id="$1" + local flag_name="$2" + local cache_duration="$3" + local cache_dir="$HOME/.cache/${this.config.bin}/autocomplete/flag_completions" + local cache_file="$cache_dir/${'$'}{cmd_id//[:]/_}_${'$'}{flag_name}.cache" + local -a opts + + # Check if cache file exists and is valid + if [[ -f "$cache_file" ]]; then + # Read timestamp from first line + local cache_timestamp=${'$'}(head -n 1 "$cache_file" 2>/dev/null) + local current_timestamp=${'$'}(date +%s) + local age=${'$'}((current_timestamp - cache_timestamp)) + + # Check if cache is still valid + if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then + # Cache valid - read options (skip first line) + opts=("${'$'}{(@f)${'$'}(tail -n +2 "$cache_file")}") + + # Check if this is a "no completion" marker + if [[ "${'$'}{opts[1]}" == "__NO_COMPLETION__" ]]; then + # No completion available - use file completion + _files + return 0 + fi + + if [[ ${'$'}{#opts[@]} -gt 0 ]]; then + _describe "${'$'}{flag_name} options" opts + return 0 + fi + fi + fi + + # Cache miss or expired - call Node.js + mkdir -p "$cache_dir" 2>/dev/null + local raw_output=${'$'}(${this.config.bin} autocomplete${this.config.topicSeparator}options ${'$'}{cmd_id} ${'$'}{flag_name} --current-line="$words" 2>/dev/null) + + if [[ -n "$raw_output" ]]; then + # Save to cache with timestamp + { + echo "${'$'}(date +%s)" + echo "$raw_output" + } > "$cache_file" + + # Provide completions + opts=("${'$'}{(@f)$raw_output}") + _describe "${'$'}{flag_name} options" opts + else + # No completion available - cache empty result to avoid repeated Node.js calls + { + echo "${'$'}(date +%s)" + echo "__NO_COMPLETION__" + } > "$cache_file" + + # Fall back to file completion + _files + fi +} +` + } + + private genZshCompletionSuffix(f: Command.Flag.Cached, commandId: string | undefined): string { + // Only handle option flags + if (f.type !== 'option') return '' + + // Check completion type: static, dynamic, or none + // @ts-expect-error - completion.type may not exist yet in types + const completionType = f.completion?.type + const hasStaticCompletion = completionType === 'static' && Array.isArray(f.completion?.options) + // @ts-expect-error - completion.cacheDuration may not exist yet in types + const cacheDuration = f.completion?.cacheDuration || 86_400 // Default: 24 hours + + if (hasStaticCompletion && commandId) { + // STATIC: Embed options directly (instant!) + // @ts-expect-error - we checked it's an array above + const options = f.completion.options.join(' ') + return f.char ? `:${f.name}:(${options})` : `${f.name}:(${options})` + } + + if (f.options) { + // Legacy static options + return f.char ? `:${f.name} options:(${f.options?.join(' ')})` : `${f.name} options:(${f.options.join(' ')})` + } + + // ALWAYS try dynamic completion for option flags if we have a command ID + // The autocomplete:options command will return empty if no completion is defined, + // and the shell script will fall back to _files + if (commandId) { + // For both char and non-char flags: format is ": :action" (no closing quote!) + return `: :_${this.config.bin}_dynamic_comp ${commandId} ${f.name} ${cacheDuration}` + } + + // No command ID - fall back to file completion + return f.char ? ':file:_files' : 'file:_files' + } + private genZshFlagArgumentsBlock(flags?: CommandFlags, commandId?: string): string { // if a command doesn't have flags make it only complete files // also add comp for the global `--help` flag. @@ -141,68 +244,46 @@ _${this.config.bin} if (f.hidden) continue const flagSummary = this.sanitizeSummary(f.summary ?? f.description) + const flagSpec = this.genZshFlagSpec(f, flagSummary, commandId) - let flagSpec = '' + argumentsBlock += flagSpec + ' \\\n' + } - if (f.type === 'option') { - // Always try dynamic completion for option flags - // The autocomplete:options command will check if the flag actually has a completion function - const hasCompletion = true + // add global `--help` flag + argumentsBlock += '--help"[Show help for command]" \\\n' + // complete files if `-` is not present on the current line + argumentsBlock += '"*: :_files"' - if (f.char) { - // eslint-disable-next-line unicorn/prefer-ternary - if (f.multiple) { - // this flag can be present multiple times on the line - flagSpec += `"*"{-${f.char},--${f.name}}` - } else { - flagSpec += `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}` - } + return argumentsBlock + } - flagSpec += `"[${flagSummary}]` + private genZshFlagSpec(f: Command.Flag.Any, flagSummary: string, commandId?: string): string { + if (f.type === 'option') { + return this.genZshOptionFlagSpec(f, flagSummary, commandId) + } - if (hasCompletion && commandId) { - // Use dynamic completion - flagSpec += `:${f.name}:(\`${this.config.bin} autocomplete${this.config.topicSeparator}options ${commandId} ${f.name} --current-line="$words" 2>/dev/null\`)"` - } else if (f.options) { - flagSpec += `:${f.name} options:(${f.options?.join(' ')})"` - } else { - flagSpec += ':file:_files"' - } - } else { - if (f.multiple) { - // this flag can be present multiple times on the line - flagSpec += '"*"' - } + // Boolean flag + if (f.char) { + return `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}"[${flagSummary}]"` + } - flagSpec += `--${f.name}"[${flagSummary}]:` + return `--${f.name}"[${flagSummary}]"` + } - if (hasCompletion && commandId) { - // Use dynamic completion - flagSpec += `${f.name}:(\`${this.config.bin} autocomplete${this.config.topicSeparator}options ${commandId} ${f.name} --current-line="$words" 2>/dev/null\`)"` - } else if (f.options) { - flagSpec += `${f.name} options:(${f.options.join(' ')})"` - } else { - flagSpec += 'file:_files"' - } - } - } else if (f.char) { - // Flag.Boolean - flagSpec += `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}"[${flagSummary}]"` - } else { - // Flag.Boolean - flagSpec += `--${f.name}"[${flagSummary}]"` - } + private genZshOptionFlagSpec(f: Command.Flag.Cached, flagSummary: string, commandId?: string): string { + // TypeScript doesn't narrow f to option type, so we cast + const optionFlag = f as Command.Flag.Cached & {multiple?: boolean} + const completionSuffix = this.genZshCompletionSuffix(f, commandId) - flagSpec += ' \\\n' - argumentsBlock += flagSpec + if (f.char) { + const multiplePart = optionFlag.multiple + ? `"*"{-${f.char},--${f.name}}` + : `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}` + return `${multiplePart}"[${flagSummary}]"${completionSuffix}"` } - // add global `--help` flag - argumentsBlock += '--help"[Show help for command]" \\\n' - // complete files if `-` is not present on the current line - argumentsBlock += '"*: :_files"' - - return argumentsBlock + const multiplePart = optionFlag.multiple ? '"*"' : '' + return `${multiplePart}--${f.name}"[${flagSummary}]"${completionSuffix}"` } private genZshTopicCompFun(id: string): string { @@ -340,8 +421,10 @@ _${this.config.bin} private genZshValuesBlock(subArgs: {id: string; summary?: string}[]): string { let valuesBlock = '_values "completions" \\\n' - for (const subArg of subArgs) { - valuesBlock += `"${subArg.id}[${subArg.summary}]" \\\n` + for (let i = 0; i < subArgs.length; i++) { + const subArg = subArgs[i] + const isLast = i === subArgs.length - 1 + valuesBlock += `"${subArg.id}[${subArg.summary}]"${isLast ? '\n' : ' \\\n'}` } return valuesBlock @@ -426,6 +509,26 @@ _${this.config.bin} return topics } + private hasDynamicCompletions(): boolean { + // Check if any command has dynamic completions + for (const command of this.commands) { + const flags = command.flags || {} + for (const flag of Object.values(flags)) { + if ( + flag.type === 'option' && + flag.completion && // If completion doesn't have a type, assume dynamic (backward compatibility) + // If it has type === 'dynamic', it's dynamic + // @ts-expect-error - completion.type may not exist yet in types + (!flag.completion.type || flag.completion.type === 'dynamic') + ) { + return true + } + } + } + + return false + } + private sanitizeSummary(summary?: string): string { if (summary === undefined) { return '' diff --git a/src/commands/autocomplete/options.ts b/src/commands/autocomplete/options.ts index 09ad0707..93e16d69 100644 --- a/src/commands/autocomplete/options.ts +++ b/src/commands/autocomplete/options.ts @@ -1,6 +1,4 @@ import {Args, Command, Flags, Interfaces} from '@oclif/core' -import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs' -import path from 'node:path' export default class Options extends Command { static args = { @@ -21,54 +19,55 @@ export default class Options extends Command { } static hidden = true - private get cacheDir(): string { - return path.join(this.config.cacheDir, 'autocomplete', 'completions') - } - - // Cache duration in seconds - can be configured via env var - private get cacheDuration(): number { - const envDuration = process.env.OCLIF_AUTOCOMPLETE_CACHE_DURATION - return envDuration ? Number.parseInt(envDuration, 10) : 60 * 60 * 24 // Default: 24 hours - } - async run(): Promise { const {args, flags} = await this.parse(Options) const commandId = args.command const flagName = args.flag try { - // Find the command const command = this.config.findCommand(commandId) if (!command) { this.log('') return } - // Load the actual command class to get the completion function - // The manifest doesn't include functions, so we need to load the command class + // Load the actual command class to get the completion definition const CommandClass = await command.load() - // Get the flag definition from the loaded command class + // Get the flag definition const flagDef = CommandClass.flags?.[flagName] as Interfaces.OptionFlag if (!flagDef || !flagDef.completion) { this.log('') return } - // Parse the current command line to extract context - const currentLine = flags['current-line'] || '' - const context = this.parseCommandLine(currentLine) + // Check completion type + // @ts-expect-error - completion.type may not exist yet in types + const completionType = flagDef.completion.type - // Generate cache key - const cacheKey = `${commandId}:${flagName}` + // Handle static completions + if (completionType === 'static') { + const {options} = flagDef.completion + if (Array.isArray(options)) { + this.log(options.join('\n')) + } else { + this.log('') + } - // Check cache - const cached = this.getFromCache(cacheKey) - if (cached) { - this.log(cached.join('\n')) return } + // Handle dynamic completions (or legacy completions without type) + const optionsFunc = flagDef.completion.options + if (typeof optionsFunc !== 'function') { + this.log('') + return + } + + // Parse command line for context + const currentLine = flags['current-line'] || '' + const context = this.parseCommandLine(currentLine) + // Execute the completion function const completionContext: Interfaces.CompletionContext = { args: context.args, @@ -77,12 +76,7 @@ export default class Options extends Command { flags: context.flags, } - const options = await flagDef.completion.options(completionContext) - - // Cache the results - this.saveToCache(cacheKey, options) - - // Output the options + const options = await optionsFunc(completionContext) this.log(options.join('\n')) } catch { // Silently fail and return empty completions @@ -90,29 +84,6 @@ export default class Options extends Command { } } - private getCachePath(key: string): string { - return path.join(this.cacheDir, `${Buffer.from(key).toString('base64')}.json`) - } - - private getFromCache(key: string): null | string[] { - const cachePath = this.getCachePath(key) - if (!existsSync(cachePath)) return null - - try { - const {options, timestamp} = JSON.parse(readFileSync(cachePath, 'utf8')) - const age = Date.now() - timestamp - const maxAge = this.cacheDuration * 1000 - - if (age < maxAge) { - return options - } - - return null - } catch { - return null - } - } - private parseCommandLine(line: string): { args: {[name: string]: string} argv: string[] @@ -155,22 +126,4 @@ export default class Options extends Command { return {args, argv, flags} } - - private saveToCache(key: string, options: string[]): void { - const cachePath = this.getCachePath(key) - const {cacheDir} = this - - try { - mkdirSync(cacheDir, {recursive: true}) - writeFileSync( - cachePath, - JSON.stringify({ - options, - timestamp: Date.now(), - }), - ) - } catch { - // Silently fail if we can't write to cache - } - } } diff --git a/test/autocomplete/zsh.test.ts b/test/autocomplete/zsh.test.ts index 034062fa..987d9cb9 100644 --- a/test/autocomplete/zsh.test.ts +++ b/test/autocomplete/zsh.test.ts @@ -55,6 +55,9 @@ const commandPluginA: Command.Loadable = { flags: { 'api-version': { char: 'a', + completion: { + options: async () => ['50.0', '51.0', '52.0'], + }, multiple: false, name: 'api-version', type: 'option', @@ -74,6 +77,9 @@ const commandPluginA: Command.Loadable = { }, metadata: { char: 'm', + completion: { + options: async () => ['ApexClass', 'CustomObject', 'Profile'], + }, multiple: true, name: 'metadata', type: 'option', @@ -97,6 +103,9 @@ const commandPluginB: Command.Loadable = { flags: { branch: { char: 'b', + completion: { + options: async () => ['main', 'develop', 'feature'], + }, multiple: false, name: 'branch', type: 'option', @@ -206,6 +215,69 @@ skipWindows('zsh comp', () => { expect(zshCompWithSpaces.generate()).to.equal(`#compdef test-cli + +# Dynamic completion helper with timestamp-based caching +_test-cli_dynamic_comp() { + local cmd_id="$1" + local flag_name="$2" + local cache_duration="$3" + local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" + local -a opts + + # Check if cache file exists and is valid + if [[ -f "$cache_file" ]]; then + # Read timestamp from first line + local cache_timestamp=$(head -n 1 "$cache_file" 2>/dev/null) + local current_timestamp=$(date +%s) + local age=$((current_timestamp - cache_timestamp)) + + # Check if cache is still valid + if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then + # Cache valid - read options (skip first line) + opts=("\${(@f)$(tail -n +2 "$cache_file")}") + + # Check if this is a "no completion" marker + if [[ "\${opts[1]}" == "__NO_COMPLETION__" ]]; then + # No completion available - use file completion + _files + return 0 + fi + + if [[ \${#opts[@]} -gt 0 ]]; then + _describe "\${flag_name} options" opts + return 0 + fi + fi + fi + + # Cache miss or expired - call Node.js + mkdir -p "$cache_dir" 2>/dev/null + local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} --current-line="$words" 2>/dev/null) + + if [[ -n "$raw_output" ]]; then + # Save to cache with timestamp + { + echo "$(date +%s)" + echo "$raw_output" + } > "$cache_file" + + # Provide completions + opts=("\${(@f)$raw_output}") + _describe "\${flag_name} options" opts + else + # No completion available - cache empty result to avoid repeated Node.js calls + { + echo "$(date +%s)" + echo "__NO_COMPLETION__" + } > "$cache_file" + + # Fall back to file completion + _files + fi +} + + _test-cli_app() { local context state state_descr line typeset -A opt_args @@ -215,7 +287,7 @@ _test-cli_app() { case "$state" in cmds) _values "completions" \\ -"execute[execute code]" \\ +"execute[execute code]" ;; args) @@ -260,10 +332,10 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]:api-version:(\`test-cli autocomplete:options deploy api-version --current-line="$words" 2>/dev/null\`)" \\ +"(-a --api-version)"{-a,--api-version}"[]": :_test-cli_dynamic_comp deploy api-version 86400" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ --json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]:metadata:(\`test-cli autocomplete:options deploy metadata --current-line="$words" 2>/dev/null\`)" \\ +"*"{-m,--metadata}"[]": :_test-cli_dynamic_comp deploy metadata 86400" \\ --help"[Show help for command]" \\ "*: :_files" } @@ -287,7 +359,7 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]:branch:(\`test-cli autocomplete:options deploy:functions branch --current-line="$words" 2>/dev/null\`)" \\ +"(-b --branch)"{-b,--branch}"[]": :_test-cli_dynamic_comp deploy:functions branch 86400" \\ --help"[Show help for command]" \\ "*: :_files" ;; @@ -312,7 +384,7 @@ _test-cli() { _values "completions" \\ "app[execute code]" \\ "deploy[Deploy a project]" \\ -"search[Search for a command]" \\ +"search[Search for a command]" ;; args) @@ -340,6 +412,69 @@ _test-cli expect(zshCompWithSpaces.generate()).to.equal(`#compdef test-cli compdef testing=test-cli + +# Dynamic completion helper with timestamp-based caching +_test-cli_dynamic_comp() { + local cmd_id="$1" + local flag_name="$2" + local cache_duration="$3" + local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" + local -a opts + + # Check if cache file exists and is valid + if [[ -f "$cache_file" ]]; then + # Read timestamp from first line + local cache_timestamp=$(head -n 1 "$cache_file" 2>/dev/null) + local current_timestamp=$(date +%s) + local age=$((current_timestamp - cache_timestamp)) + + # Check if cache is still valid + if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then + # Cache valid - read options (skip first line) + opts=("\${(@f)$(tail -n +2 "$cache_file")}") + + # Check if this is a "no completion" marker + if [[ "\${opts[1]}" == "__NO_COMPLETION__" ]]; then + # No completion available - use file completion + _files + return 0 + fi + + if [[ \${#opts[@]} -gt 0 ]]; then + _describe "\${flag_name} options" opts + return 0 + fi + fi + fi + + # Cache miss or expired - call Node.js + mkdir -p "$cache_dir" 2>/dev/null + local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} --current-line="$words" 2>/dev/null) + + if [[ -n "$raw_output" ]]; then + # Save to cache with timestamp + { + echo "$(date +%s)" + echo "$raw_output" + } > "$cache_file" + + # Provide completions + opts=("\${(@f)$raw_output}") + _describe "\${flag_name} options" opts + else + # No completion available - cache empty result to avoid repeated Node.js calls + { + echo "$(date +%s)" + echo "__NO_COMPLETION__" + } > "$cache_file" + + # Fall back to file completion + _files + fi +} + + _test-cli_app() { local context state state_descr line typeset -A opt_args @@ -349,7 +484,7 @@ _test-cli_app() { case "$state" in cmds) _values "completions" \\ -"execute[execute code]" \\ +"execute[execute code]" ;; args) @@ -394,10 +529,10 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]:api-version:(\`test-cli autocomplete:options deploy api-version --current-line="$words" 2>/dev/null\`)" \\ +"(-a --api-version)"{-a,--api-version}"[]": :_test-cli_dynamic_comp deploy api-version 86400" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ --json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]:metadata:(\`test-cli autocomplete:options deploy metadata --current-line="$words" 2>/dev/null\`)" \\ +"*"{-m,--metadata}"[]": :_test-cli_dynamic_comp deploy metadata 86400" \\ --help"[Show help for command]" \\ "*: :_files" } @@ -421,7 +556,7 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]:branch:(\`test-cli autocomplete:options deploy:functions branch --current-line="$words" 2>/dev/null\`)" \\ +"(-b --branch)"{-b,--branch}"[]": :_test-cli_dynamic_comp deploy:functions branch 86400" \\ --help"[Show help for command]" \\ "*: :_files" ;; @@ -446,7 +581,7 @@ _test-cli() { _values "completions" \\ "app[execute code]" \\ "deploy[Deploy a project]" \\ -"search[Search for a command]" \\ +"search[Search for a command]" ;; args) @@ -475,6 +610,69 @@ _test-cli compdef testing=test-cli compdef testing2=test-cli + +# Dynamic completion helper with timestamp-based caching +_test-cli_dynamic_comp() { + local cmd_id="$1" + local flag_name="$2" + local cache_duration="$3" + local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" + local -a opts + + # Check if cache file exists and is valid + if [[ -f "$cache_file" ]]; then + # Read timestamp from first line + local cache_timestamp=$(head -n 1 "$cache_file" 2>/dev/null) + local current_timestamp=$(date +%s) + local age=$((current_timestamp - cache_timestamp)) + + # Check if cache is still valid + if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then + # Cache valid - read options (skip first line) + opts=("\${(@f)$(tail -n +2 "$cache_file")}") + + # Check if this is a "no completion" marker + if [[ "\${opts[1]}" == "__NO_COMPLETION__" ]]; then + # No completion available - use file completion + _files + return 0 + fi + + if [[ \${#opts[@]} -gt 0 ]]; then + _describe "\${flag_name} options" opts + return 0 + fi + fi + fi + + # Cache miss or expired - call Node.js + mkdir -p "$cache_dir" 2>/dev/null + local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} --current-line="$words" 2>/dev/null) + + if [[ -n "$raw_output" ]]; then + # Save to cache with timestamp + { + echo "$(date +%s)" + echo "$raw_output" + } > "$cache_file" + + # Provide completions + opts=("\${(@f)$raw_output}") + _describe "\${flag_name} options" opts + else + # No completion available - cache empty result to avoid repeated Node.js calls + { + echo "$(date +%s)" + echo "__NO_COMPLETION__" + } > "$cache_file" + + # Fall back to file completion + _files + fi +} + + _test-cli_app() { local context state state_descr line typeset -A opt_args @@ -484,7 +682,7 @@ _test-cli_app() { case "$state" in cmds) _values "completions" \\ -"execute[execute code]" \\ +"execute[execute code]" ;; args) @@ -529,10 +727,10 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]:api-version:(\`test-cli autocomplete:options deploy api-version --current-line="$words" 2>/dev/null\`)" \\ +"(-a --api-version)"{-a,--api-version}"[]": :_test-cli_dynamic_comp deploy api-version 86400" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ --json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]:metadata:(\`test-cli autocomplete:options deploy metadata --current-line="$words" 2>/dev/null\`)" \\ +"*"{-m,--metadata}"[]": :_test-cli_dynamic_comp deploy metadata 86400" \\ --help"[Show help for command]" \\ "*: :_files" } @@ -556,7 +754,7 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]:branch:(\`test-cli autocomplete:options deploy:functions branch --current-line="$words" 2>/dev/null\`)" \\ +"(-b --branch)"{-b,--branch}"[]": :_test-cli_dynamic_comp deploy:functions branch 86400" \\ --help"[Show help for command]" \\ "*: :_files" ;; @@ -581,7 +779,7 @@ _test-cli() { _values "completions" \\ "app[execute code]" \\ "deploy[Deploy a project]" \\ -"search[Search for a command]" \\ +"search[Search for a command]" ;; args) From c2eec82561e82189f4386bc9e8a68c14d83e7088 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 14 Oct 2025 11:41:12 -0600 Subject: [PATCH 03/16] chore: next steps --- src/autocomplete/zsh.ts | 57 ++++-------- test/autocomplete/zsh.test.ts | 162 +++++++++++----------------------- 2 files changed, 70 insertions(+), 149 deletions(-) diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index 62053890..b7693c8c 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -127,13 +127,13 @@ _${this.config.bin} private genDynamicCompletionHelper(): string { // Using ${'$'} instead of \$ to avoid linter errors return `# Dynamic completion helper with timestamp-based caching +# This outputs completion options to stdout for use in command substitution _${this.config.bin}_dynamic_comp() { local cmd_id="$1" local flag_name="$2" local cache_duration="$3" local cache_dir="$HOME/.cache/${this.config.bin}/autocomplete/flag_completions" local cache_file="$cache_dir/${'$'}{cmd_id//[:]/_}_${'$'}{flag_name}.cache" - local -a opts # Check if cache file exists and is valid if [[ -f "$cache_file" ]]; then @@ -144,26 +144,15 @@ _${this.config.bin}_dynamic_comp() { # Check if cache is still valid if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then - # Cache valid - read options (skip first line) - opts=("${'$'}{(@f)${'$'}(tail -n +2 "$cache_file")}") - - # Check if this is a "no completion" marker - if [[ "${'$'}{opts[1]}" == "__NO_COMPLETION__" ]]; then - # No completion available - use file completion - _files - return 0 - fi - - if [[ ${'$'}{#opts[@]} -gt 0 ]]; then - _describe "${'$'}{flag_name} options" opts - return 0 - fi + # Cache valid - read and output options (skip first line and empty lines) + tail -n +2 "$cache_file" | grep -v "^${'$'}" + return 0 fi fi # Cache miss or expired - call Node.js mkdir -p "$cache_dir" 2>/dev/null - local raw_output=${'$'}(${this.config.bin} autocomplete${this.config.topicSeparator}options ${'$'}{cmd_id} ${'$'}{flag_name} --current-line="$words" 2>/dev/null) + local raw_output=${'$'}(${this.config.bin} autocomplete:options ${'$'}{cmd_id} ${'$'}{flag_name} 2>/dev/null) if [[ -n "$raw_output" ]]; then # Save to cache with timestamp @@ -172,19 +161,10 @@ _${this.config.bin}_dynamic_comp() { echo "$raw_output" } > "$cache_file" - # Provide completions - opts=("${'$'}{(@f)$raw_output}") - _describe "${'$'}{flag_name} options" opts - else - # No completion available - cache empty result to avoid repeated Node.js calls - { - echo "${'$'}(date +%s)" - echo "__NO_COMPLETION__" - } > "$cache_file" - - # Fall back to file completion - _files + # Output the completions + echo "$raw_output" fi + # If no output, return nothing (will fall back to default completion) } ` } @@ -204,20 +184,21 @@ _${this.config.bin}_dynamic_comp() { // STATIC: Embed options directly (instant!) // @ts-expect-error - we checked it's an array above const options = f.completion.options.join(' ') - return f.char ? `:${f.name}:(${options})` : `${f.name}:(${options})` + return f.char ? `:${f.name}:(${options})` : `: :${f.name}:(${options})` } if (f.options) { // Legacy static options - return f.char ? `:${f.name} options:(${f.options?.join(' ')})` : `${f.name} options:(${f.options.join(' ')})` + return f.char ? `:${f.name} options:(${f.options?.join(' ')})` : `: :${f.name} options:(${f.options.join(' ')})` } // ALWAYS try dynamic completion for option flags if we have a command ID // The autocomplete:options command will return empty if no completion is defined, // and the shell script will fall back to _files if (commandId) { - // For both char and non-char flags: format is ": :action" (no closing quote!) - return `: :_${this.config.bin}_dynamic_comp ${commandId} ${f.name} ${cacheDuration}` + // Use command substitution to generate completions inline + // The ~15ms from cache reads is acceptable for working completions + return `: :(${'$'}(_${this.config.bin}_dynamic_comp ${commandId} ${f.name} ${cacheDuration}))` } // No command ID - fall back to file completion @@ -227,7 +208,7 @@ _${this.config.bin}_dynamic_comp() { private genZshFlagArgumentsBlock(flags?: CommandFlags, commandId?: string): string { // if a command doesn't have flags make it only complete files // also add comp for the global `--help` flag. - if (!flags) return '_arguments -S \\\n --help"[Show help for command]" "*: :_files' + if (!flags) return '_arguments -S \\\n"--help[Show help for command]" \\\n"*: :_files"' const flagNames = Object.keys(flags) @@ -250,7 +231,7 @@ _${this.config.bin}_dynamic_comp() { } // add global `--help` flag - argumentsBlock += '--help"[Show help for command]" \\\n' + argumentsBlock += '"--help[Show help for command]" \\\n' // complete files if `-` is not present on the current line argumentsBlock += '"*: :_files"' @@ -267,7 +248,7 @@ _${this.config.bin}_dynamic_comp() { return `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}"[${flagSummary}]"` } - return `--${f.name}"[${flagSummary}]"` + return `"--${f.name}[${flagSummary}]"` } private genZshOptionFlagSpec(f: Command.Flag.Cached, flagSummary: string, commandId?: string): string { @@ -279,11 +260,11 @@ _${this.config.bin}_dynamic_comp() { const multiplePart = optionFlag.multiple ? `"*"{-${f.char},--${f.name}}` : `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}` - return `${multiplePart}"[${flagSummary}]"${completionSuffix}"` + return `${multiplePart}"[${flagSummary}]${completionSuffix}"` } - const multiplePart = optionFlag.multiple ? '"*"' : '' - return `${multiplePart}--${f.name}"[${flagSummary}]"${completionSuffix}"` + const multiplePart = optionFlag.multiple ? '*' : '' + return `"${multiplePart}--${f.name}[${flagSummary}]${completionSuffix}"` } private genZshTopicCompFun(id: string): string { diff --git a/test/autocomplete/zsh.test.ts b/test/autocomplete/zsh.test.ts index 987d9cb9..41a3e6ca 100644 --- a/test/autocomplete/zsh.test.ts +++ b/test/autocomplete/zsh.test.ts @@ -217,13 +217,13 @@ skipWindows('zsh comp', () => { # Dynamic completion helper with timestamp-based caching +# This outputs completion options to stdout for use in command substitution _test-cli_dynamic_comp() { local cmd_id="$1" local flag_name="$2" local cache_duration="$3" local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" - local -a opts # Check if cache file exists and is valid if [[ -f "$cache_file" ]]; then @@ -234,26 +234,15 @@ _test-cli_dynamic_comp() { # Check if cache is still valid if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then - # Cache valid - read options (skip first line) - opts=("\${(@f)$(tail -n +2 "$cache_file")}") - - # Check if this is a "no completion" marker - if [[ "\${opts[1]}" == "__NO_COMPLETION__" ]]; then - # No completion available - use file completion - _files - return 0 - fi - - if [[ \${#opts[@]} -gt 0 ]]; then - _describe "\${flag_name} options" opts - return 0 - fi + # Cache valid - read and output options (skip first line and empty lines) + tail -n +2 "$cache_file" | grep -v "^$" + return 0 fi fi # Cache miss or expired - call Node.js mkdir -p "$cache_dir" 2>/dev/null - local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} --current-line="$words" 2>/dev/null) + local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} 2>/dev/null) if [[ -n "$raw_output" ]]; then # Save to cache with timestamp @@ -262,19 +251,10 @@ _test-cli_dynamic_comp() { echo "$raw_output" } > "$cache_file" - # Provide completions - opts=("\${(@f)$raw_output}") - _describe "\${flag_name} options" opts - else - # No completion available - cache empty result to avoid repeated Node.js calls - { - echo "$(date +%s)" - echo "__NO_COMPLETION__" - } > "$cache_file" - - # Fall back to file completion - _files + # Output the completions + echo "$raw_output" fi + # If no output, return nothing (will fall back to default completion) } @@ -310,14 +290,14 @@ _test-cli_app_execute() { case "$state" in cmds) _values "completions" \\ -"code[execute code]" \\ +"code[execute code]" ;; args) case $line[1] in "code") _arguments -S \\ ---help"[Show help for command]" \\ +"--help[Show help for command]" \\ "*: :_files" ;; @@ -332,11 +312,11 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]": :_test-cli_dynamic_comp deploy api-version 86400" \\ +"(-a --api-version)"{-a,--api-version}"[]: :($(_test-cli_dynamic_comp deploy api-version 86400))" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ ---json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]": :_test-cli_dynamic_comp deploy metadata 86400" \\ ---help"[Show help for command]" \\ +"--json[Format output as json.]" \\ +"*"{-m,--metadata}"[]: :($(_test-cli_dynamic_comp deploy metadata 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" } @@ -351,7 +331,7 @@ _test-cli_deploy() { _test-cli_deploy_flags else _values "completions" \\ -"functions[Deploy a function.]" \\ +"functions[Deploy a function.]" fi ;; @@ -359,8 +339,8 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]": :_test-cli_dynamic_comp deploy:functions branch 86400" \\ ---help"[Show help for command]" \\ +"(-b --branch)"{-b,--branch}"[]: :($(_test-cli_dynamic_comp deploy:functions branch 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" ;; @@ -414,13 +394,13 @@ compdef testing=test-cli # Dynamic completion helper with timestamp-based caching +# This outputs completion options to stdout for use in command substitution _test-cli_dynamic_comp() { local cmd_id="$1" local flag_name="$2" local cache_duration="$3" local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" - local -a opts # Check if cache file exists and is valid if [[ -f "$cache_file" ]]; then @@ -431,26 +411,15 @@ _test-cli_dynamic_comp() { # Check if cache is still valid if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then - # Cache valid - read options (skip first line) - opts=("\${(@f)$(tail -n +2 "$cache_file")}") - - # Check if this is a "no completion" marker - if [[ "\${opts[1]}" == "__NO_COMPLETION__" ]]; then - # No completion available - use file completion - _files - return 0 - fi - - if [[ \${#opts[@]} -gt 0 ]]; then - _describe "\${flag_name} options" opts - return 0 - fi + # Cache valid - read and output options (skip first line and empty lines) + tail -n +2 "$cache_file" | grep -v "^$" + return 0 fi fi # Cache miss or expired - call Node.js mkdir -p "$cache_dir" 2>/dev/null - local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} --current-line="$words" 2>/dev/null) + local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} 2>/dev/null) if [[ -n "$raw_output" ]]; then # Save to cache with timestamp @@ -459,19 +428,10 @@ _test-cli_dynamic_comp() { echo "$raw_output" } > "$cache_file" - # Provide completions - opts=("\${(@f)$raw_output}") - _describe "\${flag_name} options" opts - else - # No completion available - cache empty result to avoid repeated Node.js calls - { - echo "$(date +%s)" - echo "__NO_COMPLETION__" - } > "$cache_file" - - # Fall back to file completion - _files + # Output the completions + echo "$raw_output" fi + # If no output, return nothing (will fall back to default completion) } @@ -507,14 +467,14 @@ _test-cli_app_execute() { case "$state" in cmds) _values "completions" \\ -"code[execute code]" \\ +"code[execute code]" ;; args) case $line[1] in "code") _arguments -S \\ ---help"[Show help for command]" \\ +"--help[Show help for command]" \\ "*: :_files" ;; @@ -529,11 +489,11 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]": :_test-cli_dynamic_comp deploy api-version 86400" \\ +"(-a --api-version)"{-a,--api-version}"[]: :($(_test-cli_dynamic_comp deploy api-version 86400))" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ ---json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]": :_test-cli_dynamic_comp deploy metadata 86400" \\ ---help"[Show help for command]" \\ +"--json[Format output as json.]" \\ +"*"{-m,--metadata}"[]: :($(_test-cli_dynamic_comp deploy metadata 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" } @@ -548,7 +508,7 @@ _test-cli_deploy() { _test-cli_deploy_flags else _values "completions" \\ -"functions[Deploy a function.]" \\ +"functions[Deploy a function.]" fi ;; @@ -556,8 +516,8 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]": :_test-cli_dynamic_comp deploy:functions branch 86400" \\ ---help"[Show help for command]" \\ +"(-b --branch)"{-b,--branch}"[]: :($(_test-cli_dynamic_comp deploy:functions branch 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" ;; @@ -612,13 +572,13 @@ compdef testing2=test-cli # Dynamic completion helper with timestamp-based caching +# This outputs completion options to stdout for use in command substitution _test-cli_dynamic_comp() { local cmd_id="$1" local flag_name="$2" local cache_duration="$3" local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" - local -a opts # Check if cache file exists and is valid if [[ -f "$cache_file" ]]; then @@ -629,26 +589,15 @@ _test-cli_dynamic_comp() { # Check if cache is still valid if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then - # Cache valid - read options (skip first line) - opts=("\${(@f)$(tail -n +2 "$cache_file")}") - - # Check if this is a "no completion" marker - if [[ "\${opts[1]}" == "__NO_COMPLETION__" ]]; then - # No completion available - use file completion - _files - return 0 - fi - - if [[ \${#opts[@]} -gt 0 ]]; then - _describe "\${flag_name} options" opts - return 0 - fi + # Cache valid - read and output options (skip first line and empty lines) + tail -n +2 "$cache_file" | grep -v "^$" + return 0 fi fi # Cache miss or expired - call Node.js mkdir -p "$cache_dir" 2>/dev/null - local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} --current-line="$words" 2>/dev/null) + local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} 2>/dev/null) if [[ -n "$raw_output" ]]; then # Save to cache with timestamp @@ -657,19 +606,10 @@ _test-cli_dynamic_comp() { echo "$raw_output" } > "$cache_file" - # Provide completions - opts=("\${(@f)$raw_output}") - _describe "\${flag_name} options" opts - else - # No completion available - cache empty result to avoid repeated Node.js calls - { - echo "$(date +%s)" - echo "__NO_COMPLETION__" - } > "$cache_file" - - # Fall back to file completion - _files + # Output the completions + echo "$raw_output" fi + # If no output, return nothing (will fall back to default completion) } @@ -705,14 +645,14 @@ _test-cli_app_execute() { case "$state" in cmds) _values "completions" \\ -"code[execute code]" \\ +"code[execute code]" ;; args) case $line[1] in "code") _arguments -S \\ ---help"[Show help for command]" \\ +"--help[Show help for command]" \\ "*: :_files" ;; @@ -727,11 +667,11 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]": :_test-cli_dynamic_comp deploy api-version 86400" \\ +"(-a --api-version)"{-a,--api-version}"[]: :($(_test-cli_dynamic_comp deploy api-version 86400))" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ ---json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]": :_test-cli_dynamic_comp deploy metadata 86400" \\ ---help"[Show help for command]" \\ +"--json[Format output as json.]" \\ +"*"{-m,--metadata}"[]: :($(_test-cli_dynamic_comp deploy metadata 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" } @@ -746,7 +686,7 @@ _test-cli_deploy() { _test-cli_deploy_flags else _values "completions" \\ -"functions[Deploy a function.]" \\ +"functions[Deploy a function.]" fi ;; @@ -754,8 +694,8 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]": :_test-cli_dynamic_comp deploy:functions branch 86400" \\ ---help"[Show help for command]" \\ +"(-b --branch)"{-b,--branch}"[]: :($(_test-cli_dynamic_comp deploy:functions branch 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" ;; From 11f83a0304cdab2a7387302a5714daded4c9e765 Mon Sep 17 00:00:00 2001 From: svc-cli-bot Date: Tue, 14 Oct 2025 17:42:47 +0000 Subject: [PATCH 04/16] chore(release): 3.2.37-autocomplete.0 [skip ci] --- README.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e162262c..20f08c3a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ EXAMPLES $ oclif-example autocomplete --refresh-cache ``` -_See code: [src/commands/autocomplete/index.ts](https://github.com/oclif/plugin-autocomplete/blob/3.2.36/src/commands/autocomplete/index.ts)_ +_See code: [src/commands/autocomplete/index.ts](https://github.com/oclif/plugin-autocomplete/blob/3.2.37-autocomplete.0/src/commands/autocomplete/index.ts)_ diff --git a/package.json b/package.json index b526918c..acfc8cef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@oclif/plugin-autocomplete", "description": "autocomplete plugin for oclif", - "version": "3.2.36", + "version": "3.2.37-autocomplete.0", "author": "Salesforce", "bugs": "https://github.com/oclif/plugin-autocomplete/issues", "dependencies": { From 5582d597b83af4fa237f90bb5f337da700ed01f2 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 14 Oct 2025 13:44:16 -0600 Subject: [PATCH 05/16] chore: create caches on --refresh-cache, don't worry about flags --- src/autocomplete/zsh.ts | 22 +++--- src/commands/autocomplete/index.ts | 103 ++++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index b7693c8c..5ccb4f82 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -174,16 +174,15 @@ _${this.config.bin}_dynamic_comp() { if (f.type !== 'option') return '' // Check completion type: static, dynamic, or none - // @ts-expect-error - completion.type may not exist yet in types - const completionType = f.completion?.type - const hasStaticCompletion = completionType === 'static' && Array.isArray(f.completion?.options) - // @ts-expect-error - completion.cacheDuration may not exist yet in types - const cacheDuration = f.completion?.cacheDuration || 86_400 // Default: 24 hours + const {completion} = f as any + const completionType = completion?.type + const hasStaticCompletion = completionType === 'static' && Array.isArray(completion?.options) + const hasDynamicCompletion = completionType === 'dynamic' || typeof completion?.options === 'function' + const cacheDuration = completion?.cacheDuration || 86_400 // Default: 24 hours if (hasStaticCompletion && commandId) { // STATIC: Embed options directly (instant!) - // @ts-expect-error - we checked it's an array above - const options = f.completion.options.join(' ') + const options = completion.options.join(' ') return f.char ? `:${f.name}:(${options})` : `: :${f.name}:(${options})` } @@ -192,16 +191,13 @@ _${this.config.bin}_dynamic_comp() { return f.char ? `:${f.name} options:(${f.options?.join(' ')})` : `: :${f.name} options:(${f.options.join(' ')})` } - // ALWAYS try dynamic completion for option flags if we have a command ID - // The autocomplete:options command will return empty if no completion is defined, - // and the shell script will fall back to _files - if (commandId) { + // ONLY add dynamic completion if the flag has a completion property + if (hasDynamicCompletion && commandId) { // Use command substitution to generate completions inline - // The ~15ms from cache reads is acceptable for working completions return `: :(${'$'}(_${this.config.bin}_dynamic_comp ${commandId} ${f.name} ${cacheDuration}))` } - // No command ID - fall back to file completion + // No completion defined - fall back to file completion return f.char ? ':file:_files' : 'file:_files' } diff --git a/src/commands/autocomplete/index.ts b/src/commands/autocomplete/index.ts index 3d3a7a9d..4bbc8092 100644 --- a/src/commands/autocomplete/index.ts +++ b/src/commands/autocomplete/index.ts @@ -4,6 +4,7 @@ import {EOL} from 'node:os' import {AutocompleteBase} from '../../base.js' import Create from './create.js' +import Options from './options.js' export default class Index extends AutocompleteBase { static args = { @@ -39,11 +40,82 @@ export default class Index extends AutocompleteBase { await Create.run([], this.config) ux.action.stop() + // Pre-warm dynamic completion caches + await this.prewarmCompletionCaches() + if (!flags['refresh-cache']) { this.printShellInstructions(shell) } } + private async prewarmCompletionCaches(): Promise { + const commandsWithDynamicCompletions: Array<{commandId: string; flagName: string}> = [] + + // Find all commands with flags that have completion functions + for (const commandId of this.config.commandIDs) { + const command = this.config.findCommand(commandId) + if (!command) continue + + try { + // Load the actual command class to access completion functions + // eslint-disable-next-line no-await-in-loop + const CommandClass = await command.load() + const flags = CommandClass.flags || {} + + for (const [flagName, flag] of Object.entries(flags)) { + if (flag.type !== 'option') continue + + const {completion} = flag as any + if (!completion) continue + + // Check if it has dynamic completion or legacy options function + const isDynamic = completion.type === 'dynamic' || typeof completion.options === 'function' + + if (isDynamic) { + commandsWithDynamicCompletions.push({commandId, flagName}) + } + } + } catch { + // Ignore errors loading command class + continue + } + } + + if (commandsWithDynamicCompletions.length === 0) { + this.log('No dynamic completions to pre-warm.') + return + } + + const total = commandsWithDynamicCompletions.length + const startTime = Date.now() + + ux.action.start(`${bold('Pre-warming')} ${total} ${bold('dynamic completion caches')} ${cyan('(in parallel)')}`) + + // Pre-warm caches in parallel with concurrency limit + const concurrency = 10 // Run 10 at a time + const results = await this.runWithConcurrency( + commandsWithDynamicCompletions, + concurrency, + async ({commandId, flagName}, index) => { + ux.action.status = `${index + 1}/${total}` + try { + await Options.run([commandId, flagName], this.config) + return {success: true} + } catch { + // Ignore errors - some completions may fail, that's ok + return {success: false} + } + }, + ) + + const successful = results.filter((r) => r.success).length + const duration = ((Date.now() - startTime) / 1000).toFixed(1) + + ux.action.stop( + `${bold('✓')} Pre-warmed ${successful}/${total} caches in ${cyan(duration + 's')} ${cyan(`(~${(total / Number(duration)).toFixed(0)}/s)`)}`, + ) + } + private printShellInstructions(shell: string): void { const setupEnvVar = this.getSetupEnvVar(shell) const tabStr = shell === 'bash' ? '' : '' @@ -63,7 +135,7 @@ Setup Instructions for ${this.config.bin.toUpperCase()} CLI Autocomplete --- The previous command adds the ${cyan(setupEnvVar)} environment variable to your Bash config file and then sources the file. - ${bold('NOTE')}: If you’ve configured your terminal to start as a login shell, you may need to modify the command so it updates either the ~/.bash_profile or ~/.profile file. For example: + ${bold('NOTE')}: If you've configured your terminal to start as a login shell, you may need to modify the command so it updates either the ~/.bash_profile or ~/.profile file. For example: ${cyan(`printf "$(${scriptCommand})" >> ~/.bash_profile; source ~/.bash_profile`)} @@ -126,4 +198,33 @@ Setup Instructions for ${this.config.bin.toUpperCase()} CLI Autocomplete --- ` this.log(instructions) } + + /** + * Run async tasks with concurrency limit + */ + private async runWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T, index: number) => Promise, + ): Promise { + const results: R[] = [] + const executing: Array> = [] + + for (const [index, item] of items.entries()) { + const promise = fn(item, index).then((result) => { + results[index] = result + executing.splice(executing.indexOf(promise), 1) + }) + + executing.push(promise) + + if (executing.length >= concurrency) { + // eslint-disable-next-line no-await-in-loop + await Promise.race(executing) + } + } + + await Promise.all(executing) + return results + } } From 929d446a46b2304c9433eb05bef6da45652ab5c6 Mon Sep 17 00:00:00 2001 From: svc-cli-bot Date: Tue, 14 Oct 2025 19:45:53 +0000 Subject: [PATCH 06/16] chore(release): 3.2.37-autocomplete.1 [skip ci] --- README.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 20f08c3a..199cf308 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ EXAMPLES $ oclif-example autocomplete --refresh-cache ``` -_See code: [src/commands/autocomplete/index.ts](https://github.com/oclif/plugin-autocomplete/blob/3.2.37-autocomplete.0/src/commands/autocomplete/index.ts)_ +_See code: [src/commands/autocomplete/index.ts](https://github.com/oclif/plugin-autocomplete/blob/3.2.37-autocomplete.1/src/commands/autocomplete/index.ts)_ diff --git a/package.json b/package.json index acfc8cef..097ed617 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@oclif/plugin-autocomplete", "description": "autocomplete plugin for oclif", - "version": "3.2.37-autocomplete.0", + "version": "3.2.37-autocomplete.1", "author": "Salesforce", "bugs": "https://github.com/oclif/plugin-autocomplete/issues", "dependencies": { From 44d9b6fc9060f2221f2606a2951d129880d34417 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 14 Oct 2025 14:26:32 -0600 Subject: [PATCH 07/16] chore: temp --- src/autocomplete/zsh.ts | 29 +++++++++++++++++++++++++---- src/commands/autocomplete/create.ts | 6 +++++- src/commands/autocomplete/index.ts | 23 ++++++++++++++++++++--- test/autocomplete/zsh.test.ts | 12 ++++++------ 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index 5ccb4f82..6ac0c753 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -28,7 +28,11 @@ export default class ZshCompWithSpaces { constructor(config: Config) { this.config = config this.topics = this.getTopics() - this.commands = this.getCommands() + this.commands = [] + } + + async init(): Promise { + this.commands = await this.getCommands() } private get coTopics(): string[] { @@ -49,7 +53,12 @@ export default class ZshCompWithSpaces { return this._coTopics } - public generate(): string { + public async generate(): Promise { + // Ensure commands are loaded with completion properties + if (this.commands.length === 0) { + await this.init() + } + const firstArgs: {id: string; summary?: string}[] = [] for (const t of this.topics) { @@ -407,14 +416,26 @@ _${this.config.bin}_dynamic_comp() { return valuesBlock } - private getCommands(): CommandCompletion[] { + private async getCommands(): Promise { const cmds: CommandCompletion[] = [] for (const p of this.config.getPluginsList()) { for (const c of p.commands) { if (c.hidden) continue const summary = this.sanitizeSummary(c.summary ?? c.description) - const {flags} = c + + // Load the actual command class to get flags with completion properties + let flags = c.flags + try { + const CommandClass = await c.load() + // Use flags from command class if available (includes completion properties) + if (CommandClass.flags) { + flags = CommandClass.flags as CommandFlags + } + } catch { + // Fall back to manifest flags if loading fails + } + cmds.push({ flags, id: c.id, diff --git a/src/commands/autocomplete/create.ts b/src/commands/autocomplete/create.ts index 983e3325..888852f4 100644 --- a/src/commands/autocomplete/create.ts +++ b/src/commands/autocomplete/create.ts @@ -233,6 +233,10 @@ compinit;\n` // zsh const supportSpaces = this.config.topicSeparator === ' ' + // Generate completion scripts + const zshGenerator = new ZshCompWithSpaces(this.config) + const zshScript = await zshGenerator.generate() + await Promise.all( [ writeFile(this.bashSetupScriptPath, this.bashSetupScript), @@ -243,7 +247,7 @@ compinit;\n` process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces ? [writeFile(this.zshCompletionFunctionPath, this.zshCompletionFunction)] : [ - writeFile(this.zshCompletionFunctionPath, new ZshCompWithSpaces(this.config).generate()), + writeFile(this.zshCompletionFunctionPath, zshScript), writeFile(this.pwshCompletionFunctionPath, new PowerShellComp(this.config).generate()), ], ), diff --git a/src/commands/autocomplete/index.ts b/src/commands/autocomplete/index.ts index 4bbc8092..8e2a6f08 100644 --- a/src/commands/autocomplete/index.ts +++ b/src/commands/autocomplete/index.ts @@ -38,14 +38,16 @@ export default class Index extends AutocompleteBase { ux.action.start(`${bold('Building the autocomplete cache')}`) await Create.run([], this.config) - ux.action.stop() + + ux.action.status = 'Pre-warming dynamic completion caches' // Pre-warm dynamic completion caches await this.prewarmCompletionCaches() if (!flags['refresh-cache']) { this.printShellInstructions(shell) - } + } + ux.action.stop() } private async prewarmCompletionCaches(): Promise { @@ -99,7 +101,22 @@ export default class Index extends AutocompleteBase { async ({commandId, flagName}, index) => { ux.action.status = `${index + 1}/${total}` try { - await Options.run([commandId, flagName], this.config) + // Suppress stdout by temporarily replacing console.log and process.stdout.write + const originalLog = console.log + const originalWrite = process.stdout.write.bind(process.stdout) + // eslint-disable-next-line @typescript-eslint/no-empty-function + console.log = () => { } + // @ts-ignore - intentionally suppressing stdout + process.stdout.write = () => true + + try { + await Options.run([commandId, flagName], this.config) + } finally { + // Restore stdout + console.log = originalLog + process.stdout.write = originalWrite + } + return {success: true} } catch { // Ignore errors - some completions may fail, that's ok diff --git a/test/autocomplete/zsh.test.ts b/test/autocomplete/zsh.test.ts index 41a3e6ca..17db61f4 100644 --- a/test/autocomplete/zsh.test.ts +++ b/test/autocomplete/zsh.test.ts @@ -209,10 +209,10 @@ skipWindows('zsh comp', () => { } }) - it('generates a valid completion file.', () => { + it('generates a valid completion file.', async () => { config.bin = 'test-cli' const zshCompWithSpaces = new ZshCompWithSpaces(config as Config) - expect(zshCompWithSpaces.generate()).to.equal(`#compdef test-cli + expect(await zshCompWithSpaces.generate()).to.equal(`#compdef test-cli @@ -385,11 +385,11 @@ _test-cli `) }) - it('generates a valid completion file with a bin alias.', () => { + it('generates a valid completion file with a bin alias.', async () => { config.bin = 'test-cli' config.binAliases = ['testing'] const zshCompWithSpaces = new ZshCompWithSpaces(config as Config) - expect(zshCompWithSpaces.generate()).to.equal(`#compdef test-cli + expect(await zshCompWithSpaces.generate()).to.equal(`#compdef test-cli compdef testing=test-cli @@ -562,11 +562,11 @@ _test-cli `) }) - it('generates a valid completion file with multiple bin aliases.', () => { + it('generates a valid completion file with multiple bin aliases.', async () => { config.bin = 'test-cli' config.binAliases = ['testing', 'testing2'] const zshCompWithSpaces = new ZshCompWithSpaces(config as Config) - expect(zshCompWithSpaces.generate()).to.equal(`#compdef test-cli + expect(await zshCompWithSpaces.generate()).to.equal(`#compdef test-cli compdef testing=test-cli compdef testing2=test-cli From 21a8b202e709a0ced0e4a0a9e8116f046512d022 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 14 Oct 2025 14:37:35 -0600 Subject: [PATCH 08/16] fix: working attempt --- src/autocomplete/zsh.ts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index 6ac0c753..2ce4c427 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -178,7 +178,7 @@ _${this.config.bin}_dynamic_comp() { ` } - private genZshCompletionSuffix(f: Command.Flag.Cached, commandId: string | undefined): string { + private genZshCompletionSuffix(f: Command.Flag.Cached, flagName: string, commandId: string | undefined): string { // Only handle option flags if (f.type !== 'option') return '' @@ -192,22 +192,22 @@ _${this.config.bin}_dynamic_comp() { if (hasStaticCompletion && commandId) { // STATIC: Embed options directly (instant!) const options = completion.options.join(' ') - return f.char ? `:${f.name}:(${options})` : `: :${f.name}:(${options})` + return f.char ? `:${flagName}:(${options})` : `: :${flagName}:(${options})` } if (f.options) { // Legacy static options - return f.char ? `:${f.name} options:(${f.options?.join(' ')})` : `: :${f.name} options:(${f.options.join(' ')})` + return f.char ? `:${flagName} options:(${f.options?.join(' ')})` : `: :${flagName} options:(${f.options.join(' ')})` } // ONLY add dynamic completion if the flag has a completion property if (hasDynamicCompletion && commandId) { // Use command substitution to generate completions inline - return `: :(${'$'}(_${this.config.bin}_dynamic_comp ${commandId} ${f.name} ${cacheDuration}))` + return `: :(${'$'}(_${this.config.bin}_dynamic_comp ${commandId} ${flagName} ${cacheDuration}))` } // No completion defined - fall back to file completion - return f.char ? ':file:_files' : 'file:_files' + return ':file:_files' } private genZshFlagArgumentsBlock(flags?: CommandFlags, commandId?: string): string { @@ -225,12 +225,13 @@ _${this.config.bin}_dynamic_comp() { for (const flagName of flagNames) { const f = flags[flagName] + // willie testing changes // skip hidden flags if (f.hidden) continue const flagSummary = this.sanitizeSummary(f.summary ?? f.description) - const flagSpec = this.genZshFlagSpec(f, flagSummary, commandId) + const flagSpec = this.genZshFlagSpec(f, flagName, flagSummary, commandId) argumentsBlock += flagSpec + ' \\\n' } @@ -243,33 +244,33 @@ _${this.config.bin}_dynamic_comp() { return argumentsBlock } - private genZshFlagSpec(f: Command.Flag.Any, flagSummary: string, commandId?: string): string { + private genZshFlagSpec(f: Command.Flag.Any, flagName: string, flagSummary: string, commandId?: string): string { if (f.type === 'option') { - return this.genZshOptionFlagSpec(f, flagSummary, commandId) + return this.genZshOptionFlagSpec(f, flagName, flagSummary, commandId) } // Boolean flag if (f.char) { - return `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}"[${flagSummary}]"` + return `"(-${f.char} --${flagName})"{-${f.char},--${flagName}}"[${flagSummary}]"` } - return `"--${f.name}[${flagSummary}]"` + return `"--${flagName}[${flagSummary}]"` } - private genZshOptionFlagSpec(f: Command.Flag.Cached, flagSummary: string, commandId?: string): string { + private genZshOptionFlagSpec(f: Command.Flag.Cached, flagName: string, flagSummary: string, commandId?: string): string { // TypeScript doesn't narrow f to option type, so we cast const optionFlag = f as Command.Flag.Cached & {multiple?: boolean} - const completionSuffix = this.genZshCompletionSuffix(f, commandId) + const completionSuffix = this.genZshCompletionSuffix(f, flagName, commandId) if (f.char) { const multiplePart = optionFlag.multiple - ? `"*"{-${f.char},--${f.name}}` - : `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}` + ? `"*"{-${f.char},--${flagName}}` + : `"(-${f.char} --${flagName})"{-${f.char},--${flagName}}` return `${multiplePart}"[${flagSummary}]${completionSuffix}"` } const multiplePart = optionFlag.multiple ? '*' : '' - return `"${multiplePart}--${f.name}[${flagSummary}]${completionSuffix}"` + return `"${multiplePart}--${flagName}[${flagSummary}]${completionSuffix}"` } private genZshTopicCompFun(id: string): string { From 796b6061f69620fa6a891b1119ade0fb75948ec7 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 14 Oct 2025 14:43:13 -0600 Subject: [PATCH 09/16] fix: removing unnecssary changes, untested --- src/autocomplete/zsh.ts | 6 +++++- src/commands/autocomplete/index.ts | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index 2ce4c427..5e3a757a 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -425,7 +425,9 @@ _${this.config.bin}_dynamic_comp() { if (c.hidden) continue const summary = this.sanitizeSummary(c.summary ?? c.description) - // Load the actual command class to get flags with completion properties + // Try to load actual command class to get flags with completion properties + // This allows us to see dynamic completions, but gracefully falls back to + // manifest flags if loading fails - preserving existing behavior let flags = c.flags try { const CommandClass = await c.load() @@ -435,6 +437,8 @@ _${this.config.bin}_dynamic_comp() { } } catch { // Fall back to manifest flags if loading fails + // This ensures existing commands without completions continue to work exactly as before + flags = c.flags } cmds.push({ diff --git a/src/commands/autocomplete/index.ts b/src/commands/autocomplete/index.ts index 8e2a6f08..34f30d57 100644 --- a/src/commands/autocomplete/index.ts +++ b/src/commands/autocomplete/index.ts @@ -54,12 +54,14 @@ export default class Index extends AutocompleteBase { const commandsWithDynamicCompletions: Array<{commandId: string; flagName: string}> = [] // Find all commands with flags that have completion functions + // This ONLY loads command classes, doesn't affect existing functionality for (const commandId of this.config.commandIDs) { const command = this.config.findCommand(commandId) if (!command) continue try { // Load the actual command class to access completion functions + // Falls back gracefully if loading fails - no impact on existing commands // eslint-disable-next-line no-await-in-loop const CommandClass = await command.load() const flags = CommandClass.flags || {} @@ -68,6 +70,7 @@ export default class Index extends AutocompleteBase { if (flag.type !== 'option') continue const {completion} = flag as any + // Skip flags without completion property - no extra work for existing flags if (!completion) continue // Check if it has dynamic completion or legacy options function @@ -78,13 +81,14 @@ export default class Index extends AutocompleteBase { } } } catch { - // Ignore errors loading command class + // Silently ignore errors loading command class + // Existing commands continue to work with manifest-based completions continue } } + // Early exit if no dynamic completions found - zero impact on existing functionality if (commandsWithDynamicCompletions.length === 0) { - this.log('No dynamic completions to pre-warm.') return } From ee91467d3315222fffb814ad209f534a63962525 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 14 Oct 2025 15:43:32 -0600 Subject: [PATCH 10/16] chore: apply same logic to bash/pwsh --- src/autocomplete/powershell.ts | 38 ++- src/autocomplete/zsh.ts | 26 +- src/commands/autocomplete/create.ts | 336 ++++++++++++---------- src/commands/autocomplete/index.ts | 15 +- test/autocomplete/powershell.test.ts | 13 +- test/commands/autocomplete/create.test.ts | 23 +- test/test.oclif.manifest.json | 14 +- 7 files changed, 271 insertions(+), 194 deletions(-) diff --git a/src/autocomplete/powershell.ts b/src/autocomplete/powershell.ts index f03c98d6..392e3390 100644 --- a/src/autocomplete/powershell.ts +++ b/src/autocomplete/powershell.ts @@ -27,7 +27,7 @@ export default class PowerShellComp { constructor(config: Config) { this.config = config this.topics = this.getTopics() - this.commands = this.getCommands() + this.commands = [] } private get coTopics(): string[] { @@ -48,7 +48,12 @@ export default class PowerShellComp { return this._coTopics } - public generate(): string { + public async generate(): Promise { + // Ensure commands are loaded with completion properties + if (this.commands.length === 0) { + await this.init() + } + const genNode = (partialId: string): Record => { const node: Record = {} @@ -295,6 +300,10 @@ Register-ArgumentCompleter -Native -CommandName ${ return compRegister } + async init(): Promise { + this.commands = await this.getCommands() + } + private genCmdHashtable(cmd: CommandCompletion): string { const flaghHashtables: string[] = [] @@ -315,13 +324,13 @@ Register-ArgumentCompleter -Native -CommandName ${ if (f.type === 'option' && f.multiple) { flaghHashtables.push( - ` "${f.name}" = @{ + ` "${flagName}" = @{ "summary" = "${flagSummary}" "multiple" = $true }`, ) } else { - flaghHashtables.push(` "${f.name}" = @{ "summary" = "${flagSummary}" }`) + flaghHashtables.push(` "${flagName}" = @{ "summary" = "${flagSummary}" }`) } } } @@ -392,14 +401,31 @@ ${flaghHashtables.join('\n')} return leafTpl } - private getCommands(): CommandCompletion[] { + private async getCommands(): Promise { const cmds: CommandCompletion[] = [] for (const p of this.config.getPluginsList()) { for (const c of p.commands) { if (c.hidden) continue const summary = this.sanitizeSummary(c.summary ?? c.description) - const {flags} = c + + // Try to load actual command class to get flags with completion properties + // This allows us to see dynamic completions, but gracefully falls back to + // manifest flags if loading fails - preserving existing behavior + let {flags} = c + try { + // eslint-disable-next-line no-await-in-loop + const CommandClass = await c.load() + // Use flags from command class if available and not empty (includes completion properties) + if (CommandClass.flags && Object.keys(CommandClass.flags).length > 0) { + flags = CommandClass.flags as CommandFlags + } + } catch { + // Fall back to manifest flags if loading fails + // This ensures existing commands without completions continue to work exactly as before + flags = c.flags + } + cmds.push({ flags, id: c.id, diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index 5e3a757a..a3a3a7b5 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -31,10 +31,6 @@ export default class ZshCompWithSpaces { this.commands = [] } - async init(): Promise { - this.commands = await this.getCommands() - } - private get coTopics(): string[] { if (this._coTopics) return this._coTopics @@ -133,6 +129,10 @@ _${this.config.bin} ` } + async init(): Promise { + this.commands = await this.getCommands() + } + private genDynamicCompletionHelper(): string { // Using ${'$'} instead of \$ to avoid linter errors return `# Dynamic completion helper with timestamp-based caching @@ -197,7 +197,9 @@ _${this.config.bin}_dynamic_comp() { if (f.options) { // Legacy static options - return f.char ? `:${flagName} options:(${f.options?.join(' ')})` : `: :${flagName} options:(${f.options.join(' ')})` + return f.char + ? `:${flagName} options:(${f.options?.join(' ')})` + : `: :${flagName} options:(${f.options.join(' ')})` } // ONLY add dynamic completion if the flag has a completion property @@ -257,7 +259,12 @@ _${this.config.bin}_dynamic_comp() { return `"--${flagName}[${flagSummary}]"` } - private genZshOptionFlagSpec(f: Command.Flag.Cached, flagName: string, flagSummary: string, commandId?: string): string { + private genZshOptionFlagSpec( + f: Command.Flag.Cached, + flagName: string, + flagSummary: string, + commandId?: string, + ): string { // TypeScript doesn't narrow f to option type, so we cast const optionFlag = f as Command.Flag.Cached & {multiple?: boolean} const completionSuffix = this.genZshCompletionSuffix(f, flagName, commandId) @@ -428,11 +435,12 @@ _${this.config.bin}_dynamic_comp() { // Try to load actual command class to get flags with completion properties // This allows us to see dynamic completions, but gracefully falls back to // manifest flags if loading fails - preserving existing behavior - let flags = c.flags + let {flags} = c try { + // eslint-disable-next-line no-await-in-loop const CommandClass = await c.load() - // Use flags from command class if available (includes completion properties) - if (CommandClass.flags) { + // Use flags from command class if available and not empty (includes completion properties) + if (CommandClass.flags && Object.keys(CommandClass.flags).length > 0) { flags = CommandClass.flags as CommandFlags } } catch { diff --git a/src/commands/autocomplete/create.ts b/src/commands/autocomplete/create.ts index 888852f4..fae7778e 100644 --- a/src/commands/autocomplete/create.ts +++ b/src/commands/autocomplete/create.ts @@ -33,33 +33,6 @@ export default class Create extends AutocompleteBase { static hidden = true private _commands?: CommandCompletion[] - private get bashCommandsWithFlagsList(): string { - return this.commands - .map((c) => { - const publicFlags = this.genCmdPublicFlags(c).trim() - return `${c.id} ${publicFlags}` - }) - .join('\n') - } - - private get bashCompletionFunction(): string { - const {cliBin} = this - const supportSpaces = this.config.topicSeparator === ' ' - const bashScript = - process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces - ? bashAutocomplete - : bashAutocompleteWithSpaces - return ( - bashScript - // eslint-disable-next-line unicorn/prefer-spread - .concat( - ...(this.config.binAliases?.map((alias) => `complete -F __autocomplete ${alias}`).join('\n') ?? []), - ) - .replaceAll('', cliBin) - .replaceAll('', this.bashCommandsWithFlagsList) - ) - } - private get bashCompletionFunctionPath(): string { // /autocomplete/functions/bash/.bash return path.join(this.bashFunctionsDir, `${this.cliBin}.bash`) @@ -83,65 +56,6 @@ export default class Create extends AutocompleteBase { return path.join(this.autocompleteCacheDir, 'bash_setup') } - private get commands(): CommandCompletion[] { - if (this._commands) return this._commands - - const cmds: CommandCompletion[] = [] - - for (const p of this.config.getPluginsList()) { - for (const c of p.commands) { - try { - if (c.hidden) continue - const description = sanitizeDescription(c.summary ?? (c.description || '')) - const {flags} = c - cmds.push({ - description, - flags, - id: c.id, - }) - for (const a of c.aliases) { - cmds.push({ - description, - flags, - id: a, - }) - } - } catch (error: any) { - debug(`Error creating zsh flag spec for command ${c.id}`) - debug(error.message) - this.writeLogFile(error.message) - } - } - } - - this._commands = cmds - - return this._commands - } - - private get genAllCommandsMetaString(): string { - // eslint-disable-next-line no-useless-escape - return this.commands.map((c) => `\"${c.id.replaceAll(':', '\\:')}:${c.description}\"`).join('\n') - } - - private get genCaseStatementForFlagsMetaString(): string { - // command) - // _command_flags=( - // "--boolean[bool descr]" - // "--value=-[value descr]:" - // ) - // ;; - return this.commands - .map( - (c) => `${c.id}) - _command_flags=( - ${this.genZshFlagSpecs(c)} - ) -;;\n`, - ) - .join('\n') - } - private get pwshCompletionFunctionPath(): string { // /autocomplete/functions/powershell/.ps1 return path.join(this.pwshFunctionsDir, `${this.cliBin}.ps1`) @@ -152,51 +66,6 @@ export default class Create extends AutocompleteBase { return path.join(this.autocompleteCacheDir, 'functions', 'powershell') } - private get zshCompletionFunction(): string { - const {cliBin} = this - const allCommandsMeta = this.genAllCommandsMetaString - const caseStatementForFlagsMeta = this.genCaseStatementForFlagsMetaString - - return `#compdef ${cliBin} - -_${cliBin} () { - local _command_id=\${words[2]} - local _cur=\${words[CURRENT]} - local -a _command_flags=() - - ## public cli commands & flags - local -a _all_commands=( -${allCommandsMeta} - ) - - _set_flags () { - case $_command_id in -${caseStatementForFlagsMeta} - esac - } - ## end public cli commands & flags - - _complete_commands () { - _describe -t all-commands "all commands" _all_commands - } - - if [ $CURRENT -gt 2 ]; then - if [[ "$_cur" == -* ]]; then - _set_flags - else - _path_files - fi - fi - - - _arguments -S '1: :_complete_commands' \\ - $_command_flags -} - -_${cliBin} -` - } - private get zshCompletionFunctionPath(): string { // /autocomplete/functions/zsh/_ return path.join(this.zshFunctionsDir, `_${this.cliBin}`) @@ -233,25 +102,43 @@ compinit;\n` // zsh const supportSpaces = this.config.topicSeparator === ' ' - // Generate completion scripts - const zshGenerator = new ZshCompWithSpaces(this.config) - const zshScript = await zshGenerator.generate() + // Generate completion scripts (all in parallel for performance) + const [zshScript, bashCompletionFunction, pwshScript, oldZshScript] = await Promise.all([ + // Modern Zsh (with spaces support) + (async () => { + const zshGenerator = new ZshCompWithSpaces(this.config) + return zshGenerator.generate() + })(), + // Bash + this.getBashCompletionFunction(), + // PowerShell + (async () => { + const pwshGenerator = new PowerShellComp(this.config) + return pwshGenerator.generate() + })(), + // Old Zsh (colon separator) - only if needed + process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces + ? this.getZshCompletionFunction() + : Promise.resolve(''), + ]) - await Promise.all( - [ - writeFile(this.bashSetupScriptPath, this.bashSetupScript), - writeFile(this.bashCompletionFunctionPath, this.bashCompletionFunction), - writeFile(this.zshSetupScriptPath, this.zshSetupScript), - // eslint-disable-next-line unicorn/prefer-spread - ].concat( - process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces - ? [writeFile(this.zshCompletionFunctionPath, this.zshCompletionFunction)] - : [ - writeFile(this.zshCompletionFunctionPath, zshScript), - writeFile(this.pwshCompletionFunctionPath, new PowerShellComp(this.config).generate()), - ], - ), - ) + // Write all files + const writeOperations = [ + writeFile(this.bashSetupScriptPath, this.bashSetupScript), + writeFile(this.bashCompletionFunctionPath, bashCompletionFunction), + writeFile(this.zshSetupScriptPath, this.zshSetupScript), + ] + + if (process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces) { + writeOperations.push(writeFile(this.zshCompletionFunctionPath, oldZshScript)) + } else { + writeOperations.push( + writeFile(this.zshCompletionFunctionPath, zshScript), + writeFile(this.pwshCompletionFunctionPath, pwshScript), + ) + } + + await Promise.all(writeOperations) } private async ensureDirs() { @@ -264,6 +151,30 @@ compinit;\n` ]) } + private async genAllCommandsMetaString(): Promise { + const commands = await this.getCommands() + return commands.map((c) => `"${c.id.replaceAll(':', '\\:')}:${c.description}"`).join('\n') + } + + private async genCaseStatementForFlagsMetaString(): Promise { + // command) + // _command_flags=( + // "--boolean[bool descr]" + // "--value=-[value descr]:" + // ) + // ;; + const commands = await this.getCommands() + return commands + .map( + (c) => `${c.id}) + _command_flags=( + ${this.genZshFlagSpecs(c)} + ) +;;\n`, + ) + .join('\n') + } + private genCmdPublicFlags(Command: CommandCompletion): string { const Flags = Command.flags || {} return Object.keys(Flags) @@ -287,4 +198,131 @@ compinit;\n` }) .join('\n') } + + private async getBashCommandsWithFlagsList(): Promise { + const commands = await this.getCommands() + return commands + .map((c) => { + const publicFlags = this.genCmdPublicFlags(c).trim() + return `${c.id} ${publicFlags}` + }) + .join('\n') + } + + private async getBashCompletionFunction(): Promise { + const {cliBin} = this + const supportSpaces = this.config.topicSeparator === ' ' + const bashScript = + process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces + ? bashAutocomplete + : bashAutocompleteWithSpaces + const bashCommandsWithFlagsList = await this.getBashCommandsWithFlagsList() + return ( + bashScript + // eslint-disable-next-line unicorn/prefer-spread + .concat( + ...(this.config.binAliases?.map((alias) => `complete -F __autocomplete ${alias}`).join('\n') ?? []), + ) + .replaceAll('', cliBin) + .replaceAll('', bashCommandsWithFlagsList) + ) + } + + private async getCommands(): Promise { + if (this._commands) return this._commands + + const cmds: CommandCompletion[] = [] + + for (const p of this.config.getPluginsList()) { + for (const c of p.commands) { + try { + if (c.hidden) continue + const description = sanitizeDescription(c.summary ?? (c.description || '')) + + // Try to load actual command class to get flags with completion properties + // This allows us to see dynamic completions, but gracefully falls back to + // manifest flags if loading fails - preserving existing behavior + let {flags} = c + try { + // eslint-disable-next-line no-await-in-loop + const CommandClass = await c.load() + // Use flags from command class if available and not empty (includes completion properties) + if (CommandClass.flags && Object.keys(CommandClass.flags).length > 0) { + flags = CommandClass.flags + } + } catch { + // Fall back to manifest flags if loading fails + // This ensures existing commands without completions continue to work exactly as before + flags = c.flags + } + + cmds.push({ + description, + flags, + id: c.id, + }) + for (const a of c.aliases) { + cmds.push({ + description, + flags, + id: a, + }) + } + } catch (error: any) { + debug(`Error creating bash flag spec for command ${c.id}`) + debug(error.message) + this.writeLogFile(error.message) + } + } + } + + this._commands = cmds + + return this._commands + } + + private async getZshCompletionFunction(): Promise { + const {cliBin} = this + const allCommandsMeta = await this.genAllCommandsMetaString() + const caseStatementForFlagsMeta = await this.genCaseStatementForFlagsMetaString() + + return `#compdef ${cliBin} + +_${cliBin} () { + local _command_id=\${words[2]} + local _cur=\${words[CURRENT]} + local -a _command_flags=() + + ## public cli commands & flags + local -a _all_commands=( +${allCommandsMeta} + ) + + _set_flags () { + case $_command_id in +${caseStatementForFlagsMeta} + esac + } + ## end public cli commands & flags + + _complete_commands () { + _describe -t all-commands "all commands" _all_commands + } + + if [ $CURRENT -gt 2 ]; then + if [[ "$_cur" == -* ]]; then + _set_flags + else + _path_files + fi + fi + + + _arguments -S '1: :_complete_commands' \\ + $_command_flags +} + +_${cliBin} +` + } } diff --git a/src/commands/autocomplete/index.ts b/src/commands/autocomplete/index.ts index 34f30d57..9fd58844 100644 --- a/src/commands/autocomplete/index.ts +++ b/src/commands/autocomplete/index.ts @@ -39,18 +39,18 @@ export default class Index extends AutocompleteBase { ux.action.start(`${bold('Building the autocomplete cache')}`) await Create.run([], this.config) - - ux.action.status = 'Pre-warming dynamic completion caches' + ux.action.status = 'Generating dynamic completion caches' // Pre-warm dynamic completion caches - await this.prewarmCompletionCaches() + await this.generateCompletionCaches() if (!flags['refresh-cache']) { this.printShellInstructions(shell) - } + } + ux.action.stop() } - private async prewarmCompletionCaches(): Promise { + private async generateCompletionCaches(): Promise { const commandsWithDynamicCompletions: Array<{commandId: string; flagName: string}> = [] // Find all commands with flags that have completion functions @@ -108,8 +108,9 @@ export default class Index extends AutocompleteBase { // Suppress stdout by temporarily replacing console.log and process.stdout.write const originalLog = console.log const originalWrite = process.stdout.write.bind(process.stdout) - // eslint-disable-next-line @typescript-eslint/no-empty-function - console.log = () => { } + + console.log = () => {} + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - intentionally suppressing stdout process.stdout.write = () => true diff --git a/test/autocomplete/powershell.test.ts b/test/autocomplete/powershell.test.ts index e6d28342..c44110d4 100644 --- a/test/autocomplete/powershell.test.ts +++ b/test/autocomplete/powershell.test.ts @@ -194,10 +194,11 @@ describe('powershell completion', () => { } }) - it('generates a valid completion file.', () => { + it('generates a valid completion file.', async () => { config.bin = 'test-cli' const powerShellComp = new PowerShellComp(config as Config) - expect(powerShellComp.generate()).to.equal(` + const script = await powerShellComp.generate() + expect(script).to.equal(` using namespace System.Management.Automation using namespace System.Management.Automation.Language @@ -417,11 +418,11 @@ Register-ArgumentCompleter -Native -CommandName test-cli -ScriptBlock $scriptblo `) }) - it('generates a valid completion file with a bin alias.', () => { + it('generates a valid completion file with a bin alias.', async () => { config.bin = 'test-cli' config.binAliases = ['test'] const powerShellComp = new PowerShellComp(config as Config) - expect(powerShellComp.generate()).to.equal(` + expect(await powerShellComp.generate()).to.equal(` using namespace System.Management.Automation using namespace System.Management.Automation.Language @@ -641,11 +642,11 @@ Register-ArgumentCompleter -Native -CommandName @("test","test-cli") -ScriptBloc `) }) - it('generates a valid completion file with multiple bin aliases.', () => { + it('generates a valid completion file with multiple bin aliases.', async () => { config.bin = 'test-cli' config.binAliases = ['test', 'test1'] const powerShellComp = new PowerShellComp(config as Config) - expect(powerShellComp.generate()).to.equal(` + expect(await powerShellComp.generate()).to.equal(` using namespace System.Management.Automation using namespace System.Management.Automation.Language diff --git a/test/commands/autocomplete/create.test.ts b/test/commands/autocomplete/create.test.ts index a309dcf7..8be6bd78 100644 --- a/test/commands/autocomplete/create.test.ts +++ b/test/commands/autocomplete/create.test.ts @@ -67,8 +67,11 @@ compinit; `) }) - it('#bashCompletionFunction', () => { - expect(cmd.bashCompletionFunction).to.eq(`#!/usr/bin/env bash + it('#bashCompletionFunction', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - accessing private method for testing + const bashCompletionFunction = await cmd.getBashCompletionFunction() + expect(bashCompletionFunction).to.eq(`#!/usr/bin/env bash _oclif-example_autocomplete() { @@ -77,7 +80,7 @@ _oclif-example_autocomplete() COMPREPLY=() local commands=" -autocomplete --skip-instructions +autocomplete --refresh-cache autocomplete:foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json " @@ -144,7 +147,8 @@ complete -o default -F _oclif-example_autocomplete oclif-example\n`) readJson(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../test.oclif.manifest.json')) await spacedPlugin.load() - expect(spacedCmd.bashCompletionFunction).to.eq(`#!/usr/bin/env bash + const bashCompletionFunction = await spacedCmd.getBashCompletionFunction() + expect(bashCompletionFunction).to.eq(`#!/usr/bin/env bash # This function joins an array using a character passed in # e.g. ARRAY=(one two three) -> join_by ":" \${ARRAY[@]} -> "one:two:three" @@ -157,7 +161,7 @@ _oclif-example_autocomplete() COMPREPLY=() local commands=" -autocomplete --skip-instructions +autocomplete --refresh-cache autocomplete:foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json " @@ -245,9 +249,12 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json complete -F _oclif-example_autocomplete oclif-example\n`) }) - it('#zshCompletionFunction', () => { + it('#zshCompletionFunction', async () => { /* eslint-disable no-useless-escape */ - expect(cmd.zshCompletionFunction).to.eq(`#compdef oclif-example + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - accessing private method for testing + const zshCompletionFunction = await cmd.getZshCompletionFunction() + expect(zshCompletionFunction).to.eq(`#compdef oclif-example _oclif-example () { local _command_id=\${words[2]} @@ -265,7 +272,7 @@ _oclif-example () { case $_command_id in autocomplete) _command_flags=( - "--skip-instructions[don't show installation instructions]" + "--refresh-cache[Refresh cache (ignores displaying instructions)]" ) ;; diff --git a/test/test.oclif.manifest.json b/test/test.oclif.manifest.json index 91102112..c5cd26ff 100644 --- a/test/test.oclif.manifest.json +++ b/test/test.oclif.manifest.json @@ -7,17 +7,13 @@ "pluginName": "@oclif/plugin-autocomplete", "pluginType": "core", "aliases": [], - "examples": [ - "$ heroku autocomplete", - "$ heroku autocomplete bash", - "$ heroku autocomplete zsh" - ], + "examples": ["$ heroku autocomplete", "$ heroku autocomplete bash", "$ heroku autocomplete zsh"], "flags": { - "skip-instructions": { - "name": "skip-instructions", + "refresh-cache": { + "name": "refresh-cache", "type": "boolean", - "char": "s", - "description": "don't show installation instructions" + "char": "r", + "description": "Refresh cache (ignores displaying instructions)" } }, "args": [ From c53a3994e55d6f8a5dba4bc2c5171d231e17c861 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 14 Oct 2025 15:54:41 -0600 Subject: [PATCH 11/16] chore: change option command to use flags --- src/autocomplete/bash-spaces.ts | 2 +- src/autocomplete/bash.ts | 2 +- src/autocomplete/powershell.ts | 2 +- src/autocomplete/zsh.ts | 2 +- src/commands/autocomplete/index.ts | 3 +-- src/commands/autocomplete/options.ts | 26 +++++++++++----------- test/autocomplete/bash.test.ts | 15 ++++++++----- test/autocomplete/powershell.test.ts | 6 ++--- test/autocomplete/zsh.test.ts | 6 ++--- test/commands/autocomplete/create.test.ts | 4 ++-- test/commands/autocomplete/options.test.ts | 24 +++++++++++++++----- 11 files changed, 54 insertions(+), 38 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index df50833f..66f9008f 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -71,7 +71,7 @@ __autocomplete() local flagName="\${prev#--}" # Try to get dynamic completions - local dynamicOpts=$( autocomplete options "\${normalizedCommand}" "\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) + local dynamicOpts=$( autocomplete:options --command="\${normalizedCommand}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then opts="$dynamicOpts" diff --git a/src/autocomplete/bash.ts b/src/autocomplete/bash.ts index 1bec77a4..ceb134a7 100644 --- a/src/autocomplete/bash.ts +++ b/src/autocomplete/bash.ts @@ -28,7 +28,7 @@ __autocomplete() local flagName="\${prev#--}" # Try to get dynamic completions - local dynamicOpts=$( autocomplete:options "\${__COMP_WORDS}" "\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) + local dynamicOpts=$( autocomplete:options --command="\${__COMP_WORDS}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then opts="$dynamicOpts" diff --git a/src/autocomplete/powershell.ts b/src/autocomplete/powershell.ts index 392e3390..16410491 100644 --- a/src/autocomplete/powershell.ts +++ b/src/autocomplete/powershell.ts @@ -222,7 +222,7 @@ $scriptblock = { $CurrentLineStr = $CommandAst.ToString() try { - $DynamicOptions = & ${this.config.bin} autocomplete options $CommandId $FlagName --current-line="$CurrentLineStr" 2>$null + $DynamicOptions = & ${this.config.bin} autocomplete:options --command=$CommandId --flag=$FlagName --current-line="$CurrentLineStr" 2>$null if ($DynamicOptions) { $DynamicOptions | Where-Object { $_.StartsWith("$WordToComplete") diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index a3a3a7b5..91c826cb 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -161,7 +161,7 @@ _${this.config.bin}_dynamic_comp() { # Cache miss or expired - call Node.js mkdir -p "$cache_dir" 2>/dev/null - local raw_output=${'$'}(${this.config.bin} autocomplete:options ${'$'}{cmd_id} ${'$'}{flag_name} 2>/dev/null) + local raw_output=${'$'}(${this.config.bin} autocomplete:options --command=${'$'}{cmd_id} --flag=${'$'}{flag_name} 2>/dev/null) if [[ -n "$raw_output" ]]; then # Save to cache with timestamp diff --git a/src/commands/autocomplete/index.ts b/src/commands/autocomplete/index.ts index 9fd58844..20ae4e27 100644 --- a/src/commands/autocomplete/index.ts +++ b/src/commands/autocomplete/index.ts @@ -40,7 +40,6 @@ export default class Index extends AutocompleteBase { await Create.run([], this.config) ux.action.status = 'Generating dynamic completion caches' - // Pre-warm dynamic completion caches await this.generateCompletionCaches() if (!flags['refresh-cache']) { @@ -115,7 +114,7 @@ export default class Index extends AutocompleteBase { process.stdout.write = () => true try { - await Options.run([commandId, flagName], this.config) + await Options.run(['--command', commandId, '--flag', flagName], this.config) } finally { // Restore stdout console.log = originalLog diff --git a/src/commands/autocomplete/options.ts b/src/commands/autocomplete/options.ts index 93e16d69..65711252 100644 --- a/src/commands/autocomplete/options.ts +++ b/src/commands/autocomplete/options.ts @@ -1,28 +1,28 @@ -import {Args, Command, Flags, Interfaces} from '@oclif/core' +import {Command, Flags, Interfaces} from '@oclif/core' export default class Options extends Command { - static args = { - command: Args.string({ + static description = 'Display dynamic flag value completions' + static flags = { + command: Flags.string({ + char: 'c', description: 'Command name or ID', required: true, }), - flag: Args.string({ - description: 'Flag name', - required: true, - }), - } - static description = 'Display dynamic flag value completions' - static flags = { 'current-line': Flags.string({ description: 'Current command line being completed', }), + flag: Flags.string({ + char: 'f', + description: 'Flag name', + required: true, + }), } static hidden = true async run(): Promise { - const {args, flags} = await this.parse(Options) - const commandId = args.command - const flagName = args.flag + const {flags} = await this.parse(Options) + const commandId = flags.command + const flagName = flags.flag try { const command = this.config.findCommand(commandId) diff --git a/test/autocomplete/bash.test.ts b/test/autocomplete/bash.test.ts index 60204162..51ea41bd 100644 --- a/test/autocomplete/bash.test.ts +++ b/test/autocomplete/bash.test.ts @@ -185,7 +185,8 @@ skipWindows('bash comp', () => { const create = new Create([], config) // @ts-expect-error because it's a private method - expect(create.bashCompletionFunction.trim()).to.equal(`#!/usr/bin/env bash + const bashCompletionFunction = await create.getBashCompletionFunction() + expect(bashCompletionFunction.trim()).to.equal(`#!/usr/bin/env bash _test-cli_autocomplete() { @@ -219,7 +220,7 @@ ${'app:execute:code '} local flagName="$\{prev#--}" # Try to get dynamic completions - local dynamicOpts=$(test-cli autocomplete:options "$\{__COMP_WORDS}" "$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) + local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then opts="$dynamicOpts" @@ -255,7 +256,8 @@ complete -o default -F _test-cli_autocomplete test-cli`) config.binAliases = ['alias'] const create = new Create([], config) // @ts-expect-error because it's a private method - expect(create.bashCompletionFunction.trim()).to.equal(`#!/usr/bin/env bash + const bashCompletionFunction = await create.getBashCompletionFunction() + expect(bashCompletionFunction.trim()).to.equal(`#!/usr/bin/env bash _test-cli_autocomplete() { @@ -289,7 +291,7 @@ ${'app:execute:code '} local flagName="$\{prev#--}" # Try to get dynamic completions - local dynamicOpts=$(test-cli autocomplete:options "$\{__COMP_WORDS}" "$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) + local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then opts="$dynamicOpts" @@ -326,7 +328,8 @@ complete -F _test-cli_autocomplete alias`) config.binAliases = ['alias', 'alias2'] const create = new Create([], config) // @ts-expect-error because it's a private method - expect(create.bashCompletionFunction).to.equal(`#!/usr/bin/env bash + const bashCompletionFunction = await create.getBashCompletionFunction() + expect(bashCompletionFunction).to.equal(`#!/usr/bin/env bash _test-cli_autocomplete() { @@ -360,7 +363,7 @@ ${'app:execute:code '} local flagName="$\{prev#--}" # Try to get dynamic completions - local dynamicOpts=$(test-cli autocomplete:options "$\{__COMP_WORDS}" "$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) + local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then opts="$dynamicOpts" diff --git a/test/autocomplete/powershell.test.ts b/test/autocomplete/powershell.test.ts index c44110d4..59898963 100644 --- a/test/autocomplete/powershell.test.ts +++ b/test/autocomplete/powershell.test.ts @@ -346,7 +346,7 @@ $scriptblock = { $CurrentLineStr = $CommandAst.ToString() try { - $DynamicOptions = & test-cli autocomplete options $CommandId $FlagName --current-line="$CurrentLineStr" 2>$null + $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName --current-line="$CurrentLineStr" 2>$null if ($DynamicOptions) { $DynamicOptions | Where-Object { $_.StartsWith("$WordToComplete") @@ -570,7 +570,7 @@ $scriptblock = { $CurrentLineStr = $CommandAst.ToString() try { - $DynamicOptions = & test-cli autocomplete options $CommandId $FlagName --current-line="$CurrentLineStr" 2>$null + $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName --current-line="$CurrentLineStr" 2>$null if ($DynamicOptions) { $DynamicOptions | Where-Object { $_.StartsWith("$WordToComplete") @@ -794,7 +794,7 @@ $scriptblock = { $CurrentLineStr = $CommandAst.ToString() try { - $DynamicOptions = & test-cli autocomplete options $CommandId $FlagName --current-line="$CurrentLineStr" 2>$null + $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName --current-line="$CurrentLineStr" 2>$null if ($DynamicOptions) { $DynamicOptions | Where-Object { $_.StartsWith("$WordToComplete") diff --git a/test/autocomplete/zsh.test.ts b/test/autocomplete/zsh.test.ts index 17db61f4..f880157f 100644 --- a/test/autocomplete/zsh.test.ts +++ b/test/autocomplete/zsh.test.ts @@ -242,7 +242,7 @@ _test-cli_dynamic_comp() { # Cache miss or expired - call Node.js mkdir -p "$cache_dir" 2>/dev/null - local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} 2>/dev/null) + local raw_output=$(test-cli autocomplete:options --command=\${cmd_id} --flag=\${flag_name} 2>/dev/null) if [[ -n "$raw_output" ]]; then # Save to cache with timestamp @@ -419,7 +419,7 @@ _test-cli_dynamic_comp() { # Cache miss or expired - call Node.js mkdir -p "$cache_dir" 2>/dev/null - local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} 2>/dev/null) + local raw_output=$(test-cli autocomplete:options --command=\${cmd_id} --flag=\${flag_name} 2>/dev/null) if [[ -n "$raw_output" ]]; then # Save to cache with timestamp @@ -597,7 +597,7 @@ _test-cli_dynamic_comp() { # Cache miss or expired - call Node.js mkdir -p "$cache_dir" 2>/dev/null - local raw_output=$(test-cli autocomplete:options \${cmd_id} \${flag_name} 2>/dev/null) + local raw_output=$(test-cli autocomplete:options --command=\${cmd_id} --flag=\${flag_name} 2>/dev/null) if [[ -n "$raw_output" ]]; then # Save to cache with timestamp diff --git a/test/commands/autocomplete/create.test.ts b/test/commands/autocomplete/create.test.ts index 8be6bd78..7d65a544 100644 --- a/test/commands/autocomplete/create.test.ts +++ b/test/commands/autocomplete/create.test.ts @@ -103,7 +103,7 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json local flagName="\${prev#--}" # Try to get dynamic completions - local dynamicOpts=$(oclif-example autocomplete:options "\${__COMP_WORDS}" "\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) + local dynamicOpts=$(oclif-example autocomplete:options --command="\${__COMP_WORDS}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then opts="$dynamicOpts" @@ -223,7 +223,7 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json local flagName="\${prev#--}" # Try to get dynamic completions - local dynamicOpts=$(oclif-example autocomplete options "\${normalizedCommand}" "\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) + local dynamicOpts=$(oclif-example autocomplete:options --command="\${normalizedCommand}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then opts="$dynamicOpts" diff --git a/test/commands/autocomplete/options.test.ts b/test/commands/autocomplete/options.test.ts index bb0ed4f7..968c8fef 100644 --- a/test/commands/autocomplete/options.test.ts +++ b/test/commands/autocomplete/options.test.ts @@ -10,27 +10,33 @@ describe('autocomplete:options', () => { }) it('returns empty string for non-existent command', async () => { - const {stdout} = await runCommand<{name: string}>(['autocomplete:options', 'nonexistent', 'someflag'], config) + const {stdout} = await runCommand<{name: string}>( + ['autocomplete:options', '--command', 'nonexistent', '--flag', 'someflag'], + config, + ) expect(stdout).to.equal('') }) it('returns empty string for command without flag', async () => { const {stdout} = await runCommand<{name: string}>( - ['autocomplete:options', 'autocomplete', 'nonexistentflag'], + ['autocomplete:options', '--command', 'autocomplete', '--flag', 'nonexistentflag'], config, ) expect(stdout).to.equal('') }) it('returns empty string for flag without completion', async () => { - const {stdout} = await runCommand<{name: string}>(['autocomplete:options', 'autocomplete', 'refresh-cache'], config) + const {stdout} = await runCommand<{name: string}>( + ['autocomplete:options', '--command', 'autocomplete', '--flag', 'refresh-cache'], + config, + ) expect(stdout).to.equal('') }) it('handles errors gracefully', async () => { // Test with invalid arguments - should return empty string const {stdout} = await runCommand<{name: string}>( - ['autocomplete:options', 'invalid:command:that:does:not:exist', 'flag'], + ['autocomplete:options', '--command', 'invalid:command:that:does:not:exist', '--flag', 'flag'], config, ) // Should return empty string on error @@ -40,7 +46,15 @@ describe('autocomplete:options', () => { it('accepts current-line flag', async () => { // Should accept the current-line flag without error const {stdout} = await runCommand<{name: string}>( - ['autocomplete:options', 'autocomplete', 'shell', '--current-line', 'mycli autocomplete --shell'], + [ + 'autocomplete:options', + '--command', + 'autocomplete', + '--flag', + 'shell', + '--current-line', + 'mycli autocomplete --shell', + ], config, ) // Should return empty since shell arg doesn't have completion From 6f799b8fce21775fa8e3aa47c6cfcf478e842fb3 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 14 Oct 2025 16:11:12 -0600 Subject: [PATCH 12/16] chore: simplify autocomplete:options logic --- src/commands/autocomplete/index.ts | 27 ++----- src/commands/autocomplete/options.ts | 105 ++++++--------------------- 2 files changed, 28 insertions(+), 104 deletions(-) diff --git a/src/commands/autocomplete/index.ts b/src/commands/autocomplete/index.ts index 20ae4e27..fc0de775 100644 --- a/src/commands/autocomplete/index.ts +++ b/src/commands/autocomplete/index.ts @@ -94,33 +94,16 @@ export default class Index extends AutocompleteBase { const total = commandsWithDynamicCompletions.length const startTime = Date.now() - ux.action.start(`${bold('Pre-warming')} ${total} ${bold('dynamic completion caches')} ${cyan('(in parallel)')}`) + ux.action.start(`${bold('Generating')} ${total} ${bold('dynamic completion caches')} ${cyan('(in parallel)')}`) - // Pre-warm caches in parallel with concurrency limit - const concurrency = 10 // Run 10 at a time const results = await this.runWithConcurrency( commandsWithDynamicCompletions, - concurrency, + 10, async ({commandId, flagName}, index) => { ux.action.status = `${index + 1}/${total}` try { - // Suppress stdout by temporarily replacing console.log and process.stdout.write - const originalLog = console.log - const originalWrite = process.stdout.write.bind(process.stdout) - - console.log = () => {} - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - intentionally suppressing stdout - process.stdout.write = () => true - - try { - await Options.run(['--command', commandId, '--flag', flagName], this.config) - } finally { - // Restore stdout - console.log = originalLog - process.stdout.write = originalWrite - } - + // Call the static method directly instead of going through CLI parsing + await Options.getCompletionOptions(this.config, commandId, flagName) return {success: true} } catch { // Ignore errors - some completions may fail, that's ok @@ -133,7 +116,7 @@ export default class Index extends AutocompleteBase { const duration = ((Date.now() - startTime) / 1000).toFixed(1) ux.action.stop( - `${bold('✓')} Pre-warmed ${successful}/${total} caches in ${cyan(duration + 's')} ${cyan(`(~${(total / Number(duration)).toFixed(0)}/s)`)}`, + `${bold('✓')} Generated ${successful}/${total} caches in ${cyan(duration + 's')} ${cyan(`(~${(total / Number(duration)).toFixed(0)}/s)`)}`, ) } diff --git a/src/commands/autocomplete/options.ts b/src/commands/autocomplete/options.ts index 65711252..d5f1df41 100644 --- a/src/commands/autocomplete/options.ts +++ b/src/commands/autocomplete/options.ts @@ -1,4 +1,4 @@ -import {Command, Flags, Interfaces} from '@oclif/core' +import {Command, Config, Flags, Interfaces} from '@oclif/core' export default class Options extends Command { static description = 'Display dynamic flag value completions' @@ -8,9 +8,6 @@ export default class Options extends Command { description: 'Command name or ID', required: true, }), - 'current-line': Flags.string({ - description: 'Current command line being completed', - }), flag: Flags.string({ char: 'f', description: 'Flag name', @@ -19,16 +16,19 @@ export default class Options extends Command { } static hidden = true - async run(): Promise { - const {flags} = await this.parse(Options) - const commandId = flags.command - const flagName = flags.flag - + /** + * Get completion options for a specific command flag + * @param config - The oclif config + * @param commandId - The command ID + * @param flagName - The flag name + * @param currentLine - Optional current command line for context + * @returns Array of completion options, or empty array if none available + */ + static async getCompletionOptions(config: Config, commandId: string, flagName: string): Promise { try { - const command = this.config.findCommand(commandId) + const command = config.findCommand(commandId) if (!command) { - this.log('') - return + return [] } // Load the actual command class to get the completion definition @@ -37,93 +37,34 @@ export default class Options extends Command { // Get the flag definition const flagDef = CommandClass.flags?.[flagName] as Interfaces.OptionFlag if (!flagDef || !flagDef.completion) { - this.log('') - return - } - - // Check completion type - // @ts-expect-error - completion.type may not exist yet in types - const completionType = flagDef.completion.type - - // Handle static completions - if (completionType === 'static') { - const {options} = flagDef.completion - if (Array.isArray(options)) { - this.log(options.join('\n')) - } else { - this.log('') - } - - return + return [] } // Handle dynamic completions (or legacy completions without type) const optionsFunc = flagDef.completion.options if (typeof optionsFunc !== 'function') { - this.log('') - return + return [] } - // Parse command line for context - const currentLine = flags['current-line'] || '' - const context = this.parseCommandLine(currentLine) - // Execute the completion function const completionContext: Interfaces.CompletionContext = { - args: context.args, - argv: context.argv, - config: this.config, - flags: context.flags, + config, } const options = await optionsFunc(completionContext) - this.log(options.join('\n')) + return options } catch { // Silently fail and return empty completions - this.log('') + return [] } } - private parseCommandLine(line: string): { - args: {[name: string]: string} - argv: string[] - flags: {[name: string]: string} - } { - const args: {[name: string]: string} = {} - const flags: {[name: string]: string} = {} - const argv: string[] = [] - const parts = line.split(/\s+/).filter(Boolean) - - let i = 0 - // Skip the CLI bin name - if (parts.length > 0) i++ - - while (i < parts.length) { - const part = parts[i] - if (part.startsWith('--')) { - const flagName = part.slice(2) - if (i + 1 < parts.length && !parts[i + 1].startsWith('-')) { - flags[flagName] = parts[i + 1] - i += 2 - } else { - flags[flagName] = 'true' - i++ - } - } else if (part.startsWith('-') && part.length === 2) { - const flagChar = part.slice(1) - if (i + 1 < parts.length && !parts[i + 1].startsWith('-')) { - flags[flagChar] = parts[i + 1] - i += 2 - } else { - flags[flagChar] = 'true' - i++ - } - } else { - argv.push(part) - i++ - } - } + async run(): Promise { + const {flags} = await this.parse(Options) + const commandId = flags.command + const flagName = flags.flag - return {args, argv, flags} + const options = await Options.getCompletionOptions(this.config, commandId, flagName) + this.log(options.join('\n')) } } From 7e957d41f6e2cfa170b86bc8d2825549f86a6c18 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 14 Oct 2025 17:02:23 -0600 Subject: [PATCH 13/16] chore: fix cross platform/terminal location --- src/autocomplete/zsh.ts | 11 +++++- src/commands/autocomplete/index.ts | 56 ++++++++++++++++++++++++---- src/commands/autocomplete/options.ts | 1 - test/autocomplete/zsh.test.ts | 33 ++++++++++++++-- 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index 91c826cb..29602cb3 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -141,7 +141,16 @@ _${this.config.bin}_dynamic_comp() { local cmd_id="$1" local flag_name="$2" local cache_duration="$3" - local cache_dir="$HOME/.cache/${this.config.bin}/autocomplete/flag_completions" + + # Determine cache directory based on platform (cross-platform) + if [[ -n "${'$'}XDG_CACHE_HOME" ]]; then + local cache_dir="${'$'}XDG_CACHE_HOME/${this.config.bin}/autocomplete/flag_completions" + elif [[ "${'$'}OSTYPE" == darwin* ]]; then + local cache_dir="${'$'}HOME/Library/Caches/${this.config.bin}/autocomplete/flag_completions" + else + local cache_dir="${'$'}HOME/.cache/${this.config.bin}/autocomplete/flag_completions" + fi + local cache_file="$cache_dir/${'$'}{cmd_id//[:]/_}_${'$'}{flag_name}.cache" # Check if cache file exists and is valid diff --git a/src/commands/autocomplete/index.ts b/src/commands/autocomplete/index.ts index fc0de775..b49262cc 100644 --- a/src/commands/autocomplete/index.ts +++ b/src/commands/autocomplete/index.ts @@ -102,22 +102,43 @@ export default class Index extends AutocompleteBase { async ({commandId, flagName}, index) => { ux.action.status = `${index + 1}/${total}` try { - // Call the static method directly instead of going through CLI parsing - await Options.getCompletionOptions(this.config, commandId, flagName) - return {success: true} - } catch { - // Ignore errors - some completions may fail, that's ok - return {success: false} + // Get completion options + const options = await Options.getCompletionOptions(this.config, commandId, flagName) + + // Write to cache file (same location and format as shell helper) + if (options.length > 0) { + await this.writeCacheFile(commandId, flagName, options) + return {commandId, count: options.length, flagName, success: true} + } + + // No options returned - log for debugging + return {commandId, count: 0, flagName, success: true} + } catch (error) { + // Log error for debugging + return {commandId, error: (error as Error).message, flagName, success: false} } }, ) - const successful = results.filter((r) => r.success).length + const withOptions = results.filter((r) => (r as any).count > 0).length const duration = ((Date.now() - startTime) / 1000).toFixed(1) ux.action.stop( - `${bold('✓')} Generated ${successful}/${total} caches in ${cyan(duration + 's')} ${cyan(`(~${(total / Number(duration)).toFixed(0)}/s)`)}`, + `${bold('✓')} Generated ${withOptions}/${total} caches in ${cyan(duration + 's')} ${cyan(`(~${(total / Number(duration)).toFixed(0)}/s)`)}`, ) + + // Show details for all caches + this.log('') + this.log(bold('Cache generation details:')) + for (const result of results as any[]) { + if (result.success && result.count > 0) { + this.log(` ✓ ${result.commandId} --${result.flagName}: ${result.count} options cached`) + } else if (result.count === 0) { + this.log(` ⚠️ ${result.commandId} --${result.flagName}: No options returned`) + } else { + this.log(` ❌ ${result.commandId} --${result.flagName}: ${result.error}`) + } + } } private printShellInstructions(shell: string): void { @@ -231,4 +252,23 @@ Setup Instructions for ${this.config.bin.toUpperCase()} CLI Autocomplete --- await Promise.all(executing) return results } + + private async writeCacheFile(commandId: string, flagName: string, options: string[]): Promise { + const {join} = await import('node:path') + const {mkdir, writeFile} = await import('node:fs/promises') + + // Match the shell helper's cache directory structure + const cacheDir = join(this.autocompleteCacheDir, 'flag_completions') + await mkdir(cacheDir, {recursive: true}) + + // Match the shell helper's filename format (replace colons with underscores) + const cacheFilename = `${commandId.replaceAll(':', '_')}_${flagName}.cache` + const cacheFile = join(cacheDir, cacheFilename) + + // Write cache file with timestamp (same format as shell helper) + const timestamp = Math.floor(Date.now() / 1000) // Unix timestamp in seconds + const content = `${timestamp}\n${options.join('\n')}\n` + + await writeFile(cacheFile, content, 'utf8') + } } diff --git a/src/commands/autocomplete/options.ts b/src/commands/autocomplete/options.ts index d5f1df41..043fd325 100644 --- a/src/commands/autocomplete/options.ts +++ b/src/commands/autocomplete/options.ts @@ -21,7 +21,6 @@ export default class Options extends Command { * @param config - The oclif config * @param commandId - The command ID * @param flagName - The flag name - * @param currentLine - Optional current command line for context * @returns Array of completion options, or empty array if none available */ static async getCompletionOptions(config: Config, commandId: string, flagName: string): Promise { diff --git a/test/autocomplete/zsh.test.ts b/test/autocomplete/zsh.test.ts index f880157f..dc3b3ad8 100644 --- a/test/autocomplete/zsh.test.ts +++ b/test/autocomplete/zsh.test.ts @@ -222,7 +222,16 @@ _test-cli_dynamic_comp() { local cmd_id="$1" local flag_name="$2" local cache_duration="$3" - local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + + # Determine cache directory based on platform (cross-platform) + if [[ -n "$XDG_CACHE_HOME" ]]; then + local cache_dir="$XDG_CACHE_HOME/test-cli/autocomplete/flag_completions" + elif [[ "$OSTYPE" == darwin* ]]; then + local cache_dir="$HOME/Library/Caches/test-cli/autocomplete/flag_completions" + else + local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + fi + local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" # Check if cache file exists and is valid @@ -399,7 +408,16 @@ _test-cli_dynamic_comp() { local cmd_id="$1" local flag_name="$2" local cache_duration="$3" - local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + + # Determine cache directory based on platform (cross-platform) + if [[ -n "$XDG_CACHE_HOME" ]]; then + local cache_dir="$XDG_CACHE_HOME/test-cli/autocomplete/flag_completions" + elif [[ "$OSTYPE" == darwin* ]]; then + local cache_dir="$HOME/Library/Caches/test-cli/autocomplete/flag_completions" + else + local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + fi + local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" # Check if cache file exists and is valid @@ -577,7 +595,16 @@ _test-cli_dynamic_comp() { local cmd_id="$1" local flag_name="$2" local cache_duration="$3" - local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + + # Determine cache directory based on platform (cross-platform) + if [[ -n "$XDG_CACHE_HOME" ]]; then + local cache_dir="$XDG_CACHE_HOME/test-cli/autocomplete/flag_completions" + elif [[ "$OSTYPE" == darwin* ]]; then + local cache_dir="$HOME/Library/Caches/test-cli/autocomplete/flag_completions" + else + local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + fi + local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" # Check if cache file exists and is valid From 437dde57ee662cad30112eb256c730b96c1c4208 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 15 Oct 2025 11:46:07 -0600 Subject: [PATCH 14/16] fix: unescaped special chars --- src/autocomplete/bash-spaces.ts | 11 ++- src/autocomplete/bash.ts | 11 ++- src/autocomplete/zsh.ts | 25 +++++-- test/autocomplete/bash.test.ts | 33 ++++++++- test/autocomplete/zsh.test.ts | 81 ++++++++++++++++++----- test/commands/autocomplete/create.test.ts | 22 +++++- 6 files changed, 157 insertions(+), 26 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 66f9008f..1c4ae3f8 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -74,7 +74,16 @@ __autocomplete() local dynamicOpts=$( autocomplete:options --command="\${normalizedCommand}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then - opts="$dynamicOpts" + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 else # Fall back to file completion COMPREPLY=($(compgen -f -- "\${cur}")) diff --git a/src/autocomplete/bash.ts b/src/autocomplete/bash.ts index ceb134a7..eb9b070b 100644 --- a/src/autocomplete/bash.ts +++ b/src/autocomplete/bash.ts @@ -31,7 +31,16 @@ __autocomplete() local dynamicOpts=$( autocomplete:options --command="\${__COMP_WORDS}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then - opts="$dynamicOpts" + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 else # Fall back to file completion COMPREPLY=($(compgen -f -- "\${cur}")) diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index 29602cb3..d010703b 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -137,6 +137,19 @@ _${this.config.bin} // Using ${'$'} instead of \$ to avoid linter errors return `# Dynamic completion helper with timestamp-based caching # This outputs completion options to stdout for use in command substitution +# Minimal escaping for zsh completion arrays - only escape truly problematic chars +_${this.config.bin}_escape_comp() { + local value="${'$'}1" + # For zsh completion arrays :(value1 value2), we primarily need to escape: + # - Backslashes (must come first) + # - Spaces (since they separate array elements) + # - Colons (special meaning in some contexts) + value="${'$'}{value//\\\\/\\\\\\\\}" + value="${'$'}{value//:/\\\\:}" + value="${'$'}{value// /\\\\ }" + printf '%s\\n' "${'$'}value" +} + _${this.config.bin}_dynamic_comp() { local cmd_id="$1" local flag_name="$2" @@ -162,8 +175,10 @@ _${this.config.bin}_dynamic_comp() { # Check if cache is still valid if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then - # Cache valid - read and output options (skip first line and empty lines) - tail -n +2 "$cache_file" | grep -v "^${'$'}" + # Cache valid - read and output escaped options (skip first line and empty lines) + while IFS= read -r line; do + [[ -n "${'$'}line" ]] && _${this.config.bin}_escape_comp "${'$'}line" + done < <(tail -n +2 "$cache_file") return 0 fi fi @@ -179,8 +194,10 @@ _${this.config.bin}_dynamic_comp() { echo "$raw_output" } > "$cache_file" - # Output the completions - echo "$raw_output" + # Output the escaped completions + while IFS= read -r line; do + [[ -n "${'$'}line" ]] && _${this.config.bin}_escape_comp "${'$'}line" + done <<< "$raw_output" fi # If no output, return nothing (will fall back to default completion) } diff --git a/test/autocomplete/bash.test.ts b/test/autocomplete/bash.test.ts index 51ea41bd..c2efc36a 100644 --- a/test/autocomplete/bash.test.ts +++ b/test/autocomplete/bash.test.ts @@ -223,7 +223,16 @@ ${'app:execute:code '} local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then - opts="$dynamicOpts" + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 else # Fall back to file completion COMPREPLY=($(compgen -f -- "$\{cur}")) @@ -294,7 +303,16 @@ ${'app:execute:code '} local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then - opts="$dynamicOpts" + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 else # Fall back to file completion COMPREPLY=($(compgen -f -- "$\{cur}")) @@ -366,7 +384,16 @@ ${'app:execute:code '} local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then - opts="$dynamicOpts" + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 else # Fall back to file completion COMPREPLY=($(compgen -f -- "$\{cur}")) diff --git a/test/autocomplete/zsh.test.ts b/test/autocomplete/zsh.test.ts index dc3b3ad8..2a8a5068 100644 --- a/test/autocomplete/zsh.test.ts +++ b/test/autocomplete/zsh.test.ts @@ -218,11 +218,24 @@ skipWindows('zsh comp', () => { # Dynamic completion helper with timestamp-based caching # This outputs completion options to stdout for use in command substitution +# Minimal escaping for zsh completion arrays - only escape truly problematic chars +_test-cli_escape_comp() { + local value="$1" + # For zsh completion arrays :(value1 value2), we primarily need to escape: + # - Backslashes (must come first) + # - Spaces (since they separate array elements) + # - Colons (special meaning in some contexts) + value="\${value//\\\\/\\\\\\\\}" + value="\${value//:/\\\\:}" + value="\${value// /\\\\ }" + printf '%s\\n' "$value" +} + _test-cli_dynamic_comp() { local cmd_id="$1" local flag_name="$2" local cache_duration="$3" - + # Determine cache directory based on platform (cross-platform) if [[ -n "$XDG_CACHE_HOME" ]]; then local cache_dir="$XDG_CACHE_HOME/test-cli/autocomplete/flag_completions" @@ -243,8 +256,10 @@ _test-cli_dynamic_comp() { # Check if cache is still valid if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then - # Cache valid - read and output options (skip first line and empty lines) - tail -n +2 "$cache_file" | grep -v "^$" + # Cache valid - read and output escaped options (skip first line and empty lines) + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done < <(tail -n +2 "$cache_file") return 0 fi fi @@ -260,8 +275,10 @@ _test-cli_dynamic_comp() { echo "$raw_output" } > "$cache_file" - # Output the completions - echo "$raw_output" + # Output the escaped completions + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done <<< "$raw_output" fi # If no output, return nothing (will fall back to default completion) } @@ -404,11 +421,24 @@ compdef testing=test-cli # Dynamic completion helper with timestamp-based caching # This outputs completion options to stdout for use in command substitution +# Minimal escaping for zsh completion arrays - only escape truly problematic chars +_test-cli_escape_comp() { + local value="$1" + # For zsh completion arrays :(value1 value2), we primarily need to escape: + # - Backslashes (must come first) + # - Spaces (since they separate array elements) + # - Colons (special meaning in some contexts) + value="\${value//\\\\/\\\\\\\\}" + value="\${value//:/\\\\:}" + value="\${value// /\\\\ }" + printf '%s\\n' "$value" +} + _test-cli_dynamic_comp() { local cmd_id="$1" local flag_name="$2" local cache_duration="$3" - + # Determine cache directory based on platform (cross-platform) if [[ -n "$XDG_CACHE_HOME" ]]; then local cache_dir="$XDG_CACHE_HOME/test-cli/autocomplete/flag_completions" @@ -429,8 +459,10 @@ _test-cli_dynamic_comp() { # Check if cache is still valid if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then - # Cache valid - read and output options (skip first line and empty lines) - tail -n +2 "$cache_file" | grep -v "^$" + # Cache valid - read and output escaped options (skip first line and empty lines) + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done < <(tail -n +2 "$cache_file") return 0 fi fi @@ -446,8 +478,10 @@ _test-cli_dynamic_comp() { echo "$raw_output" } > "$cache_file" - # Output the completions - echo "$raw_output" + # Output the escaped completions + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done <<< "$raw_output" fi # If no output, return nothing (will fall back to default completion) } @@ -591,11 +625,24 @@ compdef testing2=test-cli # Dynamic completion helper with timestamp-based caching # This outputs completion options to stdout for use in command substitution +# Minimal escaping for zsh completion arrays - only escape truly problematic chars +_test-cli_escape_comp() { + local value="$1" + # For zsh completion arrays :(value1 value2), we primarily need to escape: + # - Backslashes (must come first) + # - Spaces (since they separate array elements) + # - Colons (special meaning in some contexts) + value="\${value//\\\\/\\\\\\\\}" + value="\${value//:/\\\\:}" + value="\${value// /\\\\ }" + printf '%s\\n' "$value" +} + _test-cli_dynamic_comp() { local cmd_id="$1" local flag_name="$2" local cache_duration="$3" - + # Determine cache directory based on platform (cross-platform) if [[ -n "$XDG_CACHE_HOME" ]]; then local cache_dir="$XDG_CACHE_HOME/test-cli/autocomplete/flag_completions" @@ -616,8 +663,10 @@ _test-cli_dynamic_comp() { # Check if cache is still valid if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then - # Cache valid - read and output options (skip first line and empty lines) - tail -n +2 "$cache_file" | grep -v "^$" + # Cache valid - read and output escaped options (skip first line and empty lines) + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done < <(tail -n +2 "$cache_file") return 0 fi fi @@ -633,8 +682,10 @@ _test-cli_dynamic_comp() { echo "$raw_output" } > "$cache_file" - # Output the completions - echo "$raw_output" + # Output the escaped completions + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done <<< "$raw_output" fi # If no output, return nothing (will fall back to default completion) } diff --git a/test/commands/autocomplete/create.test.ts b/test/commands/autocomplete/create.test.ts index 7d65a544..838a00f4 100644 --- a/test/commands/autocomplete/create.test.ts +++ b/test/commands/autocomplete/create.test.ts @@ -106,7 +106,16 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json local dynamicOpts=$(oclif-example autocomplete:options --command="\${__COMP_WORDS}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then - opts="$dynamicOpts" + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 else # Fall back to file completion COMPREPLY=($(compgen -f -- "\${cur}")) @@ -226,7 +235,16 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json local dynamicOpts=$(oclif-example autocomplete:options --command="\${normalizedCommand}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then - opts="$dynamicOpts" + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 else # Fall back to file completion COMPREPLY=($(compgen -f -- "\${cur}")) From c185c1e256267387fe38bd1edb5f3d1a33f55169 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 15 Oct 2025 16:07:10 -0600 Subject: [PATCH 15/16] chore: suprress success cache messae --- src/commands/autocomplete/index.ts | 2 +- src/commands/autocomplete/options.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commands/autocomplete/index.ts b/src/commands/autocomplete/index.ts index b49262cc..bfedc80e 100644 --- a/src/commands/autocomplete/index.ts +++ b/src/commands/autocomplete/index.ts @@ -132,7 +132,7 @@ export default class Index extends AutocompleteBase { this.log(bold('Cache generation details:')) for (const result of results as any[]) { if (result.success && result.count > 0) { - this.log(` ✓ ${result.commandId} --${result.flagName}: ${result.count} options cached`) + // this.log(` ✓ ${result.commandId} --${result.flagName}: ${result.count} options cached`) } else if (result.count === 0) { this.log(` ⚠️ ${result.commandId} --${result.flagName}: No options returned`) } else { diff --git a/src/commands/autocomplete/options.ts b/src/commands/autocomplete/options.ts index 043fd325..dbafb23d 100644 --- a/src/commands/autocomplete/options.ts +++ b/src/commands/autocomplete/options.ts @@ -50,8 +50,7 @@ export default class Options extends Command { config, } - const options = await optionsFunc(completionContext) - return options + return await optionsFunc(completionContext) } catch { // Silently fail and return empty completions return [] From eab6fccf5d9018958e670b1678498816106f0993 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 16 Oct 2025 09:55:19 -0600 Subject: [PATCH 16/16] chore: remove --current-line flag --- src/autocomplete/bash-spaces.ts | 2 +- src/autocomplete/bash.ts | 2 +- src/autocomplete/powershell.ts | 3 +-- test/autocomplete/bash.test.ts | 6 +++--- test/autocomplete/powershell.test.ts | 9 +++------ test/commands/autocomplete/create.test.ts | 4 ++-- test/commands/autocomplete/options.test.ts | 18 ------------------ 7 files changed, 11 insertions(+), 33 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 1c4ae3f8..a4859739 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -71,7 +71,7 @@ __autocomplete() local flagName="\${prev#--}" # Try to get dynamic completions - local dynamicOpts=$( autocomplete:options --command="\${normalizedCommand}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) + local dynamicOpts=$( autocomplete:options --command="\${normalizedCommand}" --flag="\${flagName}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then # Handle dynamic options line-by-line to properly support special characters diff --git a/src/autocomplete/bash.ts b/src/autocomplete/bash.ts index eb9b070b..6fa61325 100644 --- a/src/autocomplete/bash.ts +++ b/src/autocomplete/bash.ts @@ -28,7 +28,7 @@ __autocomplete() local flagName="\${prev#--}" # Try to get dynamic completions - local dynamicOpts=$( autocomplete:options --command="\${__COMP_WORDS}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) + local dynamicOpts=$( autocomplete:options --command="\${__COMP_WORDS}" --flag="\${flagName}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then # Handle dynamic options line-by-line to properly support special characters diff --git a/src/autocomplete/powershell.ts b/src/autocomplete/powershell.ts index 16410491..7090086a 100644 --- a/src/autocomplete/powershell.ts +++ b/src/autocomplete/powershell.ts @@ -219,10 +219,9 @@ $scriptblock = { # Try dynamic flag value completion $FlagName = $PrevWord.TrimStart('-') $CommandId = $NextArg._command.id - $CurrentLineStr = $CommandAst.ToString() try { - $DynamicOptions = & ${this.config.bin} autocomplete:options --command=$CommandId --flag=$FlagName --current-line="$CurrentLineStr" 2>$null + $DynamicOptions = & ${this.config.bin} autocomplete:options --command=$CommandId --flag=$FlagName 2>$null if ($DynamicOptions) { $DynamicOptions | Where-Object { $_.StartsWith("$WordToComplete") diff --git a/test/autocomplete/bash.test.ts b/test/autocomplete/bash.test.ts index c2efc36a..0515bcc3 100644 --- a/test/autocomplete/bash.test.ts +++ b/test/autocomplete/bash.test.ts @@ -220,7 +220,7 @@ ${'app:execute:code '} local flagName="$\{prev#--}" # Try to get dynamic completions - local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) + local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then # Handle dynamic options line-by-line to properly support special characters @@ -300,7 +300,7 @@ ${'app:execute:code '} local flagName="$\{prev#--}" # Try to get dynamic completions - local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) + local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then # Handle dynamic options line-by-line to properly support special characters @@ -381,7 +381,7 @@ ${'app:execute:code '} local flagName="$\{prev#--}" # Try to get dynamic completions - local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" --current-line="$\{COMP_LINE}" 2>/dev/null) + local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then # Handle dynamic options line-by-line to properly support special characters diff --git a/test/autocomplete/powershell.test.ts b/test/autocomplete/powershell.test.ts index 59898963..eb41d1a5 100644 --- a/test/autocomplete/powershell.test.ts +++ b/test/autocomplete/powershell.test.ts @@ -343,10 +343,9 @@ $scriptblock = { # Try dynamic flag value completion $FlagName = $PrevWord.TrimStart('-') $CommandId = $NextArg._command.id - $CurrentLineStr = $CommandAst.ToString() try { - $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName --current-line="$CurrentLineStr" 2>$null + $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName 2>$null if ($DynamicOptions) { $DynamicOptions | Where-Object { $_.StartsWith("$WordToComplete") @@ -567,10 +566,9 @@ $scriptblock = { # Try dynamic flag value completion $FlagName = $PrevWord.TrimStart('-') $CommandId = $NextArg._command.id - $CurrentLineStr = $CommandAst.ToString() try { - $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName --current-line="$CurrentLineStr" 2>$null + $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName 2>$null if ($DynamicOptions) { $DynamicOptions | Where-Object { $_.StartsWith("$WordToComplete") @@ -791,10 +789,9 @@ $scriptblock = { # Try dynamic flag value completion $FlagName = $PrevWord.TrimStart('-') $CommandId = $NextArg._command.id - $CurrentLineStr = $CommandAst.ToString() try { - $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName --current-line="$CurrentLineStr" 2>$null + $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName 2>$null if ($DynamicOptions) { $DynamicOptions | Where-Object { $_.StartsWith("$WordToComplete") diff --git a/test/commands/autocomplete/create.test.ts b/test/commands/autocomplete/create.test.ts index 838a00f4..bc11cd24 100644 --- a/test/commands/autocomplete/create.test.ts +++ b/test/commands/autocomplete/create.test.ts @@ -103,7 +103,7 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json local flagName="\${prev#--}" # Try to get dynamic completions - local dynamicOpts=$(oclif-example autocomplete:options --command="\${__COMP_WORDS}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) + local dynamicOpts=$(oclif-example autocomplete:options --command="\${__COMP_WORDS}" --flag="\${flagName}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then # Handle dynamic options line-by-line to properly support special characters @@ -232,7 +232,7 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json local flagName="\${prev#--}" # Try to get dynamic completions - local dynamicOpts=$(oclif-example autocomplete:options --command="\${normalizedCommand}" --flag="\${flagName}" --current-line="\${COMP_LINE}" 2>/dev/null) + local dynamicOpts=$(oclif-example autocomplete:options --command="\${normalizedCommand}" --flag="\${flagName}" 2>/dev/null) if [[ -n "$dynamicOpts" ]]; then # Handle dynamic options line-by-line to properly support special characters diff --git a/test/commands/autocomplete/options.test.ts b/test/commands/autocomplete/options.test.ts index 968c8fef..016b6a15 100644 --- a/test/commands/autocomplete/options.test.ts +++ b/test/commands/autocomplete/options.test.ts @@ -43,24 +43,6 @@ describe('autocomplete:options', () => { expect(stdout).to.equal('') }) - it('accepts current-line flag', async () => { - // Should accept the current-line flag without error - const {stdout} = await runCommand<{name: string}>( - [ - 'autocomplete:options', - '--command', - 'autocomplete', - '--flag', - 'shell', - '--current-line', - 'mycli autocomplete --shell', - ], - config, - ) - // Should return empty since shell arg doesn't have completion - expect(stdout).to.equal('') - }) - // Note: We can't easily test actual completion results without creating a test command // with a completion function. The test manifest doesn't include commands with dynamic completions. // In a real scenario, you would: