Skip to content

feat: support variadic args natively#1554

Merged
jfeingold35 merged 12 commits intooclif:mainfrom
rexxars:feat/native-variadic-args
Mar 19, 2026
Merged

feat: support variadic args natively#1554
jfeingold35 merged 12 commits intooclif:mainfrom
rexxars:feat/native-variadic-args

Conversation

@rexxars
Copy link
Copy Markdown
Contributor

@rexxars rexxars commented Mar 10, 2026

Native variadic positional arguments

Adds multiple: true support to positional arg definitions, giving commands a type-safe way to accept a variable number of positional arguments.

Before this, the only way to accept extra positional args was strict: false, which bypasses all arg parsing — no type coercion, no validation, no per-arg help text. You just get a raw string array in argv and sort it out yourself.

Now you can do:

static args = {
  files: Args.string({ multiple: true, required: true }),
  dest: Args.string({ required: true }),
}

And get properly typed, validated, parsed values:

$ mycli cp foo.txt bar.txt /tmp/
# args.files = ['foo.txt', 'bar.txt'], args.dest = '/tmp/'

How it works

The variadic arg doesn't have to be last. A shift/pop algorithm (proposed by @Radiergummi in #1346) handles placement anywhere in the arg list:

  1. Non-variadic args before the variadic consume tokens from the front
  2. Non-variadic args after the variadic consume tokens from the back
  3. Everything remaining goes to the variadic arg

Constraints enforced at definition time:

  • At most one arg per command can be variadic
  • All args after the variadic must be required: true

Tests

  • Definition-time validation (multiple variadics rejected, non-required after variadic rejected)
  • Parsing in every position (first, middle, last, single value, no values, required/optional)
  • Flags interspersed with variadic args in all configurations
  • Integer parsing and options validation on variadic args
  • Help/docopts output with ... suffix
  • Cache roundtrip

Examples

cp

Variable number of args + a trailing, required arg

import {Args, Command, Flags} from '@oclif/core'

export default class Cp extends Command {
  static args = {
    source: Args.string({description: 'Source file(s)', multiple: true, required: true}),
    dest: Args.string({description: 'Destination path', required: true}),
  }

  static description = 'Copy files to a destination (like cp)'

  static examples = [
    '<%= config.bin %> cp foo.txt bar.txt /tmp/',
    '<%= config.bin %> cp *.js ./dist/',
  ]

  static flags = {
    verbose: Flags.boolean({char: 'v', description: 'Show verbose output'}),
  }

  async run() {
    const {args, flags} = await this.parse(Cp)
    if (flags.verbose) {
      this.log('Verbose mode enabled')
    }

    this.log(`Copying ${args.source.length} file(s) to ${args.dest}:`)
    for (const src of args.source) {
      this.log(`  ${src} -> ${args.dest}`)
    }
  }
}

sum

import {Args, Command} from '@oclif/core'

export default class Sum extends Command {
  static args = {
    numbers: Args.integer({description: 'Numbers to sum', multiple: true, required: true}),
  }

  static description = 'Sum a list of numbers'

  async run() {
    const {args} = await this.parse(Sum)
    const total = args.numbers.reduce((a, b) => a + b, 0)
    this.log(`${args.numbers.join(' + ')} = ${total}`)
  }
}

Resolved issues

rexxars added 9 commits March 10, 2026 12:37
Update help rendering to add '...' suffix per-arg based on the
`multiple` property, instead of only using command-level `strict === false`.
The existing strict===false behavior is preserved as a fallback for
backward compatibility.
Rewrites _args() in parse.ts to support variadic args using a
shift/pop algorithm: pre-variadic args consume from the front,
post-variadic args consume from the back, and everything remaining
goes to the variadic arg as an array. Also updates validate.ts to
skip UnexpectedArgsError when a variadic arg is present.
Add TypeScript overloads to ArgDefinition so that Args.string({ multiple: true })
correctly types the return as Arg<T[]> instead of Arg<T>. This mirrors how
FlagDefinition handles multiple: true with its overloads.
Break apart _args() by extracting parseArgInput, applyDefault, and
tryStdin helpers. Inline the variadic path instead of a separate method
to avoid passing 7 parameters. Remove "original logic" comments.
Verify that variadic args show ... suffix in USAGE line:
- last position, first with trailing required, middle position
- optional variadic shows [FILES...]
- non-variadic siblings don't get the suffix
The cacheArgs function wasn't including the `multiple` property when
caching arg metadata, which caused help text to miss the `...` suffix
for variadic args when loaded from the manifest cache.
Verify that the shift/pop parsing algorithm correctly handles flags
placed between positional arguments in various configurations:
leading required + variadic, variadic + trailing required, both,
and boolean flags mixed in.
Copy link
Copy Markdown

@Radiergummi Radiergummi left a comment

Choose a reason for hiding this comment

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

oh, this is amazing. Thank you so much for taking care of this!

@jfeingold35
Copy link
Copy Markdown
Contributor

Thanks for submitting this. I'll get it reviewed and QA'd, and let you know if anything needs to change.

Comment thread test/parser/parse.test.ts Outdated
await parse(['a', 'b'], {
args: {
first: Args.string({multiple: true}),
second: Args.string({multiple: true}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Q: Just to make sure that this is actually failing specifically because it's two variadics and not because it's a non-required arg following a variadic, should second be required: true?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid - 8378f8b makes this change.

Comment thread test/parser/parse.test.ts Outdated
})

describe('parsing', () => {
it('variadic arg as last arg', async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just to be as thorough as possible, should this perhaps be split into two different tests called variadic as only arg and variadic as last arg?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed - the test had a single variadic arg with no other args, so it was actually "only arg" not "last arg". I've included a few more test cases in 8378f8b

Comment thread test/parser/parse.test.ts
it('variadic arg in the middle', async () => {
const out = await parse(['tar', 'a', 'b', 'c', './out.tar'], {
args: {
format: Args.string({required: true}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Your description of the algorithm indicates that args after the variadic have to be required, but don't the ones before it have to be required as well, otherwise it's unclear which arg to assign parameters to?

Copy link
Copy Markdown
Contributor Author

@rexxars rexxars Mar 18, 2026

Choose a reason for hiding this comment

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

You're right - arguments before a variadic arg were read left to right. If one of those were optional, the parser had no clear way to tell whether the user skipped it or meant their value for the next arg. So the behavior was consistent, but confusing. The validation in only checked args after a variadic one, and should also check args before it. 454ae3d includes a fix for this and also provides clearer messages (as you requested below)

@jfeingold35
Copy link
Copy Markdown
Contributor

@rexxars , I've left some comments. If you could please address them, then we should be good to move on to the QA stage.

Comment thread src/parser/validate.ts Outdated
variadicArgFound = true
} else if (variadicArgFound && !arg.required) {
// All args after a variadic arg must be required
throw new InvalidArgsSpecError({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This error does nothing to indicate that the specific problem is a non-required arg following a variadic arg; it just says "invalid argument spec" and then lists the arguments. Could we get a more informative message here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep. 454ae3d adds clearer messages indicating what specifically is invalid :)

@jfeingold35
Copy link
Copy Markdown
Contributor

@rexxars , thank you for addressing the feedback. I'll conduct another review and do the necessary QA, and we can hopefully get this merged in the near future.

jfeingold35
jfeingold35 previously approved these changes Mar 18, 2026
@jfeingold35
Copy link
Copy Markdown
Contributor

@rexxars , the code looks good, and I've done the following QA checks:

  • Lone variadic (works as expected)
  • Variadic w/ normal args before and after (works as expected, args must be required to work correctly)
  • Variadic w/ flags interspersed (notably, when a flag is multiple: true, you need to use -- to explicitly terminate the flag's list and resume the variadic list).
  • Help text shows up as expected

However, I've noticed one gap: A variadic argument's default can still only be a single string, not an array of strings. In my opinion, this isn't a blocker, and I'm willing to merge what you have now. However, if you'd like to implement that here and include it as part of this PR, then I'm happy to wait. Let me know how you'd like to proceed.

@rexxars
Copy link
Copy Markdown
Contributor Author

rexxars commented Mar 18, 2026

However, I've noticed one gap: A variadic argument's default can still only be a single string, not an array of strings. In my opinion, this isn't a blocker, and I'm willing to merge what you have now. However, if you'd like to implement that here and include it as part of this PR, then I'm happy to wait. Let me know how you'd like to proceed.

Pushed a commit for this - but needs some careful eyes, typescript defs are getting a little unwieldy 😅

@jfeingold35 jfeingold35 merged commit 4c6b3d7 into oclif:main Mar 19, 2026
87 checks passed
@rexxars rexxars deleted the feat/native-variadic-args branch March 20, 2026 15:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

First-class support for Variadic Arguments

3 participants