├── .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 |
2 |
3 | [](https://npmjs.com/package/cac) [](https://npmjs.com/package/cac) [](https://circleci.com/gh/cacjs/cac/tree/master) [](https://codecov.io/gh/cacjs/cac) [](https://github.com/egoist/donate) [](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 |
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 |
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 |
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 |
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 |
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 | 
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 |
--------------------------------------------------------------------------------