[READ-ONLY] Mirror of https://github.com/bombshell-dev/tab. shell autocompletions for javascript CLIs
4

Configure Feed

Select the types of activity you want to include in your feed.

fix: separate pkg manager completion handling from the core API (#31)

* important update

* lint

authored by

AmirHossein Sakhravi and committed by
GitHub
(Aug 11, 2025, 8:30 AM +0330) cec5d29a 80e3d057

+236 -658
+4 -3
bin/cli.ts
··· 1 1 #!/usr/bin/env node 2 2 3 3 import cac from 'cac'; 4 - import { script, Completion } from '../src/index.js'; 4 + import { script } from '../src/t.js'; 5 5 import tab from '../src/cac.js'; 6 6 7 7 import { setupCompletionForPackageManager } from './completion-handlers'; 8 + import { PackageManagerCompletion } from './package-manager-completion.js'; 8 9 9 10 const packageManagers = ['npm', 'pnpm', 'yarn', 'bun']; 10 11 const shells = ['zsh', 'bash', 'fish', 'powershell']; ··· 27 28 28 29 const dashIndex = process.argv.indexOf('--'); 29 30 if (dashIndex !== -1) { 30 - // TOOD: there's no Completion anymore 31 - const completion = new Completion(); 31 + // Use the new PackageManagerCompletion wrapper 32 + const completion = new PackageManagerCompletion(packageManager); 32 33 setupCompletionForPackageManager(packageManager, completion); 33 34 const toComplete = process.argv.slice(dashIndex + 1); 34 35 await completion.parse(toComplete);
+49 -46
bin/completion-handlers.ts
··· 1 1 // TODO: i do not see any completion functionality in this file. nothing is being provided for the defined commands of these package managers. this is a blocker for release. every each of them should be handled. 2 - import { Completion } from '../src/index.js'; 3 - 4 - const noopCompletion = async () => []; 2 + import { PackageManagerCompletion } from './package-manager-completion.js'; 5 3 6 4 export function setupCompletionForPackageManager( 7 5 packageManager: string, 8 - completion: Completion 6 + completion: PackageManagerCompletion 9 7 ) { 10 8 if (packageManager === 'pnpm') { 11 9 setupPnpmCompletions(completion); ··· 17 15 setupBunCompletions(completion); 18 16 } 19 17 20 - // TODO: the core functionality of tab should have nothing related to package managers. even though completion is not there anymore, but this is something to consider. 21 - completion.setPackageManager(packageManager); 18 + // Note: Package manager logic is now handled by PackageManagerCompletion wrapper 22 19 } 23 20 24 - export function setupPnpmCompletions(completion: Completion) { 25 - completion.addCommand('add', 'Install a package', [], noopCompletion); 26 - completion.addCommand('remove', 'Remove a package', [], noopCompletion); 27 - completion.addCommand( 28 - 'install', 29 - 'Install all dependencies', 30 - [], 31 - noopCompletion 32 - ); 33 - completion.addCommand('update', 'Update packages', [], noopCompletion); 34 - completion.addCommand('exec', 'Execute a command', [], noopCompletion); 35 - completion.addCommand('run', 'Run a script', [], noopCompletion); 36 - completion.addCommand('publish', 'Publish package', [], noopCompletion); 37 - completion.addCommand('test', 'Run tests', [], noopCompletion); 38 - completion.addCommand('build', 'Build project', [], noopCompletion); 21 + export function setupPnpmCompletions(completion: PackageManagerCompletion) { 22 + completion.command('add', 'Install a package'); 23 + completion.command('remove', 'Remove a package'); 24 + completion.command('install', 'Install dependencies'); 25 + completion.command('update', 'Update dependencies'); 26 + completion.command('run', 'Run a script'); 27 + completion.command('exec', 'Execute a command'); 28 + completion.command('dlx', 'Run a package without installing'); 29 + completion.command('create', 'Create a new project'); 30 + completion.command('init', 'Initialize a new project'); 31 + completion.command('publish', 'Publish the package'); 32 + completion.command('pack', 'Create a tarball'); 33 + completion.command('link', 'Link a package'); 34 + completion.command('unlink', 'Unlink a package'); 35 + completion.command('outdated', 'Check for outdated packages'); 36 + completion.command('audit', 'Run security audit'); 37 + completion.command('list', 'List installed packages'); 39 38 } 40 39 41 - export function setupNpmCompletions(completion: Completion) { 42 - completion.addCommand('install', 'Install a package', [], noopCompletion); 43 - completion.addCommand('uninstall', 'Uninstall a package', [], noopCompletion); 44 - completion.addCommand('run', 'Run a script', [], noopCompletion); 45 - completion.addCommand('test', 'Run tests', [], noopCompletion); 46 - completion.addCommand('publish', 'Publish package', [], noopCompletion); 47 - completion.addCommand('update', 'Update packages', [], noopCompletion); 48 - completion.addCommand('start', 'Start the application', [], noopCompletion); 49 - completion.addCommand('build', 'Build project', [], noopCompletion); 40 + export function setupNpmCompletions(completion: PackageManagerCompletion) { 41 + completion.command('install', 'Install a package'); 42 + completion.command('uninstall', 'Remove a package'); 43 + completion.command('update', 'Update dependencies'); 44 + completion.command('run', 'Run a script'); 45 + completion.command('exec', 'Execute a command'); 46 + completion.command('init', 'Initialize a new project'); 47 + completion.command('publish', 'Publish the package'); 48 + completion.command('pack', 'Create a tarball'); 49 + completion.command('link', 'Link a package'); 50 + completion.command('unlink', 'Unlink a package'); 50 51 } 51 52 52 - export function setupYarnCompletions(completion: Completion) { 53 - completion.addCommand('add', 'Add a package', [], noopCompletion); 54 - completion.addCommand('remove', 'Remove a package', [], noopCompletion); 55 - completion.addCommand('run', 'Run a script', [], noopCompletion); 56 - completion.addCommand('test', 'Run tests', [], noopCompletion); 57 - completion.addCommand('publish', 'Publish package', [], noopCompletion); 58 - completion.addCommand('install', 'Install dependencies', [], noopCompletion); 59 - completion.addCommand('build', 'Build project', [], noopCompletion); 53 + export function setupYarnCompletions(completion: PackageManagerCompletion) { 54 + completion.command('add', 'Install a package'); 55 + completion.command('remove', 'Remove a package'); 56 + completion.command('install', 'Install dependencies'); 57 + completion.command('upgrade', 'Update dependencies'); 58 + completion.command('run', 'Run a script'); 59 + completion.command('exec', 'Execute a command'); 60 + completion.command('create', 'Create a new project'); 61 + completion.command('init', 'Initialize a new project'); 60 62 } 61 63 62 - export function setupBunCompletions(completion: Completion) { 63 - completion.addCommand('add', 'Add a package', [], noopCompletion); 64 - completion.addCommand('remove', 'Remove a package', [], noopCompletion); 65 - completion.addCommand('run', 'Run a script', [], noopCompletion); 66 - completion.addCommand('test', 'Run tests', [], noopCompletion); 67 - completion.addCommand('install', 'Install dependencies', [], noopCompletion); 68 - completion.addCommand('update', 'Update packages', [], noopCompletion); 69 - completion.addCommand('build', 'Build project', [], noopCompletion); 64 + export function setupBunCompletions(completion: PackageManagerCompletion) { 65 + completion.command('add', 'Install a package'); 66 + completion.command('remove', 'Remove a package'); 67 + completion.command('install', 'Install dependencies'); 68 + completion.command('update', 'Update dependencies'); 69 + completion.command('run', 'Run a script'); 70 + completion.command('x', 'Execute a command'); 71 + completion.command('create', 'Create a new project'); 72 + completion.command('init', 'Initialize a new project'); 70 73 }
+115
bin/package-manager-completion.ts
··· 1 + import { execSync } from 'child_process'; 2 + import { RootCommand } from '../src/t.js'; 3 + 4 + function debugLog(...args: any[]) { 5 + if (process.env.DEBUG) { 6 + console.error('[DEBUG]', ...args); 7 + } 8 + } 9 + 10 + async function checkCliHasCompletions( 11 + cliName: string, 12 + packageManager: string 13 + ): Promise<boolean> { 14 + try { 15 + debugLog(`Checking if ${cliName} has completions via ${packageManager}`); 16 + const command = `${packageManager} ${cliName} complete --`; 17 + const result = execSync(command, { 18 + encoding: 'utf8', 19 + stdio: ['pipe', 'pipe', 'ignore'], 20 + timeout: 1000, 21 + }); 22 + const hasCompletions = !!result.trim(); 23 + debugLog(`${cliName} supports completions: ${hasCompletions}`); 24 + return hasCompletions; 25 + } catch (error) { 26 + debugLog(`Error checking completions for ${cliName}:`, error); 27 + return false; 28 + } 29 + } 30 + 31 + async function getCliCompletions( 32 + cliName: string, 33 + packageManager: string, 34 + args: string[] 35 + ): Promise<string[]> { 36 + try { 37 + const completeArgs = args.map((arg) => 38 + arg.includes(' ') ? `"${arg}"` : arg 39 + ); 40 + const completeCommand = `${packageManager} ${cliName} complete -- ${completeArgs.join(' ')}`; 41 + debugLog(`Getting completions with command: ${completeCommand}`); 42 + 43 + const result = execSync(completeCommand, { 44 + encoding: 'utf8', 45 + stdio: ['pipe', 'pipe', 'ignore'], 46 + timeout: 1000, 47 + }); 48 + 49 + const completions = result.trim().split('\n').filter(Boolean); 50 + debugLog(`Got ${completions.length} completions from ${cliName}`); 51 + return completions; 52 + } catch (error) { 53 + debugLog(`Error getting completions from ${cliName}:`, error); 54 + return []; 55 + } 56 + } 57 + 58 + /** 59 + * Package Manager Completion Wrapper 60 + * 61 + * This extends RootCommand and adds package manager-specific logic. 62 + * It acts as a layer on top of the core tab library. 63 + */ 64 + export class PackageManagerCompletion extends RootCommand { 65 + private packageManager: string; 66 + 67 + constructor(packageManager: string) { 68 + super(); 69 + this.packageManager = packageManager; 70 + } 71 + 72 + // Enhanced parse method with package manager logic 73 + async parse(args: string[]) { 74 + // Handle package manager completions first 75 + if (args.length >= 1 && args[0].trim() !== '') { 76 + const potentialCliName = args[0]; 77 + const knownCommands = [...this.commands.keys()]; 78 + 79 + if (!knownCommands.includes(potentialCliName)) { 80 + const hasCompletions = await checkCliHasCompletions( 81 + potentialCliName, 82 + this.packageManager 83 + ); 84 + 85 + if (hasCompletions) { 86 + const cliArgs = args.slice(1); 87 + const suggestions = await getCliCompletions( 88 + potentialCliName, 89 + this.packageManager, 90 + cliArgs 91 + ); 92 + 93 + if (suggestions.length > 0) { 94 + // Print completions directly in the same format as the core library 95 + for (const suggestion of suggestions) { 96 + if (suggestion.startsWith(':')) continue; 97 + 98 + if (suggestion.includes('\t')) { 99 + const [value, description] = suggestion.split('\t'); 100 + console.log(`${value}\t${description}`); 101 + } else { 102 + console.log(suggestion); 103 + } 104 + } 105 + console.log(':4'); // Shell completion directive (NoFileComp) 106 + return; 107 + } 108 + } 109 + } 110 + } 111 + 112 + // Fall back to regular completion logic (shows basic package manager commands) 113 + return super.parse(args); 114 + } 115 + }
+3 -7
examples/demo.commander.ts
··· 61 61 62 62 // Configure custom completions 63 63 for (const command of completion.commands.values()) { 64 - if (command.name === 'lint') { 65 - command.handler = () => { 66 - return [ 67 - { value: 'src/**/*.ts', description: 'TypeScript source files' }, 68 - { value: 'tests/**/*.ts', description: 'Test files' }, 69 - ]; 70 - }; 64 + if (command.value === 'lint') { 65 + // Note: Direct handler assignment is not supported in the current API 66 + // Custom completion logic would need to be implemented differently 71 67 } 72 68 73 69 for (const [option, config] of command.options.entries()) {
+5 -5
package.json
··· 1 1 { 2 2 "name": "@bombsh/tab", 3 3 "version": "0.0.0", 4 - "main": "./dist/index.js", 5 - "types": "./dist/index.d.ts", 4 + "main": "./dist/t.js", 5 + "types": "./dist/t.d.ts", 6 6 "type": "module", 7 7 "bin": { 8 8 "tab": "./dist/bin/cli.js" ··· 41 41 }, 42 42 "exports": { 43 43 ".": { 44 - "types": "./dist/index.d.ts", 45 - "import": "./dist/index.js", 46 - "require": "./dist/index.cjs" 44 + "types": "./dist/t.d.ts", 45 + "import": "./dist/t.js", 46 + "require": "./dist/t.cjs" 47 47 }, 48 48 "./citty": { 49 49 "types": "./dist/citty.d.ts",
+1 -1
src/bash.ts
··· 1 - import { ShellCompDirective } from './'; 1 + import { ShellCompDirective } from './t'; 2 2 3 3 export function generate(name: string, exec: string): string { 4 4 // Replace '-' and ':' with '_' for variable names
+48 -70
src/commander.ts
··· 2 2 import * as bash from './bash'; 3 3 import * as fish from './fish'; 4 4 import * as powershell from './powershell'; 5 - import type { Command as CommanderCommand } from 'commander'; 6 - import { Completion } from './'; 5 + import type { Command as CommanderCommand, ParseOptions } from 'commander'; 6 + import t, { RootCommand } from './t'; 7 7 import { assertDoubleDashes } from './shared'; 8 8 9 9 const execPath = process.execPath; ··· 18 18 return path.includes(' ') ? `'${path}'` : path; 19 19 } 20 20 21 - export default function tab(instance: CommanderCommand): Completion { 22 - const completion = new Completion(); 21 + export default function tab(instance: CommanderCommand): RootCommand { 23 22 const programName = instance.name(); 24 23 25 24 // Process the root command 26 - processRootCommand(completion, instance, programName); 25 + processRootCommand(instance, programName); 27 26 28 27 // Process all subcommands 29 - processSubcommands(completion, instance, programName); 28 + processSubcommands(instance, programName); 30 29 31 - // Add the complete command 30 + // Add the complete command for normal shell script generation 32 31 instance 33 32 .command('complete [shell]') 34 - .allowUnknownOption(true) 35 33 .description('Generate shell completion scripts') 36 - .action(async (shell, options) => { 37 - // Check if there are arguments after -- 38 - const dashDashIndex = process.argv.indexOf('--'); 39 - let extra: string[] = []; 40 - 41 - if (dashDashIndex !== -1) { 42 - extra = process.argv.slice(dashDashIndex + 1); 43 - // If shell is actually part of the extra args, adjust accordingly 44 - if (shell && extra.length > 0 && shell === '--') { 45 - shell = undefined; 46 - } 47 - } 48 - 34 + .action(async (shell) => { 49 35 switch (shell) { 50 36 case 'zsh': { 51 37 const script = zsh.generate(programName, x); ··· 80 66 break; 81 67 } 82 68 default: { 83 - assertDoubleDashes(programName); 84 - 85 - // Parse current command context for autocompletion 86 - return completion.parse(extra); 69 + console.error(`Unknown shell: ${shell}`); 70 + console.error('Supported shells: zsh, bash, fish, powershell'); 71 + process.exit(1); 87 72 } 88 73 } 89 74 }); 90 75 91 - return completion; 76 + // Override the parse method to handle completion requests before normal parsing 77 + const originalParse = instance.parse.bind(instance); 78 + instance.parse = function (argv?: readonly string[], options?: ParseOptions) { 79 + const args = argv || process.argv; 80 + const completeIndex = args.findIndex((arg) => arg === 'complete'); 81 + const dashDashIndex = args.findIndex((arg) => arg === '--'); 82 + 83 + if ( 84 + completeIndex !== -1 && 85 + dashDashIndex !== -1 && 86 + dashDashIndex > completeIndex 87 + ) { 88 + // This is a completion request, handle it directly 89 + const extra = args.slice(dashDashIndex + 1); 90 + 91 + // Handle the completion directly 92 + assertDoubleDashes(programName); 93 + t.parse(extra); 94 + return instance; 95 + } 96 + 97 + // Normal parsing 98 + return originalParse(argv, options); 99 + }; 100 + 101 + return t; 92 102 } 93 103 94 104 function processRootCommand( 95 - completion: Completion, 96 105 command: CommanderCommand, 97 106 programName: string 98 107 ): void { 99 - // Add the root command 100 - completion.addCommand('', command.description() || '', [], async () => []); 101 - 102 - // Add root command options 108 + // Add root command options to the root t instance 103 109 for (const option of command.options) { 104 110 // Extract short flag from the name if it exists (e.g., "-c, --config" -> "c") 105 111 const flags = option.flags; ··· 107 113 const longFlag = flags.match(/--([a-zA-Z0-9-]+)/)?.[1]; 108 114 109 115 if (longFlag) { 110 - completion.addOption( 111 - '', 112 - `--${longFlag}`, 113 - option.description || '', 114 - async () => [], 115 - shortFlag 116 - ); 116 + if (shortFlag) { 117 + t.option(longFlag, option.description || '', shortFlag); 118 + } else { 119 + t.option(longFlag, option.description || ''); 120 + } 117 121 } 118 122 } 119 123 } 120 124 121 125 function processSubcommands( 122 - completion: Completion, 123 126 rootCommand: CommanderCommand, 124 127 programName: string 125 128 ): void { ··· 133 136 for (const [path, cmd] of commandMap.entries()) { 134 137 if (path === '') continue; // Skip root command, already processed 135 138 136 - // Extract positional arguments from usage 137 - const usage = cmd.usage(); 138 - const args = (usage?.match(/\[.*?\]|<.*?>/g) || []).map((arg) => 139 - arg.startsWith('[') 140 - ); // true if optional (wrapped in []) 141 - 142 - // Add command to completion 143 - completion.addCommand(path, cmd.description() || '', args, async () => []); 139 + // Add command using t.ts API 140 + const command = t.command(path, cmd.description() || ''); 144 141 145 142 // Add command options 146 143 for (const option of cmd.options) { ··· 150 147 const longFlag = flags.match(/--([a-zA-Z0-9-]+)/)?.[1]; 151 148 152 149 if (longFlag) { 153 - completion.addOption( 154 - path, 155 - `--${longFlag}`, 156 - option.description || '', 157 - async () => [], 158 - shortFlag 159 - ); 160 - } 161 - } 162 - 163 - // For commands with subcommands, add a special handler 164 - if (cmd.commands.length > 0) { 165 - const subcommandNames = cmd.commands 166 - .filter((subcmd) => subcmd.name() !== 'complete') 167 - .map((subcmd) => ({ 168 - value: subcmd.name(), 169 - description: subcmd.description() || '', 170 - })); 171 - 172 - if (subcommandNames.length > 0) { 173 - const cmdObj = completion.commands.get(path); 174 - if (cmdObj) { 175 - cmdObj.handler = async () => subcommandNames; 150 + if (shortFlag) { 151 + command.option(longFlag, option.description || '', shortFlag); 152 + } else { 153 + command.option(longFlag, option.description || ''); 176 154 } 177 155 } 178 156 }
+1 -1
src/fish.ts
··· 1 - import { ShellCompDirective } from './'; 1 + import { ShellCompDirective } from './t'; 2 2 3 3 export function generate(name: string, exec: string): string { 4 4 // Replace '-' and ':' with '_' for variable names
-520
src/index.ts
··· 1 - import * as zsh from './zsh'; 2 - import * as bash from './bash'; 3 - import * as fish from './fish'; 4 - import * as powershell from './powershell'; 5 - import { execSync } from 'child_process'; 6 - import { Completion as CompletionItem } from './t'; 7 - 8 - const DEBUG = false; 9 - 10 - function debugLog(...args: unknown[]) { 11 - if (DEBUG) { 12 - console.error('[DEBUG]', ...args); 13 - } 14 - } 15 - 16 - async function checkCliHasCompletions( 17 - cliName: string, 18 - packageManager: string 19 - ): Promise<boolean> { 20 - try { 21 - debugLog(`Checking if ${cliName} has completions via ${packageManager}`); 22 - const command = `${packageManager} ${cliName} complete --`; 23 - const result = execSync(command, { 24 - encoding: 'utf8', 25 - stdio: ['pipe', 'pipe', 'ignore'], 26 - timeout: 1000, 27 - }); 28 - const hasCompletions = !!result.trim(); 29 - debugLog(`${cliName} supports completions: ${hasCompletions}`); 30 - return hasCompletions; 31 - } catch (error) { 32 - debugLog(`Error checking completions for ${cliName}:`, error); 33 - return false; 34 - } 35 - } 36 - 37 - async function getCliCompletions( 38 - cliName: string, 39 - packageManager: string, 40 - args: string[] 41 - ): Promise<string[]> { 42 - try { 43 - const completeArgs = args.map((arg) => 44 - arg.includes(' ') ? `"${arg}"` : arg 45 - ); 46 - const completeCommand = `${packageManager} ${cliName} complete -- ${completeArgs.join(' ')}`; 47 - debugLog(`Getting completions with command: ${completeCommand}`); 48 - 49 - const result = execSync(completeCommand, { 50 - encoding: 'utf8', 51 - stdio: ['pipe', 'pipe', 'ignore'], 52 - timeout: 1000, 53 - }); 54 - 55 - const completions = result.trim().split('\n').filter(Boolean); 56 - debugLog(`Got ${completions.length} completions from ${cliName}`); 57 - return completions; 58 - } catch (error) { 59 - debugLog(`Error getting completions from ${cliName}:`, error); 60 - return []; 61 - } 62 - } 63 - 64 - // ShellCompRequestCmd is the name of the hidden command that is used to request 65 - // completion results from the program. It is used by the shell completion scripts. 66 - export const ShellCompRequestCmd: string = '__complete'; 67 - 68 - // ShellCompNoDescRequestCmd is the name of the hidden command that is used to request 69 - // completion results without their description. It is used by the shell completion scripts. 70 - export const ShellCompNoDescRequestCmd: string = '__completeNoDesc'; 71 - 72 - // ShellCompDirective is a bit map representing the different behaviors the shell 73 - // can be instructed to have once completions have been provided. 74 - export const ShellCompDirective = { 75 - // ShellCompDirectiveError indicates an error occurred and completions should be ignored. 76 - ShellCompDirectiveError: 1 << 0, 77 - 78 - // ShellCompDirectiveNoSpace indicates that the shell should not add a space 79 - // after the completion even if there is a single completion provided. 80 - ShellCompDirectiveNoSpace: 1 << 1, 81 - 82 - // ShellCompDirectiveNoFileComp indicates that the shell should not provide 83 - // file completion even when no completion is provided. 84 - ShellCompDirectiveNoFileComp: 1 << 2, 85 - 86 - // ShellCompDirectiveFilterFileExt indicates that the provided completions 87 - // should be used as file extension filters. 88 - // For flags, using Command.MarkFlagFilename() and Command.MarkPersistentFlagFilename() 89 - // is a shortcut to using this directive explicitly. The BashCompFilenameExt 90 - // annotation can also be used to obtain the same behavior for flags. 91 - ShellCompDirectiveFilterFileExt: 1 << 3, 92 - 93 - // ShellCompDirectiveFilterDirs indicates that only directory names should 94 - // be provided in file completion. To request directory names within another 95 - // directory, the returned completions should specify the directory within 96 - // which to search. The BashCompSubdirsInDir annotation can be used to 97 - // obtain the same behavior but only for flags. 98 - ShellCompDirectiveFilterDirs: 1 << 4, 99 - 100 - // ShellCompDirectiveKeepOrder indicates that the shell should preserve the order 101 - // in which the completions are provided. 102 - ShellCompDirectiveKeepOrder: 1 << 5, 103 - 104 - // =========================================================================== 105 - 106 - // All directives using iota (or equivalent in Go) should be above this one. 107 - // For internal use. 108 - shellCompDirectiveMaxValue: 1 << 6, 109 - 110 - // ShellCompDirectiveDefault indicates to let the shell perform its default 111 - // behavior after completions have been provided. 112 - // This one must be last to avoid messing up the iota count. 113 - ShellCompDirectiveDefault: 0, 114 - }; 115 - 116 - export type Positional = { 117 - required: boolean; 118 - variadic: boolean; 119 - completion: Handler; 120 - }; 121 - 122 - type CompletionResult = { 123 - items: CompletionItem[]; 124 - suppressDefault: boolean; 125 - }; 126 - 127 - export type Handler = ( 128 - previousArgs: string[], 129 - toComplete: string, 130 - endsWithSpace: boolean 131 - ) => CompletionItem[] | Promise<CompletionItem[]>; 132 - 133 - type Option = { 134 - description: string; 135 - handler: Handler; 136 - alias?: string; 137 - }; 138 - 139 - type Command = { 140 - name: string; 141 - description: string; 142 - args: boolean[]; 143 - handler: Handler; 144 - options: Map<string, Option>; 145 - parent?: Command; 146 - }; 147 - 148 - export class Completion { 149 - commands = new Map<string, Command>(); 150 - completions: CompletionItem[] = []; 151 - directive = ShellCompDirective.ShellCompDirectiveDefault; 152 - result: CompletionResult = { items: [], suppressDefault: false }; 153 - private packageManager: string | null = null; 154 - 155 - setPackageManager(packageManager: string) { 156 - this.packageManager = packageManager; 157 - } 158 - 159 - // vite <entry> <another> [...files] 160 - // args: [false, false, true], only the last argument can be variadic 161 - addCommand( 162 - name: string, 163 - description: string, 164 - args: boolean[], 165 - handler: Handler, 166 - parent?: string 167 - ) { 168 - const key = parent ? `${parent} ${name}` : name; 169 - this.commands.set(key, { 170 - name: key, 171 - description, 172 - args, 173 - handler, 174 - options: new Map(), 175 - parent: parent ? this.commands.get(parent) : undefined, 176 - }); 177 - return key; 178 - } 179 - 180 - // --port 181 - addOption( 182 - command: string, 183 - option: string, 184 - description: string, 185 - handler: Handler, 186 - alias?: string 187 - ) { 188 - const cmd = this.commands.get(command); 189 - if (!cmd) { 190 - throw new Error(`Command ${command} not found.`); 191 - } 192 - cmd.options.set(option, { description, handler, alias }); 193 - return option; 194 - } 195 - 196 - // TODO: this should be aware of boolean args and stuff 197 - private stripOptions(args: string[]): string[] { 198 - const parts: string[] = []; 199 - let option = false; 200 - for (const k of args) { 201 - if (k.startsWith('-')) { 202 - option = true; 203 - continue; 204 - } 205 - if (option) { 206 - option = false; 207 - continue; 208 - } 209 - parts.push(k); 210 - } 211 - return parts; 212 - } 213 - 214 - private matchCommand(args: string[]): [Command, string[]] { 215 - args = this.stripOptions(args); 216 - const parts: string[] = []; 217 - let remaining: string[] = []; 218 - // TODO (43081j): we should probably remove this non-null assertion and 219 - // throw if the `''` command doesn't exist 220 - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 221 - let matched: Command = this.commands.get('')!; 222 - for (let i = 0; i < args.length; i++) { 223 - const k = args[i]; 224 - parts.push(k); 225 - const potential = this.commands.get(parts.join(' ')); 226 - 227 - if (potential) { 228 - matched = potential; 229 - } else { 230 - remaining = args.slice(i, args.length); 231 - break; 232 - } 233 - } 234 - return [matched, remaining]; 235 - } 236 - 237 - async parse(args: string[]) { 238 - this.result = { items: [], suppressDefault: false }; 239 - 240 - // TODO: i did not notice this, this should not be handled here at all. package manager completions are something on top of this. just like any other completion system that is going to be built on top of tab. 241 - // Handle package manager completions first 242 - if (this.packageManager && args.length >= 1) { 243 - const potentialCliName = args[0]; 244 - const knownCommands = [...this.commands.keys()]; 245 - 246 - if (!knownCommands.includes(potentialCliName)) { 247 - const hasCompletions = await checkCliHasCompletions( 248 - potentialCliName, 249 - this.packageManager 250 - ); 251 - 252 - if (hasCompletions) { 253 - const cliArgs = args.slice(1); 254 - const suggestions = await getCliCompletions( 255 - potentialCliName, 256 - this.packageManager, 257 - cliArgs 258 - ); 259 - 260 - if (suggestions.length > 0) { 261 - this.result.suppressDefault = true; 262 - 263 - for (const suggestion of suggestions) { 264 - if (suggestion.startsWith(':')) continue; 265 - 266 - if (suggestion.includes('\t')) { 267 - const [value, description] = suggestion.split('\t'); 268 - this.result.items.push({ value, description }); 269 - } else { 270 - this.result.items.push({ value: suggestion }); 271 - } 272 - } 273 - 274 - this.completions = this.result.items; 275 - this.complete(''); 276 - return; 277 - } 278 - } 279 - } 280 - } 281 - 282 - const endsWithSpace = args[args.length - 1] === ''; 283 - 284 - if (endsWithSpace) { 285 - args.pop(); 286 - } 287 - 288 - let toComplete = args[args.length - 1] || ''; 289 - const previousArgs = args.slice(0, -1); 290 - 291 - if (endsWithSpace) { 292 - previousArgs.push(toComplete); 293 - toComplete = ''; 294 - } 295 - 296 - const [matchedCommand] = this.matchCommand(previousArgs); 297 - 298 - const lastPrevArg = previousArgs[previousArgs.length - 1]; 299 - 300 - // 1. Handle flag/option completion 301 - if (this.shouldCompleteFlags(lastPrevArg, toComplete, endsWithSpace)) { 302 - await this.handleFlagCompletion( 303 - matchedCommand, 304 - previousArgs, 305 - toComplete, 306 - endsWithSpace, 307 - lastPrevArg 308 - ); 309 - } else { 310 - // 2. Handle command/subcommand completion 311 - if (this.shouldCompleteCommands(toComplete, endsWithSpace)) { 312 - await this.handleCommandCompletion(previousArgs, toComplete); 313 - } 314 - // 3. Handle positional arguments 315 - if (matchedCommand && matchedCommand.args.length > 0) { 316 - await this.handlePositionalCompletion( 317 - matchedCommand, 318 - previousArgs, 319 - toComplete, 320 - endsWithSpace 321 - ); 322 - } 323 - } 324 - this.complete(toComplete); 325 - } 326 - 327 - private complete(toComplete: string) { 328 - this.directive = ShellCompDirective.ShellCompDirectiveNoFileComp; 329 - 330 - const seen = new Set<string>(); 331 - this.completions 332 - .filter((comp) => { 333 - if (seen.has(comp.value)) return false; 334 - seen.add(comp.value); 335 - return true; 336 - }) 337 - .filter((comp) => comp.value.startsWith(toComplete)) 338 - .forEach((comp) => 339 - console.log(`${comp.value}\t${comp.description ?? ''}`) 340 - ); 341 - console.log(`:${this.directive}`); 342 - } 343 - 344 - private shouldCompleteFlags( 345 - lastPrevArg: string | undefined, 346 - toComplete: string, 347 - endsWithSpace: boolean 348 - ): boolean { 349 - return ( 350 - lastPrevArg?.startsWith('--') || 351 - lastPrevArg?.startsWith('-') || 352 - toComplete.startsWith('--') || 353 - toComplete.startsWith('-') 354 - ); 355 - } 356 - 357 - private shouldCompleteCommands( 358 - toComplete: string, 359 - endsWithSpace: boolean 360 - ): boolean { 361 - return !toComplete.startsWith('-'); 362 - } 363 - 364 - private async handleFlagCompletion( 365 - command: Command, 366 - previousArgs: string[], 367 - toComplete: string, 368 - endsWithSpace: boolean, 369 - lastPrevArg: string | undefined 370 - ) { 371 - // Handle flag value completion 372 - let flagName: string | undefined; 373 - let valueToComplete = toComplete; 374 - 375 - if (toComplete.includes('=')) { 376 - // Handle --flag=value or -f=value case 377 - const parts = toComplete.split('='); 378 - flagName = parts[0]; 379 - valueToComplete = parts[1] || ''; 380 - } else if (lastPrevArg?.startsWith('-')) { 381 - // Handle --flag value or -f value case 382 - flagName = lastPrevArg; 383 - } 384 - 385 - if (flagName) { 386 - // Try to find the option by long name or alias 387 - let option = command.options.get(flagName); 388 - if (!option) { 389 - // If not found by direct match, try to find by alias 390 - for (const [name, opt] of command.options) { 391 - if (opt.alias && `-${opt.alias}` === flagName) { 392 - option = opt; 393 - flagName = name; // Use the long name for completion 394 - break; 395 - } 396 - } 397 - } 398 - 399 - if (option) { 400 - const suggestions = await option.handler( 401 - previousArgs, 402 - valueToComplete, 403 - endsWithSpace 404 - ); 405 - if (toComplete.includes('=')) { 406 - // Reconstruct the full flag=value format 407 - this.completions = suggestions.map((suggestion) => ({ 408 - value: `${flagName}=${suggestion.value}`, 409 - description: suggestion.description, 410 - })); 411 - } else { 412 - this.completions.push(...suggestions); 413 - } 414 - } 415 - return; 416 - } 417 - 418 - // Handle flag name completion 419 - if (toComplete.startsWith('-')) { 420 - const isShortFlag = 421 - toComplete.startsWith('-') && !toComplete.startsWith('--'); 422 - 423 - for (const [name, option] of command.options) { 424 - // For short flags (-), only show aliases 425 - if (isShortFlag) { 426 - if (option.alias && `-${option.alias}`.startsWith(toComplete)) { 427 - this.completions.push({ 428 - value: `-${option.alias}`, 429 - description: option.description, 430 - }); 431 - } 432 - } 433 - // For long flags (--), show the full names 434 - else if (name.startsWith(toComplete)) { 435 - this.completions.push({ 436 - value: name, 437 - description: option.description, 438 - }); 439 - } 440 - } 441 - } 442 - } 443 - 444 - private async handleCommandCompletion( 445 - previousArgs: string[], 446 - toComplete: string 447 - ) { 448 - const commandParts = [...previousArgs].filter(Boolean); 449 - 450 - for (const [k, command] of this.commands) { 451 - if (k === '') continue; 452 - 453 - const parts = k.split(' '); 454 - 455 - let match = true; 456 - let i = 0; 457 - 458 - while (i < commandParts.length) { 459 - if (parts[i] !== commandParts[i]) { 460 - match = false; 461 - break; 462 - } 463 - i++; 464 - } 465 - 466 - if (match && parts[i]?.startsWith(toComplete)) { 467 - this.completions.push({ 468 - value: parts[i], 469 - description: command.description, 470 - }); 471 - } 472 - } 473 - } 474 - 475 - private async handlePositionalCompletion( 476 - command: Command, 477 - previousArgs: string[], 478 - toComplete: string, 479 - endsWithSpace: boolean 480 - ) { 481 - const suggestions = await command.handler( 482 - previousArgs, 483 - toComplete, 484 - endsWithSpace 485 - ); 486 - this.completions.push(...suggestions); 487 - } 488 - } 489 - 490 - export function script( 491 - shell: 'zsh' | 'bash' | 'fish' | 'powershell', 492 - name: string, 493 - x: string 494 - ) { 495 - switch (shell) { 496 - case 'zsh': { 497 - const script = zsh.generate(name, x); 498 - console.log(script); 499 - break; 500 - } 501 - case 'bash': { 502 - const script = bash.generate(name, x); 503 - console.log(script); 504 - break; 505 - } 506 - case 'fish': { 507 - const script = fish.generate(name, x); 508 - console.log(script); 509 - break; 510 - } 511 - case 'powershell': { 512 - const script = powershell.generate(name, x); 513 - console.log(script); 514 - break; 515 - } 516 - default: { 517 - throw new Error(`Unsupported shell: ${shell}`); 518 - } 519 - } 520 - }
+1 -1
src/powershell.ts
··· 1 - import { ShellCompDirective } from './'; 1 + import { ShellCompDirective } from './t'; 2 2 3 3 // TODO: issue with -- -- completions 4 4
+6 -1
src/t.ts
··· 1 1 // Shell directive constants 2 - const ShellCompDirective = { 2 + export const ShellCompDirective = { 3 3 ShellCompDirectiveError: 1 << 0, 4 4 ShellCompDirectiveNoSpace: 1 << 1, 5 5 ShellCompDirectiveNoFileComp: 1 << 2, ··· 540 540 } 541 541 542 542 const t = new RootCommand(); 543 + 544 + // TODO: re-check the below 545 + export function script(shell: string, name: string, executable: string) { 546 + t.setup(name, executable, shell); 547 + } 543 548 544 549 export default t;
+1 -1
src/zsh.ts
··· 1 - import { ShellCompDirective } from './'; 1 + import { ShellCompDirective } from './t'; 2 2 3 3 export function generate(name: string, exec: string) { 4 4 return `#compdef ${name}
+1 -1
tests/shell.test.ts
··· 3 3 import * as bash from '../src/bash'; 4 4 import * as zsh from '../src/zsh'; 5 5 import * as powershell from '../src/powershell'; 6 - import { ShellCompDirective } from '../src'; 6 + import { ShellCompDirective } from '../src/t'; 7 7 8 8 describe('shell completion generators', () => { 9 9 const name = 'testcli';
+1 -1
tsdown.config.ts
··· 2 2 3 3 export default defineConfig({ 4 4 entry: [ 5 - 'src/index.ts', 5 + 'src/t.ts', 6 6 'src/citty.ts', 7 7 'src/cac.ts', 8 8 'src/commander.ts',