From da9fed9a78c1ad6a94b79ad16decb714ad814c9f Mon Sep 17 00:00:00 2001 From: Amir Date: Thu, 11 Jun 2026 18:01:40 +0330 Subject: [PATCH 1/3] feat(commander): support positional argument completions --- examples/demo.commander.ts | 53 ++++++++++++ src/commander.ts | 23 ++++- tests/__snapshots__/cli.test.ts.snap | 123 +++++++++++++++++++++++++++ tests/cli.test.ts | 11 +-- 4 files changed, 202 insertions(+), 8 deletions(-) diff --git a/examples/demo.commander.ts b/examples/demo.commander.ts index 909e8af..7a3cfca 100644 --- a/examples/demo.commander.ts +++ b/examples/demo.commander.ts @@ -18,6 +18,8 @@ program ]) ); +program.argument('[project]', 'Project name'); + // Add commands const devCommand = program .command('dev') @@ -81,6 +83,14 @@ program console.log('Linting files...'); }); +// Command with multiple required positional arguments +program + .command('copy ') + .description('Copy files') + .action((source, destination) => { + console.log(`Copying ${source} to ${destination}...`); + }); + // Initialize tab completion const completion = tab(program); @@ -129,5 +139,48 @@ if (devCommandInstance) { } } +// Positional argument on root command +const projectArg = completion.arguments.get('project'); +if (projectArg) { + projectArg.handler = (complete) => { + complete('my-app', 'My application'); + complete('my-lib', 'My library'); + complete('my-tool', 'My tool'); + }; +} + +// Positional arguments on lint command +const lintCommandInstance = completion.commands.get('lint'); +if (lintCommandInstance) { + const filesArg = lintCommandInstance.arguments.get('files'); + if (filesArg) { + filesArg.handler = (complete) => { + complete('main.ts', 'Main file'); + complete('index.ts', 'Index file'); + }; + } +} + +// Positional arguments on copy command +const copyCommandInstance = completion.commands.get('copy'); +if (copyCommandInstance) { + const sourceArg = copyCommandInstance.arguments.get('source'); + if (sourceArg) { + sourceArg.handler = (complete) => { + complete('src/', 'Source directory'); + complete('dist/', 'Distribution directory'); + complete('public/', 'Public assets'); + }; + } + const destinationArg = copyCommandInstance.arguments.get('destination'); + if (destinationArg) { + destinationArg.handler = (complete) => { + complete('build/', 'Build output'); + complete('release/', 'Release directory'); + complete('backup/', 'Backup location'); + }; + } +} + // Parse command line arguments program.parse(); diff --git a/src/commander.ts b/src/commander.ts index 71ad63d..7832bd0 100644 --- a/src/commander.ts +++ b/src/commander.ts @@ -1,5 +1,5 @@ import type { Command as CommanderCommand } from 'commander'; -import t, { type RootCommand } from './t'; +import t, { Command as TabCommand, type RootCommand } from './t'; // rawArgs is available on (just) the Commander root command, but is not included in the TypeScript types. interface CommandWithRawArgs extends CommanderCommand { @@ -165,6 +165,25 @@ function processRootCommand(command: CommanderCommand): void { registerOption(t, flags, longFlag, option.description || '', shortFlag); } } + + processArguments(t, command); +} + +function processArguments(tabCommand: TabCommand, cmd: CommanderCommand): void { + for (const arg of cmd.registeredArguments) { + const choices = arg.argChoices; + if (choices?.length) { + tabCommand.argument( + arg.name(), + (complete) => { + for (const choice of choices) complete(choice, ''); + }, + arg.variadic + ); + } else { + tabCommand.argument(arg.name(), undefined, arg.variadic); + } + } } function processSubcommands(rootCommand: CommanderCommand): void { @@ -198,6 +217,8 @@ function processSubcommands(rootCommand: CommanderCommand): void { ); } } + + processArguments(command, cmd); } } diff --git a/tests/__snapshots__/cli.test.ts.snap b/tests/__snapshots__/cli.test.ts.snap index 1b4eebf..761dbc1 100644 --- a/tests/__snapshots__/cli.test.ts.snap +++ b/tests/__snapshots__/cli.test.ts.snap @@ -717,6 +717,35 @@ exports[`cli completion tests for commander > cli option value handling > should " `; +exports[`cli completion tests for commander > copy command argument handlers > should complete destination argument with build suggestions 1`] = ` +"build/ Build output +release/ Release directory +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for commander > copy command argument handlers > should complete source argument with directory suggestions 1`] = ` +"src/ Source directory +dist/ Distribution directory +public/ Public assets +:4 +" +`; + +exports[`cli completion tests for commander > copy command argument handlers > should filter destination suggestions when typing partial input 1`] = ` +"build/ Build output +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for commander > copy command argument handlers > should filter source suggestions when typing partial input 1`] = ` +"src/ Source directory +:4 +" +`; + exports[`cli completion tests for commander > edge case completions for end with space > should keep suggesting the --port option if user typed partial but didn't end with space 1`] = ` "--port Specify port :4 @@ -737,6 +766,96 @@ exports[`cli completion tests for commander > edge case completions for end with " `; +exports[`cli completion tests for commander > lint command argument handlers > should complete files argument with file suggestions 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > lint command argument handlers > should continue completing variadic files argument after first file 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > lint command argument handlers > should continue completing variadic suggestions after first file 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > lint command argument handlers > should filter file suggestions when typing partial input 1`] = ` +"main.ts Main file +:4 +" +`; + +exports[`cli completion tests for commander > positional argument completions > should complete multiple positional arguments when ending with part of the value 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > positional argument completions > should complete multiple positional arguments when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > positional argument completions > should complete single positional argument when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > root command argument tests > should complete root command project argument 1`] = ` +"dev Start dev server +serve Start the server +build Build the project +deploy Deploy the application +lint Lint source files +copy Copy files +my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + +exports[`cli completion tests for commander > root command argument tests > should complete root command project argument after options 1`] = ` +"dev Start dev server +serve Start the server +build Build the project +deploy Deploy the application +lint Lint source files +copy Copy files +my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + +exports[`cli completion tests for commander > root command argument tests > should complete root command project argument with options and partial input 1`] = ` +"my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + +exports[`cli completion tests for commander > root command argument tests > should complete root command project argument with partial input 1`] = ` +"my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + exports[`cli completion tests for commander > root command option tests > should complete root command --logLevel option values 1`] = ` "info Info level warn Warn level @@ -829,6 +948,10 @@ serve Start the server build Build the project deploy Deploy the application lint Lint source files +copy Copy files +my-app My application +my-lib My library +my-tool My tool :4 " `; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index c1244d8..742a509 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -16,9 +16,6 @@ function runCommand(command: string): Promise { const cliTools = ['t', 'citty', 'cac', 'commander']; describe.each(cliTools)('cli completion tests for %s', (cliTool) => { - // Commander does not have custom completions for arguments yet. - const shouldSkipTest = cliTool === 'commander'; - const commandPrefix = `pnpm tsx examples/demo.${cliTool}.ts complete --`; it('should complete cli options', async () => { @@ -230,7 +227,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe.runIf(!shouldSkipTest)('root command argument tests', () => { + describe('root command argument tests', () => { it('should complete root command project argument', async () => { const command = `${commandPrefix} ""`; const output = await runCommand(command); @@ -352,7 +349,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe.runIf(!shouldSkipTest)('positional argument completions', () => { + describe('positional argument completions', () => { it('should complete multiple positional arguments when ending with space', async () => { const command = `${commandPrefix} lint ""`; const output = await runCommand(command); @@ -372,7 +369,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe.runIf(!shouldSkipTest)('copy command argument handlers', () => { + describe('copy command argument handlers', () => { it('should complete source argument with directory suggestions', async () => { const command = `${commandPrefix} copy ""`; const output = await runCommand(command); @@ -398,7 +395,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe.runIf(!shouldSkipTest)('lint command argument handlers', () => { + describe('lint command argument handlers', () => { it('should complete files argument with file suggestions', async () => { const command = `${commandPrefix} lint ""`; const output = await runCommand(command); From 2eaeb77f86d50a3b78fcd7a3c245b0790eac76a2 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sat, 13 Jun 2026 13:10:59 +0330 Subject: [PATCH 2/3] address john's comment --- examples/demo.commander.ts | 12 ------------ tests/__snapshots__/cli.test.ts.snap | 19 ++----------------- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/examples/demo.commander.ts b/examples/demo.commander.ts index 7a3cfca..1d30d1f 100644 --- a/examples/demo.commander.ts +++ b/examples/demo.commander.ts @@ -18,8 +18,6 @@ program ]) ); -program.argument('[project]', 'Project name'); - // Add commands const devCommand = program .command('dev') @@ -139,16 +137,6 @@ if (devCommandInstance) { } } -// Positional argument on root command -const projectArg = completion.arguments.get('project'); -if (projectArg) { - projectArg.handler = (complete) => { - complete('my-app', 'My application'); - complete('my-lib', 'My library'); - complete('my-tool', 'My tool'); - }; -} - // Positional arguments on lint command const lintCommandInstance = completion.commands.get('lint'); if (lintCommandInstance) { diff --git a/tests/__snapshots__/cli.test.ts.snap b/tests/__snapshots__/cli.test.ts.snap index 761dbc1..2261316 100644 --- a/tests/__snapshots__/cli.test.ts.snap +++ b/tests/__snapshots__/cli.test.ts.snap @@ -819,9 +819,6 @@ build Build the project deploy Deploy the application lint Lint source files copy Copy files -my-app My application -my-lib My library -my-tool My tool :4 " `; @@ -833,26 +830,17 @@ build Build the project deploy Deploy the application lint Lint source files copy Copy files -my-app My application -my-lib My library -my-tool My tool :4 " `; exports[`cli completion tests for commander > root command argument tests > should complete root command project argument with options and partial input 1`] = ` -"my-app My application -my-lib My library -my-tool My tool -:4 +":4 " `; exports[`cli completion tests for commander > root command argument tests > should complete root command project argument with partial input 1`] = ` -"my-app My application -my-lib My library -my-tool My tool -:4 +":4 " `; @@ -949,9 +937,6 @@ build Build the project deploy Deploy the application lint Lint source files copy Copy files -my-app My application -my-lib My library -my-tool My tool :4 " `; From 38387a3a5921c5ce43f83e0382847fbe102d6cb5 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sat, 13 Jun 2026 13:17:28 +0330 Subject: [PATCH 3/3] add changeset --- .changeset/cold-eggs-wonder.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cold-eggs-wonder.md diff --git a/.changeset/cold-eggs-wonder.md b/.changeset/cold-eggs-wonder.md new file mode 100644 index 0000000..4bb2c50 --- /dev/null +++ b/.changeset/cold-eggs-wonder.md @@ -0,0 +1,5 @@ +--- +'@bomb.sh/tab': patch +--- + +feat(commander): support completions for positional arguments