├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE.md ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── circle.yml ├── examples ├── basic-usage.js ├── command-examples.js ├── command-options.js ├── dot-nested-options.js ├── help.js ├── ignore-default-value.js ├── negated-option.js ├── sub-command.js └── variadic-arguments.js ├── index-compat.js ├── jest.config.js ├── mod.js ├── mod.ts ├── mod_test.ts ├── package.json ├── pnpm-lock.yaml ├── rollup.config.js ├── scripts └── build-deno.ts ├── src ├── CAC.ts ├── Command.ts ├── Option.ts ├── __test__ │ ├── __snapshots__ │ │ └── index.test.ts.snap │ └── index.test.ts ├── deno.ts ├── index.ts ├── node.ts └── utils.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: egoist 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Issue Type 2 | 3 | 7 | 8 | - [ ] Bug Report 9 | - [ ] Feature Request 10 | - [ ] Other 11 | 12 | ## Expected 13 | 14 | 15 | 16 | ## Actual 17 | 18 | 19 | 20 | ## Possible Solutions 21 | 22 | 23 | 24 | ## Info 25 | 26 | - CAC version: 27 | - Reproduction link: 28 | 29 | 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /lib 3 | /types 4 | /api-doc 5 | coverage 6 | /dist 7 | /deno -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "bracketSpacing": true 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) EGOIST <0x142857@gmail.com> (https://github.com/egoist) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2017-07-26 9 27 05 2 | 3 | [![NPM version](https://img.shields.io/npm/v/cac.svg?style=flat)](https://npmjs.com/package/cac) [![NPM downloads](https://img.shields.io/npm/dm/cac.svg?style=flat)](https://npmjs.com/package/cac) [![CircleCI](https://circleci.com/gh/cacjs/cac/tree/master.svg?style=shield)](https://circleci.com/gh/cacjs/cac/tree/master) [![Codecov](https://badgen.net/codecov/c/github/cacjs/cac/master)](https://codecov.io/gh/cacjs/cac) [![donate](https://img.shields.io/badge/$-donate-ff69b4.svg?maxAge=2592000&style=flat)](https://github.com/egoist/donate) [![install size](https://badgen.net/packagephobia/install/cac)](https://packagephobia.now.sh/result?p=cac) 4 | 5 | ## Introduction 6 | 7 | **C**ommand **A**nd **C**onquer is a JavaScript library for building CLI apps. 8 | 9 | ## Features 10 | 11 | - **Super light-weight**: No dependency, just a single file. 12 | - **Easy to learn**. There're only 4 APIs you need to learn for building simple CLIs: `cli.option` `cli.version` `cli.help` `cli.parse`. 13 | - **Yet so powerful**. Enable features like default command, git-like subcommands, validation for required arguments and options, variadic arguments, dot-nested options, automated help message generation and so on. 14 | - **Developer friendly**. Written in TypeScript. 15 | 16 | ## Table of Contents 17 | 18 | 19 | 20 | - [Install](#install) 21 | - [Usage](#usage) 22 | - [Simple Parsing](#simple-parsing) 23 | - [Display Help Message and Version](#display-help-message-and-version) 24 | - [Command-specific Options](#command-specific-options) 25 | - [Dash in option names](#dash-in-option-names) 26 | - [Brackets](#brackets) 27 | - [Negated Options](#negated-options) 28 | - [Variadic Arguments](#variadic-arguments) 29 | - [Dot-nested Options](#dot-nested-options) 30 | - [Default Command](#default-command) 31 | - [Supply an array as option value](#supply-an-array-as-option-value) 32 | - [Error Handling](#error-handling) 33 | - [With TypeScript](#with-typescript) 34 | - [With Deno](#with-deno) 35 | - [Projects Using CAC](#projects-using-cac) 36 | - [References](#references) 37 | - [CLI Instance](#cli-instance) 38 | - [cac(name?)](#cacname) 39 | - [cli.command(name, description, config?)](#clicommandname-description-config) 40 | - [cli.option(name, description, config?)](#clioptionname-description-config) 41 | - [cli.parse(argv?)](#cliparseargv) 42 | - [cli.version(version, customFlags?)](#cliversionversion-customflags) 43 | - [cli.help(callback?)](#clihelpcallback) 44 | - [cli.outputHelp()](#clioutputhelp) 45 | - [cli.usage(text)](#cliusagetext) 46 | - [Command Instance](#command-instance) 47 | - [command.option()](#commandoption) 48 | - [command.action(callback)](#commandactioncallback) 49 | - [command.alias(name)](#commandaliasname) 50 | - [command.allowUnknownOptions()](#commandallowunknownoptions) 51 | - [command.example(example)](#commandexampleexample) 52 | - [command.usage(text)](#commandusagetext) 53 | - [Events](#events) 54 | - [FAQ](#faq) 55 | - [How is the name written and pronounced?](#how-is-the-name-written-and-pronounced) 56 | - [Why not use Commander.js?](#why-not-use-commanderjs) 57 | - [Project Stats](#project-stats) 58 | - [Contributing](#contributing) 59 | - [Author](#author) 60 | 61 | 62 | 63 | ## Install 64 | 65 | ```bash 66 | yarn add cac 67 | ``` 68 | 69 | ## Usage 70 | 71 | ### Simple Parsing 72 | 73 | Use CAC as simple argument parser: 74 | 75 | ```js 76 | // examples/basic-usage.js 77 | const cli = require('cac')() 78 | 79 | cli.option('--type ', 'Choose a project type', { 80 | default: 'node', 81 | }) 82 | 83 | const parsed = cli.parse() 84 | 85 | console.log(JSON.stringify(parsed, null, 2)) 86 | ``` 87 | 88 | 2018-11-26 12 28 03 89 | 90 | ### Display Help Message and Version 91 | 92 | ```js 93 | // examples/help.js 94 | const cli = require('cac')() 95 | 96 | cli.option('--type [type]', 'Choose a project type', { 97 | default: 'node', 98 | }) 99 | cli.option('--name ', 'Provide your name') 100 | 101 | cli.command('lint [...files]', 'Lint files').action((files, options) => { 102 | console.log(files, options) 103 | }) 104 | 105 | // Display help message when `-h` or `--help` appears 106 | cli.help() 107 | // Display version number when `-v` or `--version` appears 108 | // It's also used in help message 109 | cli.version('0.0.0') 110 | 111 | cli.parse() 112 | ``` 113 | 114 | 2018-11-25 8 21 14 115 | 116 | ### Command-specific Options 117 | 118 | You can attach options to a command. 119 | 120 | ```js 121 | const cli = require('cac')() 122 | 123 | cli 124 | .command('rm ', 'Remove a dir') 125 | .option('-r, --recursive', 'Remove recursively') 126 | .action((dir, options) => { 127 | console.log('remove ' + dir + (options.recursive ? ' recursively' : '')) 128 | }) 129 | 130 | cli.help() 131 | 132 | cli.parse() 133 | ``` 134 | 135 | A command's options are validated when the command is used. Any unknown options will be reported as an error. However, if an action-based command does not define an action, then the options are not validated. If you really want to use unknown options, use [`command.allowUnknownOptions`](#commandallowunknownoptions). 136 | 137 | command options 138 | 139 | ### Dash in option names 140 | 141 | Options in kebab-case should be referenced in camelCase in your code: 142 | 143 | ```js 144 | cli 145 | .command('dev', 'Start dev server') 146 | .option('--clear-screen', 'Clear screen') 147 | .action((options) => { 148 | console.log(options.clearScreen) 149 | }) 150 | ``` 151 | 152 | In fact `--clear-screen` and `--clearScreen` are both mapped to `options.clearScreen`. 153 | 154 | ### Brackets 155 | 156 | When using brackets in command name, angled brackets indicate required command arguments, while square bracket indicate optional arguments. 157 | 158 | When using brackets in option name, angled brackets indicate that a string / number value is required, while square bracket indicate that the value can also be `true`. 159 | 160 | ```js 161 | const cli = require('cac')() 162 | 163 | cli 164 | .command('deploy ', 'Deploy a folder to AWS') 165 | .option('--scale [level]', 'Scaling level') 166 | .action((folder, options) => { 167 | // ... 168 | }) 169 | 170 | cli 171 | .command('build [project]', 'Build a project') 172 | .option('--out ', 'Output directory') 173 | .action((folder, options) => { 174 | // ... 175 | }) 176 | 177 | cli.parse() 178 | ``` 179 | 180 | ### Negated Options 181 | 182 | To allow an option whose value is `false`, you need to manually specify a negated option: 183 | 184 | ```js 185 | cli 186 | .command('build [project]', 'Build a project') 187 | .option('--no-config', 'Disable config file') 188 | .option('--config ', 'Use a custom config file') 189 | ``` 190 | 191 | This will let CAC set the default value of `config` to true, and you can use `--no-config` flag to set it to `false`. 192 | 193 | ### Variadic Arguments 194 | 195 | The last argument of a command can be variadic, and only the last argument. To make an argument variadic you have to add `...` to the start of argument name, just like the rest operator in JavaScript. Here is an example: 196 | 197 | ```js 198 | const cli = require('cac')() 199 | 200 | cli 201 | .command('build [...otherFiles]', 'Build your app') 202 | .option('--foo', 'Foo option') 203 | .action((entry, otherFiles, options) => { 204 | console.log(entry) 205 | console.log(otherFiles) 206 | console.log(options) 207 | }) 208 | 209 | cli.help() 210 | 211 | cli.parse() 212 | ``` 213 | 214 | 2018-11-25 8 25 30 215 | 216 | ### Dot-nested Options 217 | 218 | Dot-nested options will be merged into a single option. 219 | 220 | ```js 221 | const cli = require('cac')() 222 | 223 | cli 224 | .command('build', 'desc') 225 | .option('--env ', 'Set envs') 226 | .example('--env.API_SECRET xxx') 227 | .action((options) => { 228 | console.log(options) 229 | }) 230 | 231 | cli.help() 232 | 233 | cli.parse() 234 | ``` 235 | 236 | 2018-11-25 9 37 53 237 | 238 | ### Default Command 239 | 240 | Register a command that will be used when no other command is matched. 241 | 242 | ```js 243 | const cli = require('cac')() 244 | 245 | cli 246 | // Simply omit the command name, just brackets 247 | .command('[...files]', 'Build files') 248 | .option('--minimize', 'Minimize output') 249 | .action((files, options) => { 250 | console.log(files) 251 | console.log(options.minimize) 252 | }) 253 | 254 | cli.parse() 255 | ``` 256 | 257 | ### Supply an array as option value 258 | 259 | ```bash 260 | node cli.js --include project-a 261 | # The parsed options will be: 262 | # { include: 'project-a' } 263 | 264 | node cli.js --include project-a --include project-b 265 | # The parsed options will be: 266 | # { include: ['project-a', 'project-b'] } 267 | ``` 268 | 269 | ### Error Handling 270 | 271 | To handle command errors globally: 272 | 273 | ```js 274 | try { 275 | // Parse CLI args without running the command 276 | cli.parse(process.argv, { run: false }) 277 | // Run the command yourself 278 | // You only need `await` when your command action returns a Promise 279 | await cli.runMatchedCommand() 280 | } catch (error) { 281 | // Handle error here.. 282 | // e.g. 283 | // console.error(error.stack) 284 | // process.exit(1) 285 | } 286 | ``` 287 | 288 | ### With TypeScript 289 | 290 | First you need `@types/node` to be installed as a dev dependency in your project: 291 | 292 | ```bash 293 | yarn add @types/node --dev 294 | ``` 295 | 296 | Then everything just works out of the box: 297 | 298 | ```js 299 | const { cac } = require('cac') 300 | // OR ES modules 301 | import { cac } from 'cac' 302 | ``` 303 | 304 | ### With Deno 305 | 306 | ```ts 307 | import { cac } from 'https://unpkg.com/cac/mod.ts' 308 | 309 | const cli = cac('my-program') 310 | ``` 311 | 312 | ## Projects Using CAC 313 | 314 | Projects that use **CAC**: 315 | 316 | - [VuePress](https://github.com/vuejs/vuepress): :memo: Minimalistic Vue-powered static site generator. 317 | - [SAO](https://github.com/egoist/sao): ⚔️ Futuristic scaffolding tool. 318 | - [DocPad](https://github.com/docpad/docpad): 🏹 Powerful Static Site Generator. 319 | - [Poi](https://github.com/egoist/poi): ⚡️ Delightful web development. 320 | - [bili](https://github.com/egoist/bili): 🥂 Schweizer Armeemesser for bundling JavaScript libraries. 321 | - [Lad](https://github.com/ladjs/lad): 👦 Lad scaffolds a Koa webapp and API framework for Node.js. 322 | - [Lass](https://github.com/lassjs/lass): 💁🏻 Scaffold a modern package boilerplate for Node.js. 323 | - [Foy](https://github.com/zaaack/foy): 🏗 A lightweight and modern task runner and build tool for general purpose. 324 | - [Vuese](https://github.com/vuese/vuese): 🤗 One-stop solution for vue component documentation. 325 | - [NUT](https://github.com/nut-project/nut): 🌰 A framework born for microfrontends 326 | - Feel free to add yours here... 327 | 328 | ## References 329 | 330 | **💁 Check out [the generated docs](https://cac-api-doc.egoist.dev/classes/_cac_.cac.html) from source code if you want a more in-depth API references.** 331 | 332 | Below is a brief overview. 333 | 334 | ### CLI Instance 335 | 336 | CLI instance is created by invoking the `cac` function: 337 | 338 | ```js 339 | const cac = require('cac') 340 | const cli = cac() 341 | ``` 342 | 343 | #### cac(name?) 344 | 345 | Create a CLI instance, optionally specify the program name which will be used to display in help and version message. When not set we use the basename of `argv[1]`. 346 | 347 | #### cli.command(name, description, config?) 348 | 349 | - Type: `(name: string, description: string) => Command` 350 | 351 | Create a command instance. 352 | 353 | The option also accepts a third argument `config` for additional command config: 354 | 355 | - `config.allowUnknownOptions`: `boolean` Allow unknown options in this command. 356 | - `config.ignoreOptionDefaultValue`: `boolean` Don't use the options's default value in parsed options, only display them in help message. 357 | 358 | #### cli.option(name, description, config?) 359 | 360 | - Type: `(name: string, description: string, config?: OptionConfig) => CLI` 361 | 362 | Add a global option. 363 | 364 | The option also accepts a third argument `config` for additional option config: 365 | 366 | - `config.default`: Default value for the option. 367 | - `config.type`: `any[]` When set to `[]`, the option value returns an array type. You can also use a conversion function such as `[String]`, which will invoke the option value with `String`. 368 | 369 | #### cli.parse(argv?) 370 | 371 | - Type: `(argv = process.argv) => ParsedArgv` 372 | 373 | ```ts 374 | interface ParsedArgv { 375 | args: string[] 376 | options: { 377 | [k: string]: any 378 | } 379 | } 380 | ``` 381 | 382 | When this method is called, `cli.rawArgs` `cli.args` `cli.options` `cli.matchedCommand` will also be available. 383 | 384 | #### cli.version(version, customFlags?) 385 | 386 | - Type: `(version: string, customFlags = '-v, --version') => CLI` 387 | 388 | Output version number when `-v, --version` flag appears. 389 | 390 | #### cli.help(callback?) 391 | 392 | - Type: `(callback?: HelpCallback) => CLI` 393 | 394 | Output help message when `-h, --help` flag appears. 395 | 396 | Optional `callback` allows post-processing of help text before it is displayed: 397 | 398 | ```ts 399 | type HelpCallback = (sections: HelpSection[]) => void 400 | 401 | interface HelpSection { 402 | title?: string 403 | body: string 404 | } 405 | ``` 406 | 407 | #### cli.outputHelp() 408 | 409 | - Type: `() => CLI` 410 | 411 | Output help message. 412 | 413 | #### cli.usage(text) 414 | 415 | - Type: `(text: string) => CLI` 416 | 417 | Add a global usage text. This is not used by sub-commands. 418 | 419 | ### Command Instance 420 | 421 | Command instance is created by invoking the `cli.command` method: 422 | 423 | ```js 424 | const command = cli.command('build [...files]', 'Build given files') 425 | ``` 426 | 427 | #### command.option() 428 | 429 | Basically the same as `cli.option` but this adds the option to specific command. 430 | 431 | #### command.action(callback) 432 | 433 | - Type: `(callback: ActionCallback) => Command` 434 | 435 | Use a callback function as the command action when the command matches user inputs. 436 | 437 | ```ts 438 | type ActionCallback = ( 439 | // Parsed CLI args 440 | // The last arg will be an array if it's a variadic argument 441 | ...args: string | string[] | number | number[] 442 | // Parsed CLI options 443 | options: Options 444 | ) => any 445 | 446 | interface Options { 447 | [k: string]: any 448 | } 449 | ``` 450 | 451 | #### command.alias(name) 452 | 453 | - Type: `(name: string) => Command` 454 | 455 | Add an alias name to this command, the `name` here can't contain brackets. 456 | 457 | #### command.allowUnknownOptions() 458 | 459 | - Type: `() => Command` 460 | 461 | Allow unknown options in this command, by default CAC will log an error when unknown options are used. 462 | 463 | #### command.example(example) 464 | 465 | - Type: `(example: CommandExample) => Command` 466 | 467 | Add an example which will be displayed at the end of help message. 468 | 469 | ```ts 470 | type CommandExample = ((name: string) => string) | string 471 | ``` 472 | 473 | #### command.usage(text) 474 | 475 | - Type: `(text: string) => Command` 476 | 477 | Add a usage text for this command. 478 | 479 | ### Events 480 | 481 | Listen to commands: 482 | 483 | ```js 484 | // Listen to the `foo` command 485 | cli.on('command:foo', () => { 486 | // Do something 487 | }) 488 | 489 | // Listen to the default command 490 | cli.on('command:!', () => { 491 | // Do something 492 | }) 493 | 494 | // Listen to unknown commands 495 | cli.on('command:*', () => { 496 | console.error('Invalid command: %s', cli.args.join(' ')) 497 | process.exit(1) 498 | }) 499 | ``` 500 | 501 | ## FAQ 502 | 503 | ### How is the name written and pronounced? 504 | 505 | CAC, or cac, pronounced `C-A-C`. 506 | 507 | This project is dedicated to our lovely C.C. sama. Maybe CAC stands for C&C as well :P 508 | 509 | 510 | 511 | ### Why not use Commander.js? 512 | 513 | CAC is very similar to Commander.js, while the latter does not support dot nested options, i.e. something like `--env.API_SECRET foo`. Besides, you can't use unknown options in Commander.js either. 514 | 515 | _And maybe more..._ 516 | 517 | Basically I made CAC to fulfill my own needs for building CLI apps like [Poi](https://poi.js.org), [SAO](https://sao.vercel.app) and all my CLI apps. It's small, simple but powerful :P 518 | 519 | ## Project Stats 520 | 521 | ![Alt](https://repobeats.axiom.co/api/embed/58caf6203631bcdb9bbe22f0728a0af1683dc0bb.svg 'Repobeats analytics image') 522 | 523 | ## Contributing 524 | 525 | 1. Fork it! 526 | 2. Create your feature branch: `git checkout -b my-new-feature` 527 | 3. Commit your changes: `git commit -am 'Add some feature'` 528 | 4. Push to the branch: `git push origin my-new-feature` 529 | 5. Submit a pull request :D 530 | 531 | ## Author 532 | 533 | **CAC** © [EGOIST](https://github.com/egoist), Released under the [MIT](./LICENSE) License.
534 | Authored and maintained by egoist with help from contributors ([list](https://github.com/cacjs/cac/contributors)). 535 | 536 | > [Website](https://egoist.dev) · GitHub [@egoist](https://github.com/egoist) · Twitter [@\_egoistlily](https://twitter.com/_egoistlily) 537 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:12 6 | branches: 7 | ignore: 8 | - gh-pages # list of branches to ignore 9 | - /release\/.*/ # or ignore regexes 10 | steps: 11 | - checkout 12 | - restore_cache: 13 | key: dependency-cache-{{ checksum "yarn.lock" }} 14 | - run: 15 | name: install dependences 16 | command: yarn 17 | - save_cache: 18 | key: dependency-cache-{{ checksum "yarn.lock" }} 19 | paths: 20 | - ./node_modules 21 | - run: 22 | name: test 23 | command: yarn test:cov 24 | - run: 25 | name: upload coverage 26 | command: bash <(curl -s https://codecov.io/bash) 27 | - run: 28 | name: Release 29 | command: yarn semantic-release 30 | -------------------------------------------------------------------------------- /examples/basic-usage.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register') 2 | const cli = require('../src/index').cac() 3 | 4 | cli.option('--type [type]', 'Choose a project type') 5 | 6 | const parsed = cli.parse() 7 | 8 | console.log(JSON.stringify(parsed, null, 2)) 9 | -------------------------------------------------------------------------------- /examples/command-examples.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register') 2 | const cli = require('../src/index').cac() 3 | 4 | cli 5 | .command('build', 'Build project') 6 | .example('cli build foo.js') 7 | .example((name) => { 8 | return `${name} build foo.js` 9 | }) 10 | .option('--type [type]', 'Choose a project type') 11 | 12 | const parsed = cli.parse() 13 | 14 | console.log(JSON.stringify(parsed, null, 2)) 15 | -------------------------------------------------------------------------------- /examples/command-options.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register') 2 | const cli = require('../src/index').cac() 3 | 4 | cli 5 | .command('rm ', 'Remove a dir') 6 | .option('-r, --recursive', 'Remove recursively') 7 | .action((dir, options) => { 8 | console.log('remove ' + dir + (options.recursive ? ' recursively' : '')) 9 | }) 10 | 11 | cli.help() 12 | 13 | cli.parse() 14 | -------------------------------------------------------------------------------- /examples/dot-nested-options.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register') 2 | const cli = require('../src/index').cac() 3 | 4 | cli 5 | .command('build', 'desc') 6 | .option('--env ', 'Set envs') 7 | .option('--foo-bar ', 'Set foo bar') 8 | .example('--env.API_SECRET xxx') 9 | .action((options) => { 10 | console.log(options) 11 | }) 12 | 13 | cli.help() 14 | 15 | cli.parse() 16 | -------------------------------------------------------------------------------- /examples/help.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register') 2 | const cli = require('../src/index').cac() 3 | 4 | cli.option('--type [type]', 'Choose a project type', { 5 | default: 'node', 6 | }) 7 | cli.option('--name ', 'Provide your name') 8 | 9 | cli.command('lint [...files]', 'Lint files').action((files, options) => { 10 | console.log(files, options) 11 | }) 12 | 13 | cli.command('[...files]', 'Run files').action((files, options) => { 14 | console.log('run', files, options) 15 | }) 16 | 17 | // Display help message when `-h` or `--help` appears 18 | cli.help() 19 | // Display version number when `-v` or `--version` appears 20 | cli.version('0.0.0') 21 | 22 | cli.parse() 23 | -------------------------------------------------------------------------------- /examples/ignore-default-value.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register') 2 | const cli = require('../src/index').cac() 3 | 4 | cli 5 | .command('build', 'Build project', { 6 | ignoreOptionDefaultValue: true, 7 | }) 8 | .option('--type [type]', 'Choose a project type', { 9 | default: 'node', 10 | }) 11 | 12 | const parsed = cli.parse() 13 | 14 | console.log(JSON.stringify(parsed, null, 2)) 15 | -------------------------------------------------------------------------------- /examples/negated-option.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register') 2 | const cli = require('../src/index').cac() 3 | 4 | cli.option('--no-clear-screen', 'Do not clear screen') 5 | 6 | const parsed = cli.parse() 7 | 8 | console.log(JSON.stringify(parsed, null, 2)) 9 | -------------------------------------------------------------------------------- /examples/sub-command.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register') 2 | const cli = require('../src/index').cac() 3 | 4 | cli 5 | .command('deploy [path]', 'Deploy to AWS') 6 | .option('--token ', 'Your access token') 7 | .example('deploy ./dist') 8 | 9 | cli 10 | .command('bar [...rest]', 'The bar command') 11 | .option('--bad', 'It is bad') 12 | .action((a, b, rest) => { 13 | console.log(a, b, rest) 14 | }) 15 | 16 | cli 17 | .command('cook <...food>', 'Cook some good') 18 | .option('--bar', 'Bar is a boolean option') 19 | .action((food, options) => { 20 | console.log(food, options) 21 | }) 22 | 23 | cli 24 | .command('[...files]', 'Build given files') 25 | .option('--no-minify', 'Do not minify the output') 26 | .option('--source-map', 'Enable source maps') 27 | .action((args, flags) => { 28 | console.log(args, flags) 29 | }) 30 | 31 | cli.version('0.0.0') 32 | cli.help() 33 | 34 | cli.parse() 35 | -------------------------------------------------------------------------------- /examples/variadic-arguments.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register') 2 | const cli = require('../src/index').cac() 3 | 4 | cli 5 | .command('build [...otherFiles]', 'Build your app') 6 | .option('--foo', 'Foo option') 7 | .action((entry, otherFiles, options) => { 8 | console.log(entry) 9 | console.log(otherFiles) 10 | console.log(options) 11 | }) 12 | 13 | cli.help() 14 | 15 | cli.parse() 16 | -------------------------------------------------------------------------------- /index-compat.js: -------------------------------------------------------------------------------- 1 | const { cac, CAC, Command } = require('./dist/index') 2 | 3 | // For backwards compatibility 4 | module.exports = cac 5 | 6 | Object.assign(module.exports, { 7 | default: cac, 8 | cac, 9 | CAC, 10 | Command, 11 | }) 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest' 5 | }, 6 | testRegex: '(/__test__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | testPathIgnorePatterns: ['/node_modules/', '/dist/', '/types/'], 8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] 9 | } 10 | -------------------------------------------------------------------------------- /mod.js: -------------------------------------------------------------------------------- 1 | // Deno users should use mod.ts instead 2 | export * from './deno/index.ts' -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | // For Deno 2 | export * from './deno/index.ts' 3 | -------------------------------------------------------------------------------- /mod_test.ts: -------------------------------------------------------------------------------- 1 | import { cac } from './mod.ts' 2 | 3 | const cli = cac('my-program') 4 | 5 | cli.command('[any]', '').action(() => console.log('any')) 6 | 7 | cli.help() 8 | cli.version('0.0.0') 9 | 10 | cli.parse() 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cac", 3 | "version": "6.0.0", 4 | "description": "Simple yet powerful framework for building command-line apps.", 5 | "repository": { 6 | "url": "egoist/cac", 7 | "type": "git" 8 | }, 9 | "main": "index-compat.js", 10 | "module": "dist/index.mjs", 11 | "types": "dist/index.d.ts", 12 | "exports": { 13 | ".": { 14 | "types": "./dist/index.d.ts", 15 | "import": "./dist/index.mjs", 16 | "require": "./index-compat.js" 17 | }, 18 | "./package.json": "./package.json", 19 | "./*": "./*" 20 | }, 21 | "files": [ 22 | "dist", 23 | "!**/__test__/**", 24 | "/mod.js", 25 | "/mod.ts", 26 | "/deno", 27 | "/index-compat.js" 28 | ], 29 | "scripts": { 30 | "test": "jest", 31 | "test:cov": "jest --coverage", 32 | "build:deno": "node -r sucrase/register scripts/build-deno.ts", 33 | "build:node": "rollup -c", 34 | "build": "yarn build:deno && yarn build:node", 35 | "toc": "markdown-toc -i README.md", 36 | "prepublishOnly": "npm run build && cp mod.js mod.mjs", 37 | "docs:api": "typedoc --out api-doc --readme none --exclude \"**/__test__/**\" --theme minimal" 38 | }, 39 | "author": "egoist <0x142857@gmail.com>", 40 | "license": "MIT", 41 | "devDependencies": { 42 | "@babel/core": "^7.12.10", 43 | "@babel/plugin-syntax-typescript": "^7.12.1", 44 | "@rollup/plugin-commonjs": "^17.0.0", 45 | "@rollup/plugin-node-resolve": "^11.0.0", 46 | "@types/fs-extra": "^9.0.5", 47 | "@types/jest": "^26.0.19", 48 | "@types/mri": "^1.1.0", 49 | "cz-conventional-changelog": "^2.1.0", 50 | "esbuild": "^0.8.21", 51 | "eslint-config-rem": "^3.0.0", 52 | "execa": "^5.0.0", 53 | "fs-extra": "^9.0.1", 54 | "globby": "^11.0.1", 55 | "husky": "^1.2.0", 56 | "jest": "^24.9.0", 57 | "lint-staged": "^8.1.0", 58 | "markdown-toc": "^1.2.0", 59 | "mri": "^1.1.6", 60 | "prettier": "^2.2.1", 61 | "rollup": "^2.34.2", 62 | "rollup-plugin-dts": "^2.0.1", 63 | "rollup-plugin-esbuild": "^2.6.1", 64 | "semantic-release": "^17.3.0", 65 | "sucrase": "^3.16.0", 66 | "ts-jest": "^26.4.4", 67 | "ts-node": "^9.1.1", 68 | "typedoc": "^0.19.2", 69 | "typescript": "^4.1.2" 70 | }, 71 | "engines": { 72 | "node": ">=8" 73 | }, 74 | "release": { 75 | "branch": "master" 76 | }, 77 | "config": { 78 | "commitizen": { 79 | "path": "./node_modules/cz-conventional-changelog" 80 | } 81 | }, 82 | "lint-staged": { 83 | "linters": { 84 | "*.{js,json,ts}": [ 85 | "prettier --write", 86 | "git add" 87 | ], 88 | "*.md": [ 89 | "markdown-toc -i", 90 | "prettier --write", 91 | "git add" 92 | ] 93 | }, 94 | "ignore": [ 95 | "dist/**", 96 | "mod.js" 97 | ] 98 | }, 99 | "husky": { 100 | "hooks": { 101 | "pre-commit": "npm t && lint-staged" 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolvePlugin from '@rollup/plugin-node-resolve' 2 | import esbuildPlugin from 'rollup-plugin-esbuild' 3 | import dtsPlugin from 'rollup-plugin-dts' 4 | 5 | function createConfig({ dts, esm } = {}) { 6 | let file = 'dist/index.js' 7 | if (dts) { 8 | file = file.replace('.js', '.d.ts') 9 | } 10 | if (esm) { 11 | file = file.replace('.js', '.mjs') 12 | } 13 | return { 14 | input: 'src/index.ts', 15 | output: { 16 | format: dts || esm ? 'esm' : 'cjs', 17 | file, 18 | exports: 'named', 19 | }, 20 | plugins: [ 21 | nodeResolvePlugin({ 22 | mainFields: dts ? ['types', 'typings'] : ['module', 'main'], 23 | extensions: dts ? ['.d.ts', '.ts'] : ['.js', '.json', '.mjs'], 24 | customResolveOptions: { 25 | moduleDirectories: dts 26 | ? ['node_modules/@types', 'node_modules'] 27 | : ['node_modules'], 28 | }, 29 | }), 30 | !dts && require('@rollup/plugin-commonjs')(), 31 | !dts && 32 | esbuildPlugin({ 33 | target: 'es2017', 34 | }), 35 | dts && dtsPlugin(), 36 | ].filter(Boolean), 37 | } 38 | } 39 | 40 | export default [ 41 | createConfig(), 42 | createConfig({ dts: true }), 43 | createConfig({ esm: true }), 44 | ] 45 | -------------------------------------------------------------------------------- /scripts/build-deno.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import globby from 'globby' 3 | import fs from 'fs-extra' 4 | import { transformAsync, PluginObj, types as Types } from '@babel/core' 5 | import tsSyntax from '@babel/plugin-syntax-typescript' 6 | 7 | function node2deno(options: { types: typeof Types }): PluginObj { 8 | const t = options.types 9 | return { 10 | name: 'node2deno', 11 | 12 | visitor: { 13 | ImportDeclaration(path) { 14 | const source = path.node.source 15 | if (source.value.startsWith('.')) { 16 | if (source.value.endsWith('/node')) { 17 | source.value = source.value.replace('node', 'deno') 18 | } 19 | source.value += '.ts' 20 | } else if (source.value === 'events') { 21 | source.value = `https://deno.land/std@0.114.0/node/events.ts` 22 | } else if (source.value === 'mri') { 23 | source.value = `https://cdn.skypack.dev/mri` 24 | } 25 | }, 26 | }, 27 | } 28 | } 29 | 30 | async function main() { 31 | const files = await globby(['**/*.ts', '!**/__test__/**'], { cwd: 'src' }) 32 | await Promise.all( 33 | files.map(async (file) => { 34 | if (file === 'node.ts') return 35 | const content = await fs.readFile(path.join('src', file), 'utf8') 36 | const transformed = await transformAsync(content, { 37 | plugins: [tsSyntax, node2deno], 38 | }) 39 | await fs.outputFile(path.join('deno', file), transformed.code, 'utf8') 40 | }) 41 | ) 42 | } 43 | 44 | main().catch((error) => { 45 | console.error(error) 46 | process.exitCode = 1 47 | }) 48 | -------------------------------------------------------------------------------- /src/CAC.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import mri from 'mri' 3 | import Command, { 4 | GlobalCommand, 5 | CommandConfig, 6 | HelpCallback, 7 | CommandExample, 8 | } from './Command' 9 | import { OptionConfig } from './Option' 10 | import { 11 | getMriOptions, 12 | setDotProp, 13 | setByType, 14 | getFileName, 15 | camelcaseOptionName, 16 | } from './utils' 17 | import { processArgs } from './node' 18 | 19 | interface ParsedArgv { 20 | args: ReadonlyArray 21 | options: { 22 | [k: string]: any 23 | } 24 | } 25 | 26 | class CAC extends EventEmitter { 27 | /** The program name to display in help and version message */ 28 | name: string 29 | commands: Command[] 30 | globalCommand: GlobalCommand 31 | matchedCommand?: Command 32 | matchedCommandName?: string 33 | /** 34 | * Raw CLI arguments 35 | */ 36 | rawArgs: string[] 37 | /** 38 | * Parsed CLI arguments 39 | */ 40 | args: ParsedArgv['args'] 41 | /** 42 | * Parsed CLI options, camelCased 43 | */ 44 | options: ParsedArgv['options'] 45 | 46 | showHelpOnExit?: boolean 47 | showVersionOnExit?: boolean 48 | 49 | /** 50 | * @param name The program name to display in help and version message 51 | */ 52 | constructor(name = '') { 53 | super() 54 | this.name = name 55 | this.commands = [] 56 | this.rawArgs = [] 57 | this.args = [] 58 | this.options = {} 59 | this.globalCommand = new GlobalCommand(this) 60 | this.globalCommand.usage(' [options]') 61 | } 62 | 63 | /** 64 | * Add a global usage text. 65 | * 66 | * This is not used by sub-commands. 67 | */ 68 | usage(text: string) { 69 | this.globalCommand.usage(text) 70 | return this 71 | } 72 | 73 | /** 74 | * Add a sub-command 75 | */ 76 | command(rawName: string, description?: string, config?: CommandConfig) { 77 | const command = new Command(rawName, description || '', config, this) 78 | command.globalCommand = this.globalCommand 79 | this.commands.push(command) 80 | return command 81 | } 82 | 83 | /** 84 | * Add a global CLI option. 85 | * 86 | * Which is also applied to sub-commands. 87 | */ 88 | option(rawName: string, description: string, config?: OptionConfig) { 89 | this.globalCommand.option(rawName, description, config) 90 | return this 91 | } 92 | 93 | /** 94 | * Show help message when `-h, --help` flags appear. 95 | * 96 | */ 97 | help(callback?: HelpCallback) { 98 | this.globalCommand.option('-h, --help', 'Display this message') 99 | this.globalCommand.helpCallback = callback 100 | this.showHelpOnExit = true 101 | return this 102 | } 103 | 104 | /** 105 | * Show version number when `-v, --version` flags appear. 106 | * 107 | */ 108 | version(version: string, customFlags = '-v, --version') { 109 | this.globalCommand.version(version, customFlags) 110 | this.showVersionOnExit = true 111 | return this 112 | } 113 | 114 | /** 115 | * Add a global example. 116 | * 117 | * This example added here will not be used by sub-commands. 118 | */ 119 | example(example: CommandExample) { 120 | this.globalCommand.example(example) 121 | return this 122 | } 123 | 124 | /** 125 | * Output the corresponding help message 126 | * When a sub-command is matched, output the help message for the command 127 | * Otherwise output the global one. 128 | * 129 | */ 130 | outputHelp() { 131 | if (this.matchedCommand) { 132 | this.matchedCommand.outputHelp() 133 | } else { 134 | this.globalCommand.outputHelp() 135 | } 136 | } 137 | 138 | /** 139 | * Output the version number. 140 | * 141 | */ 142 | outputVersion() { 143 | this.globalCommand.outputVersion() 144 | } 145 | 146 | private setParsedInfo( 147 | { args, options }: ParsedArgv, 148 | matchedCommand?: Command, 149 | matchedCommandName?: string 150 | ) { 151 | this.args = args 152 | this.options = options 153 | if (matchedCommand) { 154 | this.matchedCommand = matchedCommand 155 | } 156 | if (matchedCommandName) { 157 | this.matchedCommandName = matchedCommandName 158 | } 159 | return this 160 | } 161 | 162 | unsetMatchedCommand() { 163 | this.matchedCommand = undefined 164 | this.matchedCommandName = undefined 165 | } 166 | 167 | /** 168 | * Parse argv 169 | */ 170 | parse( 171 | argv = processArgs, 172 | { 173 | /** Whether to run the action for matched command */ 174 | run = true, 175 | } = {} 176 | ): ParsedArgv { 177 | this.rawArgs = argv 178 | if (!this.name) { 179 | this.name = argv[1] ? getFileName(argv[1]) : 'cli' 180 | } 181 | 182 | let shouldParse = true 183 | 184 | // Search sub-commands 185 | for (const command of this.commands) { 186 | const parsed = this.mri(argv.slice(2), command) 187 | 188 | const commandName = parsed.args[0] 189 | if (command.isMatched(commandName)) { 190 | shouldParse = false 191 | const parsedInfo = { 192 | ...parsed, 193 | args: parsed.args.slice(1), 194 | } 195 | this.setParsedInfo(parsedInfo, command, commandName) 196 | this.emit(`command:${commandName}`, command) 197 | } 198 | } 199 | 200 | if (shouldParse) { 201 | // Search the default command 202 | for (const command of this.commands) { 203 | if (command.name === '') { 204 | shouldParse = false 205 | const parsed = this.mri(argv.slice(2), command) 206 | this.setParsedInfo(parsed, command) 207 | this.emit(`command:!`, command) 208 | } 209 | } 210 | } 211 | 212 | if (shouldParse) { 213 | const parsed = this.mri(argv.slice(2)) 214 | this.setParsedInfo(parsed) 215 | } 216 | 217 | if (this.options.help && this.showHelpOnExit) { 218 | this.outputHelp() 219 | run = false 220 | this.unsetMatchedCommand() 221 | } 222 | 223 | if (this.options.version && this.showVersionOnExit && this.matchedCommandName == null) { 224 | this.outputVersion() 225 | run = false 226 | this.unsetMatchedCommand() 227 | } 228 | 229 | const parsedArgv = { args: this.args, options: this.options } 230 | 231 | if (run) { 232 | this.runMatchedCommand() 233 | } 234 | 235 | if (!this.matchedCommand && this.args[0]) { 236 | this.emit('command:*') 237 | } 238 | 239 | return parsedArgv 240 | } 241 | 242 | private mri( 243 | argv: string[], 244 | /** Matched command */ command?: Command 245 | ): ParsedArgv { 246 | // All added options 247 | const cliOptions = [ 248 | ...this.globalCommand.options, 249 | ...(command ? command.options : []), 250 | ] 251 | const mriOptions = getMriOptions(cliOptions) 252 | 253 | // Extract everything after `--` since mri doesn't support it 254 | let argsAfterDoubleDashes: string[] = [] 255 | const doubleDashesIndex = argv.indexOf('--') 256 | if (doubleDashesIndex > -1) { 257 | argsAfterDoubleDashes = argv.slice(doubleDashesIndex + 1) 258 | argv = argv.slice(0, doubleDashesIndex) 259 | } 260 | 261 | let parsed = mri(argv, mriOptions) 262 | parsed = Object.keys(parsed).reduce( 263 | (res, name) => { 264 | return { 265 | ...res, 266 | [camelcaseOptionName(name)]: parsed[name], 267 | } 268 | }, 269 | { _: [] } 270 | ) 271 | 272 | const args = parsed._ 273 | 274 | const options: { [k: string]: any } = { 275 | '--': argsAfterDoubleDashes, 276 | } 277 | 278 | // Set option default value 279 | const ignoreDefault = 280 | command && command.config.ignoreOptionDefaultValue 281 | ? command.config.ignoreOptionDefaultValue 282 | : this.globalCommand.config.ignoreOptionDefaultValue 283 | 284 | let transforms = Object.create(null) 285 | 286 | for (const cliOption of cliOptions) { 287 | if (!ignoreDefault && cliOption.config.default !== undefined) { 288 | for (const name of cliOption.names) { 289 | options[name] = cliOption.config.default 290 | } 291 | } 292 | 293 | // If options type is defined 294 | if (Array.isArray(cliOption.config.type)) { 295 | if (transforms[cliOption.name] === undefined) { 296 | transforms[cliOption.name] = Object.create(null) 297 | 298 | transforms[cliOption.name]['shouldTransform'] = true 299 | transforms[cliOption.name]['transformFunction'] = 300 | cliOption.config.type[0] 301 | } 302 | } 303 | } 304 | 305 | // Set option values (support dot-nested property name) 306 | for (const key of Object.keys(parsed)) { 307 | if (key !== '_') { 308 | const keys = key.split('.') 309 | setDotProp(options, keys, parsed[key]) 310 | setByType(options, transforms) 311 | } 312 | } 313 | 314 | return { 315 | args, 316 | options, 317 | } 318 | } 319 | 320 | runMatchedCommand() { 321 | const { args, options, matchedCommand: command } = this 322 | 323 | if (!command || !command.commandAction) return 324 | 325 | command.checkUnknownOptions() 326 | 327 | command.checkOptionValue() 328 | 329 | command.checkRequiredArgs() 330 | 331 | const actionArgs: any[] = [] 332 | command.args.forEach((arg, index) => { 333 | if (arg.variadic) { 334 | actionArgs.push(args.slice(index)) 335 | } else { 336 | actionArgs.push(args[index]) 337 | } 338 | }) 339 | actionArgs.push(options) 340 | return command.commandAction.apply(this, actionArgs) 341 | } 342 | } 343 | 344 | export default CAC 345 | -------------------------------------------------------------------------------- /src/Command.ts: -------------------------------------------------------------------------------- 1 | import CAC from './CAC' 2 | import Option, { OptionConfig } from './Option' 3 | import { 4 | removeBrackets, 5 | findAllBrackets, 6 | findLongest, 7 | padRight, 8 | CACError, 9 | } from './utils' 10 | import { platformInfo } from './node' 11 | 12 | interface CommandArg { 13 | required: boolean 14 | value: string 15 | variadic: boolean 16 | } 17 | 18 | interface HelpSection { 19 | title?: string 20 | body: string 21 | } 22 | 23 | interface CommandConfig { 24 | allowUnknownOptions?: boolean 25 | ignoreOptionDefaultValue?: boolean 26 | } 27 | 28 | type HelpCallback = (sections: HelpSection[]) => void | HelpSection[] 29 | 30 | type CommandExample = ((bin: string) => string) | string 31 | 32 | class Command { 33 | options: Option[] 34 | aliasNames: string[] 35 | /* Parsed command name */ 36 | name: string 37 | args: CommandArg[] 38 | commandAction?: (...args: any[]) => any 39 | usageText?: string 40 | versionNumber?: string 41 | examples: CommandExample[] 42 | helpCallback?: HelpCallback 43 | globalCommand?: GlobalCommand 44 | 45 | constructor( 46 | public rawName: string, 47 | public description: string, 48 | public config: CommandConfig = {}, 49 | public cli: CAC 50 | ) { 51 | this.options = [] 52 | this.aliasNames = [] 53 | this.name = removeBrackets(rawName) 54 | this.args = findAllBrackets(rawName) 55 | this.examples = [] 56 | } 57 | 58 | usage(text: string) { 59 | this.usageText = text 60 | return this 61 | } 62 | 63 | allowUnknownOptions() { 64 | this.config.allowUnknownOptions = true 65 | return this 66 | } 67 | 68 | ignoreOptionDefaultValue() { 69 | this.config.ignoreOptionDefaultValue = true 70 | return this 71 | } 72 | 73 | version(version: string, customFlags = '-v, --version') { 74 | this.versionNumber = version 75 | this.option(customFlags, 'Display version number') 76 | return this 77 | } 78 | 79 | example(example: CommandExample) { 80 | this.examples.push(example) 81 | return this 82 | } 83 | 84 | /** 85 | * Add a option for this command 86 | * @param rawName Raw option name(s) 87 | * @param description Option description 88 | * @param config Option config 89 | */ 90 | option(rawName: string, description: string, config?: OptionConfig) { 91 | const option = new Option(rawName, description, config) 92 | this.options.push(option) 93 | return this 94 | } 95 | 96 | alias(name: string) { 97 | this.aliasNames.push(name) 98 | return this 99 | } 100 | 101 | action(callback: (...args: any[]) => any) { 102 | this.commandAction = callback 103 | return this 104 | } 105 | 106 | /** 107 | * Check if a command name is matched by this command 108 | * @param name Command name 109 | */ 110 | isMatched(name: string) { 111 | return this.name === name || this.aliasNames.includes(name) 112 | } 113 | 114 | get isDefaultCommand() { 115 | return this.name === '' || this.aliasNames.includes('!') 116 | } 117 | 118 | get isGlobalCommand(): boolean { 119 | return this instanceof GlobalCommand 120 | } 121 | 122 | /** 123 | * Check if an option is registered in this command 124 | * @param name Option name 125 | */ 126 | hasOption(name: string) { 127 | name = name.split('.')[0] 128 | return this.options.find((option) => { 129 | return option.names.includes(name) 130 | }) 131 | } 132 | 133 | outputHelp() { 134 | const { name, commands } = this.cli 135 | const { 136 | versionNumber, 137 | options: globalOptions, 138 | helpCallback, 139 | } = this.cli.globalCommand 140 | 141 | let sections: HelpSection[] = [ 142 | { 143 | body: `${name}${versionNumber ? `/${versionNumber}` : ''}`, 144 | }, 145 | ] 146 | 147 | sections.push({ 148 | title: 'Usage', 149 | body: ` $ ${name} ${this.usageText || this.rawName}`, 150 | }) 151 | 152 | const showCommands = 153 | (this.isGlobalCommand || this.isDefaultCommand) && commands.length > 0 154 | 155 | if (showCommands) { 156 | const longestCommandName = findLongest( 157 | commands.map((command) => command.rawName) 158 | ) 159 | sections.push({ 160 | title: 'Commands', 161 | body: commands 162 | .map((command) => { 163 | return ` ${padRight( 164 | command.rawName, 165 | longestCommandName.length 166 | )} ${command.description}` 167 | }) 168 | .join('\n'), 169 | }) 170 | sections.push({ 171 | title: `For more info, run any command with the \`--help\` flag`, 172 | body: commands 173 | .map( 174 | (command) => 175 | ` $ ${name}${ 176 | command.name === '' ? '' : ` ${command.name}` 177 | } --help` 178 | ) 179 | .join('\n'), 180 | }) 181 | } 182 | 183 | let options = this.isGlobalCommand 184 | ? globalOptions 185 | : [...this.options, ...(globalOptions || [])] 186 | if (!this.isGlobalCommand && !this.isDefaultCommand) { 187 | options = options.filter((option) => option.name !== 'version') 188 | } 189 | if (options.length > 0) { 190 | const longestOptionName = findLongest( 191 | options.map((option) => option.rawName) 192 | ) 193 | sections.push({ 194 | title: 'Options', 195 | body: options 196 | .map((option) => { 197 | return ` ${padRight(option.rawName, longestOptionName.length)} ${ 198 | option.description 199 | } ${ 200 | option.config.default === undefined 201 | ? '' 202 | : `(default: ${option.config.default})` 203 | }` 204 | }) 205 | .join('\n'), 206 | }) 207 | } 208 | 209 | if (this.examples.length > 0) { 210 | sections.push({ 211 | title: 'Examples', 212 | body: this.examples 213 | .map((example) => { 214 | if (typeof example === 'function') { 215 | return example(name) 216 | } 217 | return example 218 | }) 219 | .join('\n'), 220 | }) 221 | } 222 | 223 | if (helpCallback) { 224 | sections = helpCallback(sections) || sections 225 | } 226 | 227 | console.log( 228 | sections 229 | .map((section) => { 230 | return section.title 231 | ? `${section.title}:\n${section.body}` 232 | : section.body 233 | }) 234 | .join('\n\n') 235 | ) 236 | } 237 | 238 | outputVersion() { 239 | const { name } = this.cli 240 | const { versionNumber } = this.cli.globalCommand 241 | if (versionNumber) { 242 | console.log(`${name}/${versionNumber} ${platformInfo}`) 243 | } 244 | } 245 | 246 | checkRequiredArgs() { 247 | const minimalArgsCount = this.args.filter((arg) => arg.required).length 248 | 249 | if (this.cli.args.length < minimalArgsCount) { 250 | throw new CACError( 251 | `missing required args for command \`${this.rawName}\`` 252 | ) 253 | } 254 | } 255 | 256 | /** 257 | * Check if the parsed options contain any unknown options 258 | * 259 | * Exit and output error when true 260 | */ 261 | checkUnknownOptions() { 262 | const { options, globalCommand } = this.cli 263 | 264 | if (!this.config.allowUnknownOptions) { 265 | for (const name of Object.keys(options)) { 266 | if ( 267 | name !== '--' && 268 | !this.hasOption(name) && 269 | !globalCommand.hasOption(name) 270 | ) { 271 | throw new CACError( 272 | `Unknown option \`${name.length > 1 ? `--${name}` : `-${name}`}\`` 273 | ) 274 | } 275 | } 276 | } 277 | } 278 | 279 | /** 280 | * Check if the required string-type options exist 281 | */ 282 | checkOptionValue() { 283 | const { options: parsedOptions, globalCommand } = this.cli 284 | const options = [...globalCommand.options, ...this.options] 285 | for (const option of options) { 286 | const value = parsedOptions[option.name.split('.')[0]] 287 | // Check required option value 288 | if (option.required) { 289 | const hasNegated = options.some( 290 | (o) => o.negated && o.names.includes(option.name) 291 | ) 292 | if (value === true || (value === false && !hasNegated)) { 293 | throw new CACError(`option \`${option.rawName}\` value is missing`) 294 | } 295 | } 296 | } 297 | } 298 | } 299 | 300 | class GlobalCommand extends Command { 301 | constructor(cli: CAC) { 302 | super('@@global@@', '', {}, cli) 303 | } 304 | } 305 | 306 | export type { HelpCallback, CommandExample, CommandConfig } 307 | 308 | export { GlobalCommand } 309 | 310 | export default Command 311 | -------------------------------------------------------------------------------- /src/Option.ts: -------------------------------------------------------------------------------- 1 | import { removeBrackets, camelcaseOptionName } from './utils' 2 | 3 | interface OptionConfig { 4 | default?: any 5 | type?: any[] 6 | } 7 | 8 | export default class Option { 9 | /** Option name */ 10 | name: string 11 | /** Option name and aliases */ 12 | names: string[] 13 | isBoolean?: boolean 14 | // `required` will be a boolean for options with brackets 15 | required?: boolean 16 | config: OptionConfig 17 | negated: boolean 18 | 19 | constructor( 20 | public rawName: string, 21 | public description: string, 22 | config?: OptionConfig 23 | ) { 24 | this.config = Object.assign({}, config) 25 | 26 | // You may use cli.option('--env.* [value]', 'desc') to denote a dot-nested option 27 | rawName = rawName.replace(/\.\*/g, '') 28 | 29 | this.negated = false 30 | this.names = removeBrackets(rawName) 31 | .split(',') 32 | .map((v: string) => { 33 | let name = v.trim().replace(/^-{1,2}/, '') 34 | if (name.startsWith('no-')) { 35 | this.negated = true 36 | name = name.replace(/^no-/, '') 37 | } 38 | 39 | return camelcaseOptionName(name) 40 | }) 41 | .sort((a, b) => (a.length > b.length ? 1 : -1)) // Sort names 42 | 43 | // Use the longest name (last one) as actual option name 44 | this.name = this.names[this.names.length - 1] 45 | 46 | if (this.negated && this.config.default == null) { 47 | this.config.default = true 48 | } 49 | 50 | if (rawName.includes('<')) { 51 | this.required = true 52 | } else if (rawName.includes('[')) { 53 | this.required = false 54 | } else { 55 | // No arg needed, it's boolean flag 56 | this.isBoolean = true 57 | } 58 | } 59 | } 60 | 61 | export type { OptionConfig } 62 | -------------------------------------------------------------------------------- /src/__test__/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`basic-usage: basic-usage 1`] = ` 4 | "{ 5 | \\"args\\": [ 6 | \\"foo\\", 7 | \\"bar\\", 8 | \\"command\\" 9 | ], 10 | \\"options\\": { 11 | \\"--\\": [], 12 | \\"type\\": \\"ok\\" 13 | } 14 | }" 15 | `; 16 | 17 | exports[`ignore-default-value: ignore-default-value 1`] = ` 18 | "{ 19 | \\"args\\": [], 20 | \\"options\\": { 21 | \\"--\\": [] 22 | } 23 | }" 24 | `; 25 | 26 | exports[`variadic-arguments: variadic-arguments 1`] = ` 27 | "a 28 | [ 'b', 'c', 'd' ] 29 | { '--': [], foo: true }" 30 | `; 31 | -------------------------------------------------------------------------------- /src/__test__/index.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import execa from 'execa' 3 | import cac from '..' 4 | 5 | jest.setTimeout(30000) 6 | 7 | function example(file: string) { 8 | return path.relative( 9 | process.cwd(), 10 | path.join(__dirname, '../../examples', file) 11 | ) 12 | } 13 | 14 | function snapshotOutput({ 15 | title, 16 | file, 17 | args, 18 | }: { 19 | title: string 20 | file: string 21 | args?: string[] 22 | }) { 23 | test(title, async () => { 24 | const { stdout } = await execa('node', [example(file), ...(args || [])]) 25 | expect(stdout).toMatchSnapshot(title) 26 | }) 27 | } 28 | 29 | async function getOutput(file: string, args: string[] = []) { 30 | const { stdout } = await execa('node', [example(file), ...(args || [])]) 31 | return stdout 32 | } 33 | 34 | snapshotOutput({ 35 | title: 'basic-usage', 36 | file: 'basic-usage.js', 37 | args: ['foo', 'bar', '--type', 'ok', 'command'], 38 | }) 39 | 40 | snapshotOutput({ 41 | title: 'variadic-arguments', 42 | file: 'variadic-arguments.js', 43 | args: ['--foo', 'build', 'a', 'b', 'c', 'd'], 44 | }) 45 | 46 | snapshotOutput({ 47 | title: 'ignore-default-value', 48 | file: 'ignore-default-value.js', 49 | args: ['build'], 50 | }) 51 | 52 | test('negated option', () => { 53 | const cli = cac() 54 | 55 | cli.option('--foo [foo]', 'Set foo').option('--no-foo', 'Disable foo') 56 | 57 | cli.option('--bar [bar]', 'Set bar').option('--no-bar', 'Disable bar') 58 | 59 | const { options } = cli.parse(['node', 'bin', '--foo', 'foo', '--bar']) 60 | expect(options).toEqual({ 61 | '--': [], 62 | foo: 'foo', 63 | bar: true, 64 | }) 65 | }) 66 | 67 | test('double dashes', () => { 68 | const cli = cac() 69 | 70 | const { args, options } = cli.parse([ 71 | 'node', 72 | 'bin', 73 | 'foo', 74 | 'bar', 75 | '--', 76 | 'npm', 77 | 'test', 78 | ]) 79 | 80 | expect(args).toEqual(['foo', 'bar']) 81 | expect(options['--']).toEqual(['npm', 'test']) 82 | }) 83 | 84 | test('default value for negated option', () => { 85 | const cli = cac() 86 | 87 | cli.option('--no-clear-screen', 'no clear screen') 88 | cli.option('--no-a-b, --no-c-d', 'desc') 89 | 90 | const { options } = cli.parse(`node bin`.split(' ')) 91 | 92 | expect(options).toEqual({ '--': [], clearScreen: true, aB: true, cD: true }) 93 | }) 94 | 95 | test('negated option validation', () => { 96 | const cli = cac() 97 | 98 | cli.option('--config ', 'config file') 99 | cli.option('--no-config', 'no config file') 100 | 101 | const { options } = cli.parse(`node bin --no-config`.split(' ')) 102 | 103 | cli.globalCommand.checkOptionValue() 104 | expect(options.config).toBe(false) 105 | }) 106 | 107 | test('array types without transformFunction', () => { 108 | const cli = cac() 109 | 110 | cli 111 | .option( 112 | '--externals ', 113 | 'Add externals(can be used for multiple times', 114 | { 115 | type: [], 116 | } 117 | ) 118 | .option('--scale [level]', 'Scaling level') 119 | 120 | const { options: options1 } = cli.parse( 121 | `node bin --externals.env.prod production --scale`.split(' ') 122 | ) 123 | expect(options1.externals).toEqual([{ env: { prod: 'production' } }]) 124 | expect(options1.scale).toEqual(true) 125 | 126 | const { options: options2 } = cli.parse( 127 | `node bin --externals foo --externals bar`.split(' ') 128 | ) 129 | expect(options2.externals).toEqual(['foo', 'bar']) 130 | 131 | const { options: options3 } = cli.parse( 132 | `node bin --externals.env foo --externals.env bar`.split(' ') 133 | ) 134 | expect(options3.externals).toEqual([{ env: ['foo', 'bar'] }]) 135 | }) 136 | 137 | test('array types with transformFunction', () => { 138 | const cli = cac() 139 | 140 | cli 141 | .command('build [entry]', 'Build your app') 142 | .option('--config ', 'Use config file for building', { 143 | type: [String], 144 | }) 145 | .option('--scale [level]', 'Scaling level') 146 | 147 | const { options } = cli.parse( 148 | `node bin build app.js --config config.js --scale`.split(' ') 149 | ) 150 | expect(options.config).toEqual(['config.js']) 151 | expect(options.scale).toEqual(true) 152 | }) 153 | 154 | test('throw on unknown options', () => { 155 | const cli = cac() 156 | 157 | cli 158 | .command('build [entry]', 'Build your app') 159 | .option('--foo-bar', 'foo bar') 160 | .option('--aB', 'ab') 161 | .action(() => {}) 162 | 163 | expect(() => { 164 | cli.parse(`node bin build app.js --fooBar --a-b --xx`.split(' ')) 165 | }).toThrowError('Unknown option `--xx`') 166 | }) 167 | 168 | describe('--version in help message', () => { 169 | test('sub command', async () => { 170 | const output = await getOutput('help.js', ['lint', '--help']) 171 | expect(output).not.toContain(`--version`) 172 | }) 173 | 174 | test('default command', async () => { 175 | const output = await getOutput('help.js', ['--help']) 176 | expect(output).toContain(`--version`) 177 | }) 178 | }) 179 | -------------------------------------------------------------------------------- /src/deno.ts: -------------------------------------------------------------------------------- 1 | // Ignore the TypeScript errors 2 | // Since this file will only be used in Deno runtime 3 | 4 | export const processArgs = ['deno', 'cli'].concat(Deno.args) 5 | 6 | export const platformInfo = `${Deno.build.os}-${Deno.build.arch} deno-${Deno.version.deno}` 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import CAC from './CAC' 2 | import Command from './Command' 3 | 4 | /** 5 | * @param name The program name to display in help and version message 6 | */ 7 | const cac = (name = '') => new CAC(name) 8 | 9 | export default cac 10 | export { cac, CAC, Command } 11 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | export const processArgs = process.argv 2 | 3 | export const platformInfo = `${process.platform}-${process.arch} node-${process.version}` 4 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import Option from './Option' 2 | 3 | export const removeBrackets = (v: string) => v.replace(/[<[].+/, '').trim() 4 | 5 | export const findAllBrackets = (v: string) => { 6 | const ANGLED_BRACKET_RE_GLOBAL = /<([^>]+)>/g 7 | const SQUARE_BRACKET_RE_GLOBAL = /\[([^\]]+)\]/g 8 | 9 | const res = [] 10 | 11 | const parse = (match: string[]) => { 12 | let variadic = false 13 | let value = match[1] 14 | if (value.startsWith('...')) { 15 | value = value.slice(3) 16 | variadic = true 17 | } 18 | return { 19 | required: match[0].startsWith('<'), 20 | value, 21 | variadic 22 | } 23 | } 24 | 25 | let angledMatch 26 | while ((angledMatch = ANGLED_BRACKET_RE_GLOBAL.exec(v))) { 27 | res.push(parse(angledMatch)) 28 | } 29 | 30 | let squareMatch 31 | while ((squareMatch = SQUARE_BRACKET_RE_GLOBAL.exec(v))) { 32 | res.push(parse(squareMatch)) 33 | } 34 | 35 | return res 36 | } 37 | 38 | interface MriOptions { 39 | alias: { 40 | [k: string]: string[] 41 | } 42 | boolean: string[] 43 | } 44 | 45 | export const getMriOptions = (options: Option[]) => { 46 | const result: MriOptions = { alias: {}, boolean: [] } 47 | 48 | for (const [index, option] of options.entries()) { 49 | // We do not set default values in mri options 50 | // Since its type (typeof) will be used to cast parsed arguments. 51 | // Which mean `--foo foo` will be parsed as `{foo: true}` if we have `{default:{foo: true}}` 52 | 53 | // Set alias 54 | if (option.names.length > 1) { 55 | result.alias[option.names[0]] = option.names.slice(1) 56 | } 57 | // Set boolean 58 | if (option.isBoolean) { 59 | if (option.negated) { 60 | // For negated option 61 | // We only set it to `boolean` type when there's no string-type option with the same name 62 | const hasStringTypeOption = options.some((o, i) => { 63 | return ( 64 | i !== index && 65 | o.names.some(name => option.names.includes(name)) && 66 | typeof o.required === 'boolean' 67 | ) 68 | }) 69 | if (!hasStringTypeOption) { 70 | result.boolean.push(option.names[0]) 71 | } 72 | } else { 73 | result.boolean.push(option.names[0]) 74 | } 75 | } 76 | } 77 | 78 | return result 79 | } 80 | 81 | export const findLongest = (arr: string[]) => { 82 | return arr.sort((a, b) => { 83 | return a.length > b.length ? -1 : 1 84 | })[0] 85 | } 86 | 87 | export const padRight = (str: string, length: number) => { 88 | return str.length >= length ? str : `${str}${' '.repeat(length - str.length)}` 89 | } 90 | 91 | export const camelcase = (input: string) => { 92 | return input.replace(/([a-z])-([a-z])/g, (_, p1, p2) => { 93 | return p1 + p2.toUpperCase() 94 | }) 95 | } 96 | 97 | export const setDotProp = ( 98 | obj: { [k: string]: any }, 99 | keys: string[], 100 | val: any 101 | ) => { 102 | let i = 0 103 | let length = keys.length 104 | let t = obj 105 | let x 106 | for (; i < length; ++i) { 107 | x = t[keys[i]] 108 | t = t[keys[i]] = 109 | i === length - 1 110 | ? val 111 | : x != null 112 | ? x 113 | : !!~keys[i + 1].indexOf('.') || !(+keys[i + 1] > -1) 114 | ? {} 115 | : [] 116 | } 117 | } 118 | 119 | export const setByType = ( 120 | obj: { [k: string]: any }, 121 | transforms: { [k: string]: any } 122 | ) => { 123 | for (const key of Object.keys(transforms)) { 124 | const transform = transforms[key] 125 | 126 | if (transform.shouldTransform) { 127 | obj[key] = Array.prototype.concat.call([], obj[key]) 128 | 129 | if (typeof transform.transformFunction === 'function') { 130 | obj[key] = obj[key].map(transform.transformFunction) 131 | } 132 | } 133 | } 134 | } 135 | 136 | export const getFileName = (input: string) => { 137 | const m = /([^\\\/]+)$/.exec(input) 138 | return m ? m[1] : '' 139 | } 140 | 141 | export const camelcaseOptionName = (name: string) => { 142 | // Camelcase the option name 143 | // Don't camelcase anything after the dot `.` 144 | return name 145 | .split('.') 146 | .map((v, i) => { 147 | return i === 0 ? camelcase(v) : v 148 | }) 149 | .join('.') 150 | } 151 | 152 | export class CACError extends Error { 153 | constructor(message: string) { 154 | super(message) 155 | this.name = this.constructor.name 156 | if (typeof Error.captureStackTrace === 'function') { 157 | Error.captureStackTrace(this, this.constructor) 158 | } else { 159 | this.stack = new Error(message).stack 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "declaration": true, 5 | "declarationDir": "types", 6 | "esModuleInterop": true, 7 | "pretty": true, 8 | "moduleResolution": "node", 9 | "lib": ["es2015", "es2016.array.include"], 10 | "allowSyntheticDefaultImports": true, 11 | "stripInternal": true, 12 | "noImplicitAny": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "strictNullChecks": true, 19 | "strictFunctionTypes": true, 20 | "strictPropertyInitialization": true, 21 | "alwaysStrict": true, 22 | "module": "commonjs", 23 | "outDir": "lib" 24 | }, 25 | "include": ["src", "declarations.d.ts"], 26 | "exclude": ["src/deno.ts"] 27 | } 28 | --------------------------------------------------------------------------------