├── .eslintignore ├── .gitignore ├── sources ├── platform │ ├── package.json │ ├── browser.ts │ └── node.ts ├── advanced │ ├── builtins │ │ ├── index.ts │ │ ├── help.ts │ │ ├── definitions.ts │ │ └── version.ts │ ├── options │ │ ├── index.ts │ │ ├── Proxy.ts │ │ ├── Boolean.ts │ │ ├── Rest.ts │ │ ├── Counter.ts │ │ ├── Array.ts │ │ ├── utils.ts │ │ └── String.ts │ ├── index.ts │ ├── HelpCommand.ts │ ├── Command.ts │ └── Cli.ts ├── constants.ts ├── errors.ts ├── format.ts └── core.ts ├── assets ├── example-command-help.png └── example-general-help.png ├── .vscode ├── extensions.json └── settings.json ├── .gitattributes ├── tests ├── expect.ts ├── tools.ts ├── __snapshots__ │ └── treeshake.test.ts.snap.js └── specs │ ├── browser.test.ts │ ├── treeshake.test.ts │ ├── run.test.ts │ ├── inference.ts │ └── core.test.ts ├── .eslintrc.js ├── tsconfig.dist.json ├── website ├── docs │ ├── showcase.md │ ├── api │ │ ├── helpers.md │ │ ├── builtins.md │ │ ├── cli.md │ │ └── option.md │ ├── overview.md │ ├── contexts.md │ ├── paths.md │ ├── validation.md │ ├── getting-started.md │ ├── help.md │ ├── options.md │ └── tips.md └── config.js ├── tsconfig.json ├── .github └── workflows │ └── nodejs.yml ├── rollup.config.js ├── logo.svg ├── package.json ├── README.md └── demos └── advanced.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | /website/build 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | 3 | node_modules 4 | /.pnp.* 5 | -------------------------------------------------------------------------------- /sources/platform/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./node", 3 | "browser": "./browser" 4 | } 5 | -------------------------------------------------------------------------------- /assets/example-command-help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackblitz/clipanion/master/assets/example-command-help.png -------------------------------------------------------------------------------- /assets/example-general-help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackblitz/clipanion/master/assets/example-general-help.png -------------------------------------------------------------------------------- /sources/advanced/builtins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './definitions'; 2 | export * from './help'; 3 | export * from './version'; 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "arcanis.vscode-zipfs", 4 | "dbaeumer.vscode-eslint" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Hide .yarn and .vscode/pnpify from GitHub's language detection 2 | /.yarn/** linguist-vendored 3 | /.vscode/pnpify/** linguist-vendored 4 | -------------------------------------------------------------------------------- /tests/expect.ts: -------------------------------------------------------------------------------- 1 | import chaiAsPromised from 'chai-as-promised'; 2 | import chai, {expect} from 'chai'; 3 | 4 | chai.use(chaiAsPromised); 5 | 6 | export {expect}; 7 | -------------------------------------------------------------------------------- /sources/advanced/options/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Array'; 2 | export * from './Boolean'; 3 | export * from './Counter'; 4 | export * from './Proxy'; 5 | export * from './Rest'; 6 | export * from './String'; 7 | export * from './utils'; 8 | -------------------------------------------------------------------------------- /sources/platform/browser.ts: -------------------------------------------------------------------------------- 1 | export function getDefaultColorDepth() { 2 | return 1; 3 | } 4 | 5 | export function getCaptureActivator() { 6 | throw new Error(`The enableCapture option cannot be used from within a browser environment`); 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | `@yarnpkg`, 4 | `@yarnpkg/eslint-config/react`, 5 | ], 6 | ignorePatterns: [ 7 | `tests/__snapshots__`, 8 | ], 9 | env: { 10 | browser: true, 11 | node: true, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "rootDir": "sources", 6 | "outDir": "lib", 7 | "types": [ 8 | "node" 9 | ] 10 | }, 11 | "include": [ 12 | "sources/**/*" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /website/docs/showcase.md: -------------------------------------------------------------------------------- 1 | ```ts twoslash 2 | import {Command, Option, runExit} from 'clipanion'; 3 | 4 | runExit(class MainCommand extends Command { 5 | name = Option.String(); 6 | 7 | async execute() { 8 | this.context.stdout.write(`Hello ${this.name}!\n`); 9 | } 10 | }) 11 | ``` 12 | -------------------------------------------------------------------------------- /sources/advanced/builtins/help.ts: -------------------------------------------------------------------------------- 1 | import {Command} from '../Command'; 2 | 3 | /** 4 | * A command that prints the usage of all commands. 5 | * 6 | * Paths: `-h`, `--help` 7 | */ 8 | export class HelpCommand extends Command { 9 | static paths = [[`-h`], [`--help`]]; 10 | async execute() { 11 | this.context.stdout.write(this.cli.usage()); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "experimentalDecorators": true, 6 | "lib": [ 7 | "DOM", 8 | "ES2018" 9 | ], 10 | "module": "commonjs", 11 | "noEmit": true, 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "target": "ES2018", 15 | "newLine": "lf" 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /sources/advanced/builtins/definitions.ts: -------------------------------------------------------------------------------- 1 | import {Command} from '../Command'; 2 | 3 | /** 4 | * A command that prints the clipanion definitions. 5 | */ 6 | export class DefinitionsCommand extends Command { 7 | static paths = [[`--clipanion=definitions`]]; 8 | async execute() { 9 | this.context.stdout.write(`${JSON.stringify(this.cli.definitions(), null, 2)}\n`); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /sources/advanced/builtins/version.ts: -------------------------------------------------------------------------------- 1 | import {Command} from '../Command'; 2 | 3 | /** 4 | * A command that prints the version of the binary (`cli.binaryVersion`). 5 | * 6 | * Paths: `-v`, `--version` 7 | */ 8 | export class VersionCommand extends Command { 9 | static paths = [[`-v`], [`--version`]]; 10 | async execute() { 11 | this.context.stdout.write(`${this.cli.binaryVersion ?? ``}\n`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /sources/advanced/index.ts: -------------------------------------------------------------------------------- 1 | export {Command} from './Command'; 2 | 3 | export {BaseContext, Cli, RunContext, CliOptions} from './Cli'; 4 | export {CommandClass, Usage, Definition} from './Command'; 5 | 6 | export {UsageError, ErrorMeta, ErrorWithMeta} from '../errors'; 7 | export {formatMarkdownish, ColorFormat} from '../format'; 8 | 9 | export {run, runExit} from './Cli'; 10 | 11 | export * as Builtins from './builtins'; 12 | export * as Option from './options'; 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": ".yarn/sdks/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "search.exclude": { 5 | "**/.yarn": true, 6 | "**/.pnp.*": true 7 | }, 8 | "eslint.validate": [ 9 | "javascript", 10 | "javascriptreact", 11 | "typescript", 12 | "typescriptreact" 13 | ], 14 | "eslint.nodePath": ".yarn/sdks", 15 | "editor.codeActionsOnSave": { 16 | "source.fixAll.eslint": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /sources/constants.ts: -------------------------------------------------------------------------------- 1 | export const NODE_INITIAL = 0; 2 | export const NODE_SUCCESS = 1; 3 | export const NODE_ERRORED = 2; 4 | 5 | export const START_OF_INPUT = `\u0001`; 6 | export const END_OF_INPUT = `\u0000`; 7 | 8 | export const HELP_COMMAND_INDEX = -1; 9 | 10 | export const HELP_REGEX = /^(-h|--help)(?:=([0-9]+))?$/; 11 | export const OPTION_REGEX = /^(--[a-z]+(?:-[a-z]+)*|-[a-zA-Z]+)$/; 12 | export const BATCH_REGEX = /^-[a-zA-Z]{2,}$/; 13 | export const BINDING_REGEX = /^([^=]+)=([\s\S]*)$/; 14 | 15 | export const DEBUG = process.env.DEBUG_CLI === `1`; 16 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [12.x, 14.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Use Node.js ${{matrix.node-version}} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{matrix.node-version}} 25 | 26 | - run: yarn 27 | - run: yarn tsc 28 | - run: yarn test 29 | - run: yarn lint 30 | -------------------------------------------------------------------------------- /website/docs/api/helpers.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: helpers 3 | title: Helpers 4 | --- 5 | 6 | ## `runExit` / `run` 7 | 8 | ```ts 9 | run(opts?: {...}, commands: Command | Command[], argv?: string[], context?: Context) 10 | runExit(opts?: {...}, commands: Command | Command[], argv?: string[], context?: Context) 11 | ``` 12 | 13 | Those functions abstracts the `Cli` class behind simple helpers, decreasing the amount of boilerplate you need to write when building small CLIs. 14 | 15 | All parameters except the commands (and the [context](/docs/contexts), if you specify custom keys) are optional and will default to sensible values from the current environment. 16 | 17 | Calling `run` will return a promise with the exit code that you'll need to handle yourself, whereas `runExit` will set the process exit code itself. 18 | -------------------------------------------------------------------------------- /sources/advanced/options/Proxy.ts: -------------------------------------------------------------------------------- 1 | import {makeCommandOption} from "./utils"; 2 | 3 | export type ProxyFlags = { 4 | name?: string, 5 | required?: number, 6 | }; 7 | 8 | /** 9 | * Used to annotate that the command wants to retrieve all trailing 10 | * arguments that cannot be tied to a declared option. 11 | * 12 | * Be careful: this function is order-dependent! Make sure to define it 13 | * after any positional argument you want to declare. 14 | * 15 | * This function is mutually exclusive with Option.Rest. 16 | * 17 | * @example 18 | * yarn run foo hello --foo=bar world 19 | * ► proxy = ["hello", "--foo=bar", "world"] 20 | */ 21 | export function Proxy(opts: ProxyFlags = {}) { 22 | return makeCommandOption({ 23 | definition(builder, key) { 24 | builder.addProxy({ 25 | name: opts.name ?? key, 26 | required: opts.required, 27 | }); 28 | }, 29 | 30 | transformer(builder, key, state) { 31 | return state.positionals.map(({value}) => value); 32 | }, 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /website/docs/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: overview 3 | title: Overview 4 | slug: / 5 | --- 6 | 7 | The idea behind Clipanion is to provide a CLI framework that won't make you hate CLIs. In particular, it means that Clipanion wants to be: 8 | 9 | - **Correct**, with consistent and predictable behaviors regardless of your option definitions. 10 | - **Full-featured**, with no need to write custom code to support for specific CLI patterns. 11 | - **Type-safe**, with no risks that your application will silently rely on out-of-sync options. 12 | 13 | It also has a few non-goals: 14 | 15 | - We don't care about being **modular**. Given that we intend to be full-featured, it doesn't make sense to publish things under different package names. Clipanion will always be available as just `clipanion`. 16 | 17 | - We won't provide **domain-specific languages (DSL)**. Once upon a time Clipanion actually worked like this, using a "natural" language to declare commands, but over time it became clear that we were merely fighting JavaScript, losing many useful tooling integrations. 18 | -------------------------------------------------------------------------------- /website/docs/api/builtins.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: builtins 3 | title: Builtins 4 | --- 5 | 6 | The following commands may be useful in some contexts, but not necessarily all of them. For this reason you must explicitly register them into your cli: 7 | 8 | ```ts 9 | cli.register(Builtins.HelpCommand); 10 | ``` 11 | 12 | ## `Builtins.DefinitionsCommand` 13 | 14 | Command triggered by running the tool with the `--clipanion=definitions` flag as unique argument. When called, it will print on the standard output the full JSON specification for the current cli. External tools can then use this information to generate documentation for other media (for example we use this to generate the Yarn CLI documentation). 15 | 16 | ## `Builtins.HelpCommand` 17 | 18 | Command triggered by running the tool with the `-h,--help` flag as unique argument. When called, it will print the list of all available commands on the standard output (minus the hidden ones). 19 | 20 | ## `Builtins.VersionCommand` 21 | 22 | Command triggered by running the tool with the `--version` flag as unique argument. When called, it will print the value of the `binaryVersion` field. 23 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import ts from '@rollup/plugin-typescript'; 2 | import path from 'path'; 3 | import copy from 'rollup-plugin-copy'; 4 | import multiInput from 'rollup-plugin-multi-input'; 5 | 6 | // Since we're using `multiInput`, the entries output path are already set. 7 | // We only need to define the extension we want to give to the file. 8 | const entryFileNames = ext => ({name}) => `${path.basename(name)}${ext}`; 9 | 10 | // eslint-disable-next-line arca/no-default-export 11 | export default { 12 | input: [`sources/**/*.ts`], 13 | output: [ 14 | { 15 | dir: `lib`, 16 | entryFileNames: entryFileNames(`.mjs`), 17 | format: `es`, 18 | }, 19 | { 20 | dir: `lib`, 21 | entryFileNames: entryFileNames(`.js`), 22 | format: `cjs`, 23 | }, 24 | ], 25 | preserveModules: true, 26 | external: [ 27 | `tty`, 28 | `typanion`, 29 | `../platform`, 30 | ], 31 | plugins: [ 32 | multiInput({ 33 | relative: `sources/`, 34 | }), 35 | ts({ 36 | tsconfig: `tsconfig.dist.json`, 37 | include: `./sources/**/*`, 38 | }), 39 | copy({ 40 | targets: [ 41 | {src: `./sources/platform/package.json`, dest: `./lib/platform/`}, 42 | ], 43 | }), 44 | ], 45 | }; 46 | -------------------------------------------------------------------------------- /tests/tools.ts: -------------------------------------------------------------------------------- 1 | import getStream from 'get-stream'; 2 | import {PassThrough} from 'stream'; 3 | 4 | import {Cli, CommandClass, Command, RunContext} from '../sources/advanced'; 5 | 6 | export const log = (command: T, properties: Array = []) => { 7 | command.context.stdout.write(`Running ${command.constructor.name}\n`); 8 | 9 | for (const property of properties) { 10 | command.context.stdout.write(`${JSON.stringify(command[property])}\n`); 11 | } 12 | }; 13 | 14 | export const useContext = async (cb: (context: RunContext) => Promise) => { 15 | const stream = new PassThrough(); 16 | const promise = getStream(stream); 17 | 18 | const exitCode = await cb({ 19 | stdin: process.stdin, 20 | stdout: stream, 21 | stderr: stream, 22 | }); 23 | 24 | stream.end(); 25 | 26 | const output = await promise; 27 | 28 | if (exitCode !== 0) 29 | throw new Error(output); 30 | 31 | return output; 32 | }; 33 | 34 | export const runCli = async (cli: Cli | (() => Array), args: Array) => { 35 | let finalCli: Cli; 36 | 37 | if (typeof cli === `function`) { 38 | finalCli = new Cli(); 39 | 40 | for (const command of cli()) { 41 | finalCli.register(command); 42 | } 43 | } else { 44 | finalCli = cli; 45 | } 46 | 47 | return await useContext(async context => { 48 | return await finalCli.run(args, context); 49 | }); 50 | }; 51 | 52 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/docs/api/cli.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: cli 3 | title: Cli 4 | --- 5 | 6 | ## `new Cli` 7 | 8 | ```ts 9 | new Cli(opts: {...}) 10 | ``` 11 | 12 | | Option | type | Description | 13 | | --- | --- | --- | 14 | | `binaryLabel` | `string` | Tool name, as shown in the help message | 15 | | `binaryName` | `string`| Binary name, as shown in the usage line | 16 | | `binaryVersion` | `string` | Tool version, as shown in `--version` | 17 | | `enableCapture` | `boolean` | If set, redirect stdout/stderr into the command streams | 18 | | `enableColors` | `boolean` | Overrides the automatic color detection for error messages | 19 | 20 | ## `Cli#process` 21 | 22 | ```ts 23 | cli.process(input: string[]) 24 | ``` 25 | 26 | Turn the given arguments into a partially hydrated command instance. Don't call `execute` on it, as some fields must still be set before the command can be truly executed. Instead, pass it forward to `Cli#run` if you wish to truly execute it. 27 | 28 | ## `Cli#run` 29 | 30 | ```ts 31 | cli.run(input: Command, context: Context) 32 | cli.run(input: string[], context: Context) 33 | ``` 34 | 35 | Turn the given argument into a command that will be immediately executed and returned. If an error happens during execution `Cli#run` will catch it and resolve with an exit code 1 instead. 36 | 37 | ## `Cli#runExit` 38 | 39 | ```ts 40 | cli.runExit(input: string[], context?: Context) 41 | ``` 42 | 43 | Same thing as `Cli#run`, but catches the result of the command and sets `process.exitCode` accordingly. Note that it won't directly call `process.exit`, so the process may stay alive if the event loop isn't empty. 44 | -------------------------------------------------------------------------------- /sources/advanced/options/Boolean.ts: -------------------------------------------------------------------------------- 1 | import {CommandOptionReturn, GeneralOptionFlags, makeCommandOption, rerouteArguments} from "./utils"; 2 | 3 | export type BooleanFlags = GeneralOptionFlags; 4 | 5 | /** 6 | * Used to annotate boolean options. 7 | * 8 | * @example 9 | * --foo --no-bar 10 | * ► {"foo": true, "bar": false} 11 | */ 12 | export function Boolean(descriptor: string, opts: BooleanFlags & {required: true}): CommandOptionReturn; 13 | export function Boolean(descriptor: string, opts?: BooleanFlags): CommandOptionReturn; 14 | export function Boolean(descriptor: string, initialValue: boolean, opts?: Omit): CommandOptionReturn; 15 | export function Boolean(descriptor: string, initialValueBase: BooleanFlags | boolean | undefined, optsBase?: BooleanFlags) { 16 | const [initialValue, opts] = rerouteArguments(initialValueBase, optsBase ?? {}); 17 | 18 | const optNames = descriptor.split(`,`); 19 | const nameSet = new Set(optNames); 20 | 21 | return makeCommandOption({ 22 | definition(builder) { 23 | builder.addOption({ 24 | names: optNames, 25 | 26 | allowBinding: false, 27 | arity: 0, 28 | 29 | hidden: opts.hidden, 30 | description: opts.description, 31 | required: opts.required, 32 | }); 33 | }, 34 | 35 | transformer(builer, key, state) { 36 | let currentValue = initialValue; 37 | 38 | for (const {name, value} of state.options) { 39 | if (!nameSet.has(name)) 40 | continue; 41 | 42 | currentValue = value; 43 | } 44 | 45 | return currentValue; 46 | }, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /sources/advanced/options/Rest.ts: -------------------------------------------------------------------------------- 1 | import {NoLimits} from '../../core'; 2 | 3 | import {makeCommandOption} from "./utils"; 4 | 5 | export type RestFlags = { 6 | name?: string, 7 | required?: number, 8 | }; 9 | 10 | /** 11 | * Used to annotate that the command supports any number of positional 12 | * arguments. 13 | * 14 | * Be careful: this function is order-dependent! Make sure to define it 15 | * after any positional argument you want to declare. 16 | * 17 | * This function is mutually exclusive with Option.Proxy. 18 | * 19 | * @example 20 | * yarn add hello world 21 | * ► rest = ["hello", "world"] 22 | */ 23 | export function Rest(opts: RestFlags = {}) { 24 | return makeCommandOption({ 25 | definition(builder, key) { 26 | builder.addRest({ 27 | name: opts.name ?? key, 28 | required: opts.required, 29 | }); 30 | }, 31 | 32 | transformer(builder, key, state) { 33 | // The builder's arity.extra will always be NoLimits, 34 | // because it is set when we call registerDefinition 35 | 36 | const isRestPositional = (index: number) => { 37 | const positional = state.positionals[index]; 38 | 39 | // A NoLimits extra (i.e. an optional rest argument) 40 | if (positional.extra === NoLimits) 41 | return true; 42 | 43 | // A leading positional (i.e. a required rest argument) 44 | if (positional.extra === false && index < builder.arity.leading.length) 45 | return true; 46 | 47 | return false; 48 | }; 49 | 50 | let count = 0; 51 | while (count < state.positionals.length && isRestPositional(count)) 52 | count += 1; 53 | 54 | return state.positionals.splice(0, count).map(({value}) => value); 55 | }, 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /website/config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | module.exports = { 4 | name: `Clipanion`, 5 | repository: `clipanion`, 6 | description: `Type-safe CLI library with no runtime dependencies`, 7 | algolia: `d4d96f8710b3d92b82fe3e01cb108e0c`, 8 | 9 | icon: { 10 | letter: `C`, 11 | }, 12 | 13 | colors: { 14 | primary: `#7a75ad`, 15 | }, 16 | 17 | sidebar: { 18 | General: [`overview`, `getting-started`, `paths`, `options`, `contexts`, `validation`, `help`, `tips`], 19 | API: [`api/cli`, `api/builtins`, `api/option`], 20 | }, 21 | 22 | index: { 23 | overview: `/docs`, 24 | getStarted: `/docs/getting-started`, 25 | features: [{ 26 | title: `Type Safe`, 27 | description: `Clipanion provides type inference for the options you declare: no duplicated types to write and keep in sync.`, 28 | }, { 29 | title: `Tooling Integration`, 30 | description: `Because it uses standard ES6 classes, tools like ESLint can easily lint your options to detect the unused ones.`, 31 | }, { 32 | title: `Feature Complete`, 33 | description: `Clipanion supports subcommands, arrays, counters, execution contexts, error handling, option proxying, and much more.`, 34 | }, { 35 | title: `Soundness`, 36 | description: `Clipanion unifies your commands into a proper state machine. It gives little room for bugs, and unlocks command overloads.`, 37 | }, { 38 | title: `Tree Shaking`, 39 | description: `The core is implemented using a functional approach, letting most bundlers only keep what you actually use.`, 40 | }, { 41 | title: `Battle Tested`, 42 | description: `Clipanion is used to power Yarn - likely one of the most complex CLI used everyday by the JavaScript community.`, 43 | }], 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /tests/__snapshots__/treeshake.test.ts.snap.js: -------------------------------------------------------------------------------- 1 | exports[`Tree shaking : should only keep the command Options used in the bundle 1`] = "const isOptionSymbol = Symbol(`clipanion/isOption`);\nfunction makeCommandOption(spec) {\n // We lie! But it's for the good cause: the cli engine will turn the specs into proper values after instantiation.\n return { ...spec, [isOptionSymbol]: true };\n}\nfunction rerouteArguments(a, b) {\n if (typeof a === `undefined`)\n return [a, b];\n if (typeof a === `object` && a !== null && !Array.isArray(a)) {\n return [undefined, a];\n }\n else {\n return [a, b];\n }\n}\n\nfunction Array$1(descriptor, initialValueBase, optsBase) {\n const [initialValue, opts] = rerouteArguments(initialValueBase, optsBase !== null && optsBase !== void 0 ? optsBase : {});\n const { arity = 1 } = opts;\n const optNames = descriptor.split(`,`);\n const nameSet = new Set(optNames);\n return makeCommandOption({\n definition(builder) {\n builder.addOption({\n names: optNames,\n arity,\n hidden: opts === null || opts === void 0 ? void 0 : opts.hidden,\n description: opts === null || opts === void 0 ? void 0 : opts.description,\n });\n },\n transformer(builder, key, state) {\n let currentValue = typeof initialValue !== `undefined`\n ? [...initialValue]\n : undefined;\n for (const { name, value } of state.options) {\n if (!nameSet.has(name))\n continue;\n currentValue = currentValue !== null && currentValue !== void 0 ? currentValue : [];\n currentValue.push(value);\n }\n return currentValue;\n }\n });\n}\n\nArray$1();\n"; 2 | -------------------------------------------------------------------------------- /sources/advanced/options/Counter.ts: -------------------------------------------------------------------------------- 1 | import {CommandOptionReturn, GeneralOptionFlags, makeCommandOption, rerouteArguments} from "./utils"; 2 | 3 | export type CounterFlags = GeneralOptionFlags; 4 | 5 | /** 6 | * Used to annotate options whose repeated values are aggregated into a 7 | * single number. 8 | * 9 | * @example 10 | * -vvvvv 11 | * ► {"v": 5} 12 | */ 13 | export function Counter(descriptor: string, opts: CounterFlags & {required: true}): CommandOptionReturn; 14 | export function Counter(descriptor: string, opts?: CounterFlags): CommandOptionReturn; 15 | export function Counter(descriptor: string, initialValue: number, opts?: Omit): CommandOptionReturn; 16 | export function Counter(descriptor: string, initialValueBase: CounterFlags | number | undefined, optsBase?: CounterFlags) { 17 | const [initialValue, opts] = rerouteArguments(initialValueBase, optsBase ?? {}); 18 | 19 | const optNames = descriptor.split(`,`); 20 | const nameSet = new Set(optNames); 21 | 22 | return makeCommandOption({ 23 | definition(builder) { 24 | builder.addOption({ 25 | names: optNames, 26 | 27 | allowBinding: false, 28 | arity: 0, 29 | 30 | hidden: opts.hidden, 31 | description: opts.description, 32 | required: opts.required, 33 | }); 34 | }, 35 | 36 | transformer(builder, key, state) { 37 | let currentValue = initialValue; 38 | 39 | for (const {name, value} of state.options) { 40 | if (!nameSet.has(name)) 41 | continue; 42 | 43 | currentValue ??= 0; 44 | 45 | // Negated options reset the counter 46 | if (!value) { 47 | currentValue = 0; 48 | } else { 49 | currentValue += 1; 50 | } 51 | } 52 | 53 | return currentValue; 54 | }, 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /sources/platform/node.ts: -------------------------------------------------------------------------------- 1 | import {AsyncLocalStorage} from 'async_hooks'; 2 | import tty from 'tty'; 3 | 4 | import {BaseContext} from '../advanced/Cli'; 5 | 6 | export function getDefaultColorDepth() { 7 | if (tty && `getColorDepth` in tty.WriteStream.prototype) 8 | return tty.WriteStream.prototype.getColorDepth(); 9 | 10 | if (process.env.FORCE_COLOR === `0`) 11 | return 1; 12 | if (process.env.FORCE_COLOR === `1`) 13 | return 8; 14 | 15 | if (typeof process.stdout !== `undefined` && process.stdout.isTTY) 16 | return 8; 17 | 18 | return 1; 19 | } 20 | 21 | let gContextStorage: AsyncLocalStorage | undefined; 22 | 23 | export function getCaptureActivator(context: BaseContext) { 24 | let contextStorage = gContextStorage; 25 | if (typeof contextStorage === `undefined`) { 26 | if (context.stdout === process.stdout && context.stderr === process.stderr) 27 | return null; 28 | 29 | const {AsyncLocalStorage: LazyAsyncLocalStorage} = require(`async_hooks`); 30 | contextStorage = gContextStorage = new LazyAsyncLocalStorage(); 31 | 32 | const origStdoutWrite = process.stdout._write; 33 | process.stdout._write = function (chunk, encoding, cb) { 34 | const context = contextStorage!.getStore(); 35 | if (typeof context === `undefined`) 36 | return origStdoutWrite.call(this, chunk, encoding, cb); 37 | 38 | return context.stdout.write(chunk, encoding, cb); 39 | }; 40 | 41 | const origStderrWrite = process.stderr._write; 42 | process.stderr._write = function (chunk, encoding, cb) { 43 | const context = contextStorage!.getStore(); 44 | if (typeof context === `undefined`) 45 | return origStderrWrite.call(this, chunk, encoding, cb); 46 | 47 | return context.stderr.write(chunk, encoding, cb); 48 | }; 49 | } 50 | 51 | return (fn: () => Promise) => { 52 | return contextStorage!.run(context, fn); 53 | }; 54 | } 55 | 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@blitz/clipanion", 3 | "description": "Type-safe CLI library / framework with no runtime dependencies", 4 | "homepage": "https://mael.dev/clipanion/", 5 | "keywords": [ 6 | "cli", 7 | "typescript", 8 | "parser", 9 | "parsing", 10 | "argument", 11 | "args", 12 | "option", 13 | "command" 14 | ], 15 | "version": "3.2.0-rc.14", 16 | "main": "lib/advanced/index", 17 | "license": "MIT", 18 | "sideEffects": false, 19 | "repository": { 20 | "url": "https://github.com/arcanis/clipanion", 21 | "type": "git" 22 | }, 23 | "dependencies": { 24 | "typanion": "^3.8.0" 25 | }, 26 | "peerDependencies": { 27 | "typanion": "*" 28 | }, 29 | "devDependencies": { 30 | "@rollup/plugin-node-resolve": "^10.0.0", 31 | "@rollup/plugin-typescript": "^6.1.0", 32 | "@types/chai": "^4.2.11", 33 | "@types/chai-as-promised": "^7.1.2", 34 | "@types/lodash": "^4.14.179", 35 | "@types/mocha": "^7.0.2", 36 | "@types/node": "^14.0.13", 37 | "@typescript-eslint/eslint-plugin": "^4.11.1", 38 | "@typescript-eslint/parser": "^4.11.1", 39 | "chai": "^4.2.0", 40 | "chai-as-promised": "^7.1.1", 41 | "eslint": "^7.16.0", 42 | "eslint-plugin-arca": "^0.10.0", 43 | "eslint-plugin-react": "^7.21.5", 44 | "get-stream": "^5.1.0", 45 | "lodash": "^4.17.21", 46 | "mocha": "^8.0.1", 47 | "rollup": "^2.16.1", 48 | "rollup-plugin-copy": "^3.4.0", 49 | "rollup-plugin-multi-input": "^1.3.1", 50 | "ts-node": "^8.10.2", 51 | "tslib": "^2.0.0", 52 | "typescript": "^4.1.2" 53 | }, 54 | "scripts": { 55 | "prepack": "rm -rf lib && rollup -c", 56 | "postpack": "rm -rf lib", 57 | "test": "FORCE_COLOR=1 mocha --require ts-node/register --extension ts tests/specs", 58 | "lint": "eslint --max-warnings 0 .", 59 | "demo": "node --require ts-node/register demos/advanced.ts" 60 | }, 61 | "files": [ 62 | "lib" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /sources/advanced/HelpCommand.ts: -------------------------------------------------------------------------------- 1 | import {RunState} from '../core'; 2 | 3 | import {BaseContext, CliContext} from './Cli'; 4 | import {Command} from './Command'; 5 | 6 | export class HelpCommand extends Command { 7 | private commands: Array = []; 8 | private index?: number; 9 | 10 | static from(state: RunState, contexts: Array>) { 11 | const command = new HelpCommand(contexts); 12 | command.path = state.path; 13 | 14 | for (const opt of state.options) { 15 | switch (opt.name) { 16 | case `-c`: { 17 | command.commands.push(Number(opt.value)); 18 | } break; 19 | case `-i`: { 20 | command.index = Number(opt.value); 21 | } break; 22 | } 23 | } 24 | 25 | return command; 26 | } 27 | 28 | constructor(private readonly contexts: Array>) { 29 | super(); 30 | } 31 | 32 | async execute() { 33 | let commands = this.commands; 34 | if (typeof this.index !== `undefined` && this.index >= 0 && this.index < commands.length) 35 | commands = [commands[this.index]]; 36 | 37 | if (commands.length === 0) { 38 | this.context.stdout.write(this.cli.usage()); 39 | } else if (commands.length === 1) { 40 | this.context.stdout.write(this.cli.usage(this.contexts[commands[0]].commandClass, {detailed: true})); 41 | } else if (commands.length > 1) { 42 | this.context.stdout.write(`Multiple commands match your selection:\n`); 43 | this.context.stdout.write(`\n`); 44 | 45 | let index = 0; 46 | for (const command of this.commands) 47 | this.context.stdout.write(this.cli.usage(this.contexts[command].commandClass, {prefix: `${index++}. `.padStart(5)})); 48 | 49 | this.context.stdout.write(`\n`); 50 | this.context.stdout.write(`Run again with -h= to see the longer details of any of those commands.\n`); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/specs/browser.test.ts: -------------------------------------------------------------------------------- 1 | import {nodeResolve} from '@rollup/plugin-node-resolve'; 2 | import {execUtils} from '@yarnpkg/core'; 3 | import {xfs, PortablePath, npath} from '@yarnpkg/fslib'; 4 | import {rollup} from 'rollup'; 5 | 6 | import {expect} from '../expect'; 7 | 8 | describe(`Browser support`, () => { 9 | it(`should only keep the command Options used in the bundle`, async function () { 10 | this.timeout(20000); 11 | 12 | await xfs.mktempPromise(async tempDir => { 13 | const rndName = Math.floor(Math.random() * 100000000).toString(16).padStart(8, `0`); 14 | 15 | const packed = await execUtils.execvp(`yarn`, [`pack`, `--out`, npath.fromPortablePath(`${tempDir}/${rndName}.tgz`)], { 16 | cwd: npath.toPortablePath(__dirname), 17 | }); 18 | 19 | expect(packed.code).to.eq(0); 20 | 21 | await xfs.writeJsonPromise(`${tempDir}/package.json` as PortablePath, {name: `test-treeshake`}); 22 | await xfs.writeFilePromise(`${tempDir}/yarn.lock` as PortablePath, ``); 23 | 24 | const added = await execUtils.execvp(`yarn`, [`add`, `./${rndName}.tgz`], {cwd: tempDir}); 25 | expect(added.code).to.eq(0); 26 | 27 | await xfs.writeFilePromise(`${tempDir}/index.js` as PortablePath, `import { Cli } from 'clipanion';\n`); 28 | 29 | const warnings: Array = []; 30 | 31 | await rollup({ 32 | input: npath.fromPortablePath(`${tempDir}/index.js`), 33 | plugins: [nodeResolve({preferBuiltins: true, browser: true})], 34 | onwarn: warning => warnings.push(warning), 35 | }); 36 | 37 | expect(warnings).to.deep.equal([]); 38 | 39 | await rollup({ 40 | input: npath.fromPortablePath(`${tempDir}/index.js`), 41 | plugins: [nodeResolve({preferBuiltins: true})], 42 | onwarn: warning => warnings.push(warning), 43 | }); 44 | 45 | expect(warnings).to.have.length(1); 46 | expect(warnings).to.have.nested.property(`[0].code`, `UNRESOLVED_IMPORT`); 47 | expect(warnings).to.have.nested.property(`[0].source`, `tty`); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /website/docs/contexts.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: contexts 3 | title: Execution Contexts 4 | --- 5 | 6 | In Clipanion commands have what we call a *context*. Under this fancy word is simply an arbitrary object that we provide to all commands via `this.context` during their execution. The default context is fairly simple: 7 | 8 | ```ts twoslash 9 | import type {Readable, Writable} from 'stream'; 10 | 11 | interface BaseContext { 12 | env: Record; 13 | stdin: Readable; 14 | stdout: Writable; 15 | stderr: Writable; 16 | colorDepth: number; 17 | } 18 | ``` 19 | 20 | You can define your own contexts (that extend the default one) to pass more complex environment options such as the `cwd`, or the user auth token, or the configuration, or ... 21 | 22 | :::info 23 | You may wonder why we keep streams in the context in the first place, rather than just use the classic `console.log` family of functions. This is because this way commands can easily intercept the output of other commands (for instance to capture their result in a buffer), which allows for better composition. 24 | ::: 25 | 26 | :::tip 27 | If you'd prefer for this to be automatically handled by Clipanion so that all writes to the `console.log` family are captured and forwarded to the right streams, add `enableCapture: true` to your CLI configuration. It even supports proper routing when multiple commands run in parallel! 28 | ::: 29 | 30 | ## Context Switches 31 | 32 | When calling the `this.cli` API, the `run` function takes a *partial* context object - contrary to the usual `cli.run` and `cli.runExit` functions, which require a full context. This partial context will then be applied on top of the current one, so forwarded commands will automatically inherit the context you don't override. 33 | 34 | ```ts twoslash 35 | import {Command} from 'clipanion'; 36 | // ---cut--- 37 | import {PassThrough} from 'stream'; 38 | 39 | class BufferCommand extends Command { 40 | async execute() { 41 | const passThrough = new PassThrough(); 42 | 43 | await this.cli.run([`other-command`], { 44 | stdout: passThrough, 45 | }); 46 | } 47 | } 48 | ``` 49 | -------------------------------------------------------------------------------- /tests/specs/treeshake.test.ts: -------------------------------------------------------------------------------- 1 | import {nodeResolve} from '@rollup/plugin-node-resolve'; 2 | import {execUtils} from '@yarnpkg/core'; 3 | import {xfs, PortablePath, npath} from '@yarnpkg/fslib'; 4 | import {rollup} from 'rollup'; 5 | 6 | import {expect} from '../expect'; 7 | 8 | describe(`Tree shaking`, () => { 9 | it(`should only keep the command Options used in the bundle`, async function () { 10 | this.timeout(20000); 11 | 12 | await xfs.mktempPromise(async tempDir => { 13 | const rndName = Math.floor(Math.random() * 100000000).toString(16).padStart(8, `0`); 14 | 15 | const packed = await execUtils.execvp(`yarn`, [`pack`, `--out`, npath.fromPortablePath(`${tempDir}/${rndName}.tgz`)], { 16 | cwd: npath.toPortablePath(__dirname), 17 | }); 18 | 19 | expect(packed.code).to.eq(0); 20 | 21 | await xfs.writeJsonPromise(`${tempDir}/package.json` as PortablePath, {name: `test-treeshake`}); 22 | await xfs.writeFilePromise(`${tempDir}/yarn.lock` as PortablePath, ``); 23 | 24 | const added = await execUtils.execvp(`yarn`, [`add`, `./${rndName}.tgz`], {cwd: tempDir}); 25 | expect(added.code).to.eq(0); 26 | 27 | const buildCode = async (code: string) => { 28 | await xfs.writeFilePromise(`${tempDir}/index.js` as PortablePath, code); 29 | 30 | const result = await rollup({ 31 | input: npath.fromPortablePath(`${tempDir}/index.js`), 32 | plugins: [nodeResolve({preferBuiltins: true})], 33 | external: [`tty`], 34 | }); 35 | 36 | const {output} = await result.generate({format: `esm`}); 37 | return output[0].code; 38 | }; 39 | 40 | const singleCode = await buildCode(`import { Option } from 'clipanion';\nOption.Array();\n`); 41 | const multiCode = await buildCode(`import { Option } from 'clipanion';\nOption.Counter();\nOption.Array();\n`); 42 | 43 | // We expect the output when referencing multiple symbols to be quite a 44 | // bit more than the number of extra characters (with some buffer to 45 | // account with the transpilation overhead) 46 | expect(multiCode.length).to.be.greaterThan(singleCode.length + 20); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /sources/advanced/options/Array.ts: -------------------------------------------------------------------------------- 1 | import {StrictValidator} from "typanion"; 2 | 3 | import {applyValidator, GeneralOptionFlags, CommandOptionReturn, rerouteArguments, makeCommandOption, WithArity} from "./utils"; 4 | 5 | export type ArrayFlags = GeneralOptionFlags & { 6 | arity?: Arity, 7 | validator?: StrictValidator>, 8 | }; 9 | 10 | /** 11 | * Used to annotate array options. Such options will be strings unless they 12 | * are provided a schema, which will then be used for coercion. 13 | * 14 | * @example 15 | * --foo hello --foo bar 16 | * ► {"foo": ["hello", "world"]} 17 | */ 18 | export function Array(descriptor: string, opts: ArrayFlags & {required: true}): CommandOptionReturn>>; 19 | export function Array(descriptor: string, opts?: ArrayFlags): CommandOptionReturn> | undefined>; 20 | export function Array(descriptor: string, initialValue: Array>, opts?: Omit, 'required'>): CommandOptionReturn>>; 21 | export function Array(descriptor: string, initialValueBase: ArrayFlags | Array> | undefined, optsBase?: ArrayFlags) { 22 | const [initialValue, opts] = rerouteArguments(initialValueBase, optsBase ?? {}); 23 | const {arity = 1} = opts; 24 | 25 | const optNames = descriptor.split(`,`); 26 | const nameSet = new Set(optNames); 27 | 28 | return makeCommandOption({ 29 | definition(builder) { 30 | builder.addOption({ 31 | names: optNames, 32 | 33 | arity, 34 | 35 | hidden: opts?.hidden, 36 | description: opts?.description, 37 | required: opts.required, 38 | }); 39 | }, 40 | 41 | transformer(builder, key, state) { 42 | let usedName; 43 | let currentValue = typeof initialValue !== `undefined` 44 | ? [...initialValue] 45 | : undefined; 46 | 47 | for (const {name, value} of state.options) { 48 | if (!nameSet.has(name)) 49 | continue; 50 | 51 | usedName = name; 52 | currentValue = currentValue ?? []; 53 | currentValue.push(value); 54 | } 55 | 56 | if (typeof currentValue !== `undefined`) { 57 | return applyValidator(usedName ?? key, currentValue, opts.validator); 58 | } else { 59 | return currentValue; 60 | } 61 | }, 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /website/docs/paths.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: paths 3 | title: Command Paths 4 | --- 5 | 6 | By default Clipanion mounts all commands as top-level, meaning that they are selected for execution from the very first token in the command line. This is typically what you want if you build a Posix-style tool, like `cp` or `curl`, but not when building a CLI application, like `yarn` or `react-native`. 7 | 8 | To help with that, Clipanion supports giving each command one or multiple *paths*. A path is a list of fixed strings that must be found in order for the command to be selected as an execution candidate. Paths are declared using the static `paths` property from each command class: 9 | 10 | ```ts twoslash 11 | import {Command} from 'clipanion'; 12 | // ---cut--- 13 | class InstallCommand extends Command { 14 | static paths = [[`install`], [`i`]]; 15 | async execute() { 16 | // ... 17 | } 18 | } 19 | ``` 20 | 21 | In the example above, we declared a command that accepts any of two paths: `install`, or `i`. If we wanted the command to also trigger when *no* path is set (just like the default behavior), we'd use the `Command.Default` special path: 22 | 23 | ```ts 24 | import {Command} from 'clipanion'; 25 | // ---cut--- 26 | class InstallCommand extends Command { 27 | static paths = [[`install`], [`i`], Command.Default]; 28 | async execute() { 29 | // ... 30 | } 31 | } 32 | ``` 33 | 34 | :::tip 35 | You could also use an empty array instead of the `Command.Default` helper, but using the provided symbol is a good way to clearly signal to the reader that a command is also an entry point. 36 | ::: 37 | 38 | ## Path Overlaps 39 | 40 | It's possible for a path to overlap another one, as long as they aren't strictly identical: 41 | 42 | ```ts 43 | import {Command} from 'clipanion'; 44 | // ---cut--- 45 | class FooCommand extends Command { 46 | static paths = [[`foo`]]; 47 | async execute() { 48 | // ... 49 | } 50 | } 51 | 52 | class FooBarCommand extends Command { 53 | static paths = [[`foo`, `bar`]]; 54 | async execute() { 55 | // ... 56 | } 57 | } 58 | ``` 59 | 60 | Clipanion will property execute `FooBarCommand` when running `foo bar`, and `FooCommand` when running just `foo`. You can even declare positional arguments on `FooCommand` if you want, which will get picked up as long as they don't match `bar`! 61 | 62 | :::tip 63 | You can even have multiple commands that have identical paths but different options, as long as the user specifies an option unique to one of them (or doesn't if the option is required) when invoking the command! The only caveat is that if they don't Clipanion won't know which version to use and will throw a `AmbiguousSyntaxError`. 64 | ::: 65 | -------------------------------------------------------------------------------- /website/docs/validation.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: validation 3 | title: Command Validation 4 | --- 5 | 6 | While static types go a long way towards stable CLIs, you often need a tighter control on the parameters your users will feed to your command line. For this reason, Clipanion provides an automatic (and optional) integration with [Typanion](https://github.com/arcanis/typanion), a library providing both static and runtime input validations and coercions. 7 | 8 | ## Validating Options 9 | 10 | The `Option.String` declarator accepts a `validator` option. You can use it with the Clipanion predicates to enforce a specific shape for your option: 11 | 12 | ```ts twoslash 13 | import {Command, Option} from 'clipanion'; 14 | // ---cut--- 15 | import * as t from 'typanion'; 16 | 17 | class PowerCommand extends Command { 18 | a = Option.String({validator: t.isNumber()}); 19 | b = Option.String({validator: t.isNumber()}); 20 | 21 | async execute() { 22 | this.context.stdout.write(`${this.a ** this.b}\n`); 23 | } 24 | } 25 | ``` 26 | 27 | As you can see by hovering them, TypeScript correctly inferred that both `this.a` and `this.b` are numbers (coercing them from their original strings), and passing anything else at runtime will now trigger validation errors. You can apply additional rules by using the Typanion predicates, for example here to validate that something is a valid port: 28 | 29 | 30 | ```ts twoslash 31 | import {Command, Option} from 'clipanion'; 32 | // ---cut--- 33 | import * as t from 'typanion'; 34 | 35 | const isPort = t.applyCascade(t.isNumber(), [ 36 | t.isInteger(), 37 | t.isInInclusiveRange(1, 65535), 38 | ]); 39 | 40 | class ServeCommand extends Command { 41 | port = Option.String({validator: isPort}); 42 | 43 | async execute() { 44 | this.context.stdout.write(`Listening on ${this.port}\n`); 45 | } 46 | } 47 | ``` 48 | 49 | ## Validating Commands 50 | 51 | While option-level validation is typically enough, in some cases you need to also enforce constraints in your application about the final shape. For instance, imagine a command where `--foo` cannot be used if `--bar` is used. For this kind of requirements, you can leverage the `static schema` declaration: 52 | 53 | ```ts twoslash 54 | import {Command, Option} from 'clipanion'; 55 | // ---cut--- 56 | import * as t from 'typanion'; 57 | 58 | class MyCommand extends Command { 59 | foo = Option.Boolean(`--foo`, false); 60 | bar = Option.Boolean(`--bar`, false); 61 | 62 | static schema = [ 63 | t.hasMutuallyExclusiveKeys([`foo`, `bar`]), 64 | ]; 65 | 66 | async execute() { 67 | // ... 68 | } 69 | } 70 | ``` 71 | 72 | This schema will be run before executing the command, and will ensure that if any of `foo` and `bar` is true, then the other necessarily isn't. 73 | 74 | Note however that `schema` doesn't contribute to the type inference, so checking whether one value is set won't magically refine the type for the other values. 75 | -------------------------------------------------------------------------------- /website/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: getting-started 3 | title: Getting Started 4 | --- 5 | 6 | ## Installation 7 | 8 | Add Clipanion to your project using Yarn: 9 | 10 | ```bash 11 | yarn add clipanion 12 | ``` 13 | 14 | ## Your first command 15 | 16 | Create a command file, let's say `HelloCommand.ts` (we'll be using TypeScript here, but it's mostly the same if you use regular JavaScript, perhaps with the exception of the import style): 17 | 18 | ```ts twoslash 19 | import {Command, Option} from 'clipanion'; 20 | 21 | export class HelloCommand extends Command { 22 | name = Option.String(); 23 | 24 | async execute() { 25 | this.context.stdout.write(`Hello ${this.name}!\n`); 26 | } 27 | } 28 | ``` 29 | 30 | That's it for your first command. You just have declare a class, extend from the base `Command` class, declare your options as regular properties, and implement an `async execute` member function. 31 | 32 | :::info 33 | Note that `execute` is using `this.context.stdout.write` rather than `console.log`. While optional, keeping this convention can be seen as a good practice, allowing commands to call each other while buffering their outputs. 34 | ::: 35 | 36 | ## Execute your CLI 37 | 38 | Commands can be called by passing them to the `runExit` function: 39 | 40 | ```ts twoslash 41 | import {Command, Option, runExit} from 'clipanion'; 42 | 43 | runExit(class HelloCommand extends Command { 44 | name = Option.String(); 45 | 46 | async execute() { 47 | this.context.stdout.write(`Hello ${this.name}!\n`); 48 | } 49 | }); 50 | ``` 51 | 52 | Alternatively, you can construct the CLI instance itself, which is what `runExit` does: 53 | 54 | ```ts twoslash 55 | import {Command} from 'clipanion'; 56 | 57 | class HelloCommand extends Command { 58 | async execute() {} 59 | } 60 | 61 | // ---cut--- 62 | 63 | import {Cli} from 'clipanion'; 64 | 65 | const [node, app, ...args] = process.argv; 66 | 67 | const cli = new Cli({ 68 | binaryLabel: `My Application`, 69 | binaryName: `${node} ${app}`, 70 | binaryVersion: `1.0.0`, 71 | }) 72 | 73 | cli.register(HelloCommand); 74 | cli.runExit(args); 75 | ``` 76 | 77 | ## Registering multiple commands 78 | 79 | You can add multiple commands to your CLI by giving them different `paths` static properties. For example: 80 | 81 | ```ts twoslash 82 | import {Command, Option, runExit} from 'clipanion'; 83 | 84 | runExit([ 85 | class AddCommand extends Command { 86 | static paths = [[`add`]]; 87 | 88 | name = Option.String(); 89 | 90 | async execute() { 91 | this.context.stdout.write(`Adding ${this.name}!\n`); 92 | } 93 | }, 94 | 95 | class RemoveCommand extends Command { 96 | static paths = [[`remove`]]; 97 | 98 | name = Option.String(); 99 | 100 | async execute() { 101 | this.context.stdout.write(`Removing ${this.name}!\n`); 102 | } 103 | }, 104 | ]); 105 | ``` -------------------------------------------------------------------------------- /tests/specs/run.test.ts: -------------------------------------------------------------------------------- 1 | import {Command, Option, run} from '../../sources/advanced'; 2 | import {expect} from '../expect'; 3 | import {log, useContext} from '../tools'; 4 | 5 | class TestCommand extends Command { 6 | argv = Option.String(`--argv`); 7 | env = Option.String(`--env`, {env: `ENV`}); 8 | 9 | async execute() { 10 | this.context.stdout.write(`${this.cli.binaryName}\n`); 11 | log(this, [`argv`, `env`]); 12 | } 13 | } 14 | 15 | describe(`Advanced`, () => { 16 | describe(`Helpers`, () => { 17 | describe(`run`, () => { 18 | for (const opts of [true, false]) { 19 | for (const arrayCommand of [true, false]) { 20 | for (const argv of [true, false]) { 21 | for (const context of [true, false]) { 22 | const name: Array = []; 23 | 24 | const expectation: Array = []; 25 | const parameters: Array = []; 26 | 27 | expectation.push(`${opts ? `MyBin` : `...`}\n`); 28 | if (opts) { 29 | parameters.push({binaryName: `MyBin`}); 30 | name.push(`opts`); 31 | } 32 | 33 | expectation.push(`Running TestCommand\n`); 34 | parameters.push(arrayCommand ? [TestCommand] : TestCommand); 35 | name.push(arrayCommand ? `command array` : `command`); 36 | 37 | expectation.push(`${argv ? `"argv"` : `undefined`}\n`); 38 | if (argv) { 39 | parameters.push([`--argv=argv`]); 40 | name.push(`argv`); 41 | } 42 | 43 | expectation.push(`${context ? `"ENV"` : `undefined`}\n`); 44 | if (context) { 45 | parameters.push({env: {[`ENV`]: `ENV`}}); 46 | name.push(`context`); 47 | } 48 | 49 | it(`should work with ${name.join(` / `)}`, async () => { 50 | await expect(useContext(async context => { 51 | const argv = process.argv; 52 | const env = process.env; 53 | 54 | process.argv = [`node`, `example.js`]; 55 | process.env = {}; 56 | 57 | const stdoutWrite = process.stdout.write; 58 | const stderrWrite = process.stderr.write; 59 | 60 | process.stdout.write = (...args: Array) => context.stdout!.write.apply(context.stdout!, args as any); 61 | process.stderr.write = (...args: Array) => context.stderr!.write.apply(context.stderr!, args as any); 62 | 63 | try { 64 | return await run.apply(null, parameters as any); 65 | } finally { 66 | process.argv = argv; 67 | process.env = env; 68 | 69 | process.stdout.write = stdoutWrite; 70 | process.stderr.write = stderrWrite; 71 | } 72 | })).to.eventually.equal(expectation.join(``)); 73 | }); 74 | } 75 | } 76 | } 77 | } 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /website/docs/help.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: help 3 | title: Help command 4 | --- 5 | 6 | Clipanion includes tools to allow documenting and adding a help functionality easily. 7 | 8 | ## The `usage` property 9 | 10 | Commands may define a `usage` static property that will be used to document the command. If defined, it must be an object with any of the following fields: 11 | 12 | - `category` will be used to group commands in the global help listing 13 | - `description` is a one-line description used in the global help listing 14 | - `details` is a large description of your command, with paragraphs separated with `\n\n` 15 | - `examples` is an array of `[description, command]` tuple 16 | 17 | Note that all commands are hidden from the global help listing by default unless they define a `usage` property. 18 | 19 | ```ts twoslash 20 | import {Cli, Command, Option} from 'clipanion'; 21 | 22 | export class HelloCommand extends Command { 23 | static paths = [ 24 | [`my-command`], 25 | ]; 26 | 27 | static usage = Command.Usage({ 28 | category: `My category`, 29 | description: `A small description of the command.`, 30 | details: ` 31 | A longer description of the command with some \`markdown code\`. 32 | 33 | Multiple paragraphs are allowed. Clipanion will take care of both reindenting the content and wrapping the paragraphs as needed. 34 | `, 35 | examples: [[ 36 | `A basic example`, 37 | `$0 my-command`, 38 | ], [ 39 | `A second example`, 40 | `$0 my-command --with-parameter`, 41 | ]], 42 | }); 43 | 44 | p = Option.Boolean(`--with-parameter`); 45 | 46 | async execute() { 47 | this.context.stdout.write( 48 | this.p ? `Called with parameter` : `Called without parameter` 49 | ); 50 | } 51 | } 52 | ``` 53 | 54 | ``` 55 | $ my-app my-command -h 56 | ``` 57 | 58 | ``` 59 | ━━━ Usage ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 60 | 61 | $ my-app my-command [--with-parameter] 62 | 63 | ━━━ Details ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 64 | 65 | A longer description of the command with some \`markdown code\`. 66 | 67 | Multiple paragraphs are allowed. Clipanion will take care of both reindenting the 68 | content and wrapping the paragraphs as needed. 69 | 70 | ━━━ Examples ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 71 | 72 | A basic example 73 | $ my-app my-command 74 | 75 | A second example 76 | $ my-app my-command --with-parameter 77 | ``` 78 | 79 | ## The `help` command 80 | 81 | The builtin `help` command prints the list of available commands. To add it, simply import and register it: 82 | 83 | ```ts 84 | import {Cli, Builtins} from "clipanion"; 85 | 86 | const cli = new Cli({ 87 | binaryName: `my-app`, 88 | binaryLabel: `My Application`, 89 | binaryVersion: `1.0.0`, 90 | }); 91 | 92 | cli.register(Builtins.HelpCommand); 93 | ``` 94 | 95 | ``` 96 | $ my-app help 97 | ``` 98 | 99 | ``` 100 | ━━━ My Application - 1.0.0 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 101 | 102 | $ my-app 103 | 104 | ━━━ My category ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 105 | 106 | my-app my-command [--with-parameter] 107 | A small description of the command. 108 | ``` 109 | -------------------------------------------------------------------------------- /sources/errors.ts: -------------------------------------------------------------------------------- 1 | import {END_OF_INPUT} from './constants'; 2 | 3 | export type ErrorMeta = { 4 | type: `none`; 5 | } | { 6 | type: `usage`; 7 | }; 8 | 9 | /** 10 | * An error with metadata telling clipanion how to print it 11 | * 12 | * Errors with this metadata property will have their name and message printed, but not the 13 | * stacktrace. 14 | * 15 | * This should be used for errors where the message is the part that's important but the stacktrace is useless. 16 | * Some examples of where this might be useful are: 17 | * 18 | * - Invalid input by the user (see `UsageError`) 19 | * - A HTTP connection fails, the user is shown "Failed To Fetch Data: Could not connect to server example.com" without stacktrace 20 | * - A command in which the user enters credentials doesn't want to show a stacktract when the user enters invalid credentials 21 | * - ... 22 | */ 23 | export interface ErrorWithMeta extends Error { 24 | /** 25 | * Metadata detailing how clipanion should print this error 26 | */ 27 | readonly clipanion: ErrorMeta; 28 | } 29 | 30 | /** 31 | * A generic usage error with the name `UsageError`. 32 | * 33 | * It should be used over `Error` only when it's the user's fault. 34 | */ 35 | export class UsageError extends Error { 36 | public clipanion: ErrorMeta = {type: `usage`}; 37 | 38 | constructor(message: string) { 39 | super(message); 40 | this.name = `UsageError`; 41 | } 42 | } 43 | 44 | export class UnknownSyntaxError extends Error { 45 | public clipanion: ErrorMeta = {type: `none`}; 46 | 47 | constructor(public readonly input: Array, public readonly candidates: Array<{usage: string, reason: string | null}>) { 48 | super(); 49 | this.name = `UnknownSyntaxError`; 50 | 51 | if (this.candidates.length === 0) { 52 | this.message = `Command not found, but we're not sure what's the alternative.`; 53 | } else if (this.candidates.every(candidate => candidate.reason !== null && candidate.reason === candidates[0].reason) ) { 54 | const [{reason}] = this.candidates; 55 | 56 | this.message = `${reason}\n\n${this.candidates.map(({usage}) => `$ ${usage}`).join(`\n`)}`; 57 | } else if (this.candidates.length === 1) { 58 | const [{usage}] = this.candidates; 59 | this.message = `Command not found; did you mean:\n\n$ ${usage}\n${whileRunning(input)}`; 60 | } else { 61 | this.message = `Command not found; did you mean one of:\n\n${this.candidates.map(({usage}, index) => { 62 | return `${`${index}.`.padStart(4)} ${usage}`; 63 | }).join(`\n`)}\n\n${whileRunning(input)}`; 64 | } 65 | } 66 | } 67 | 68 | export class AmbiguousSyntaxError extends Error { 69 | public clipanion: ErrorMeta = {type: `none`}; 70 | 71 | constructor(public readonly input: Array, public readonly usages: Array) { 72 | super(); 73 | this.name = `AmbiguousSyntaxError`; 74 | 75 | this.message = `Cannot find which to pick amongst the following alternatives:\n\n${this.usages.map((usage, index) => { 76 | return `${`${index}.`.padStart(4)} ${usage}`; 77 | }).join(`\n`)}\n\n${whileRunning(input)}`; 78 | } 79 | } 80 | 81 | const whileRunning = (input: Array) => `While running ${input.filter(token => { 82 | return token !== END_OF_INPUT; 83 | }).map(token => { 84 | const json = JSON.stringify(token); 85 | if (token.match(/\s/) || token.length === 0 || json !== `"${token}"`) { 86 | return json; 87 | } else { 88 | return token; 89 | } 90 | }).join(` `)}`; 91 | -------------------------------------------------------------------------------- /sources/format.ts: -------------------------------------------------------------------------------- 1 | export interface ColorFormat { 2 | header(str: string): string; 3 | bold(str: string): string; 4 | error(str: string): string; 5 | code(str: string): string; 6 | } 7 | 8 | const MAX_LINE_LENGTH = 80; 9 | const richLine = Array(MAX_LINE_LENGTH).fill(`━`); 10 | for (let t = 0; t <= 24; ++t) 11 | richLine[richLine.length - t] = `\x1b[38;5;${232 + t}m━`; 12 | 13 | export const richFormat: ColorFormat = { 14 | header: str => `\x1b[1m━━━ ${str}${str.length < MAX_LINE_LENGTH - 5 ? ` ${richLine.slice(str.length + 5).join(``)}` : `:`}\x1b[0m`, 15 | bold: str => `\x1b[1m${str}\x1b[22m`, 16 | error: str => `\x1b[31m\x1b[1m${str}\x1b[22m\x1b[39m`, 17 | code: str => `\x1b[36m${str}\x1b[39m`, 18 | }; 19 | 20 | export const textFormat: ColorFormat = { 21 | header: str => str, 22 | bold: str => str, 23 | error: str => str, 24 | code: str => str, 25 | }; 26 | 27 | function dedent(text: string) { 28 | const lines = text.split(`\n`); 29 | const nonEmptyLines = lines.filter(line => line.match(/\S/)); 30 | 31 | const indent = nonEmptyLines.length > 0 ? nonEmptyLines.reduce((minLength, line) => Math.min(minLength, line.length - line.trimStart().length), Number.MAX_VALUE) : 0; 32 | 33 | return lines 34 | .map(line => line.slice(indent).trimRight()) 35 | .join(`\n`); 36 | } 37 | 38 | /** 39 | * Formats markdown text to be displayed to the console. Not all markdown features are supported. 40 | * 41 | * @param text The markdown text to format. 42 | * @param opts.format The format to use. 43 | * @param opts.paragraphs Whether to cut the text into paragraphs of 80 characters at most. 44 | */ 45 | export function formatMarkdownish(text: string, {format, paragraphs}: {format: ColorFormat, paragraphs: boolean}) { 46 | // Enforce \n as newline character 47 | text = text.replace(/\r\n?/g, `\n`); 48 | 49 | // Remove the indentation, since it got messed up with the JS indentation 50 | text = dedent(text); 51 | 52 | // Remove surrounding newlines, since they got added for JS formatting 53 | text = text.replace(/^\n+|\n+$/g, ``); 54 | 55 | // List items always end with at least two newlines (in order to not be collapsed) 56 | text = text.replace(/^(\s*)-([^\n]*?)\n+/gm, `$1-$2\n\n`); 57 | 58 | // Single newlines are removed; larger than that are collapsed into one 59 | text = text.replace(/\n(\n)?\n*/g, ($0, $1) => $1 ? $1 : ` `); 60 | 61 | if (paragraphs) { 62 | text = text.split(/\n/).map(paragraph => { 63 | // Does the paragraph starts with a list? 64 | const bulletMatch = paragraph.match(/^\s*[*-][\t ]+(.*)/); 65 | 66 | if (!bulletMatch) 67 | // No, cut the paragraphs into segments of 80 characters 68 | return paragraph.match(/(.{1,80})(?: |$)/g)!.join(`\n`); 69 | 70 | const indent = paragraph.length - paragraph.trimStart().length; 71 | 72 | // Yes, cut the paragraphs into segments of (78 - indent) characters (to account for the prefix) 73 | return bulletMatch[1].match(new RegExp(`(.{1,${78 - indent}})(?: |$)`, `g`))!.map((line, index) => { 74 | return ` `.repeat(indent) + (index === 0 ? `- ` : ` `) + line; 75 | }).join(`\n`); 76 | }).join(`\n\n`); 77 | } 78 | 79 | // Highlight the code segments 80 | text = text.replace(/(`+)((?:.|[\n])*?)\1/g, ($0, $1, $2) => { 81 | return format.code($1 + $2 + $1); 82 | }); 83 | 84 | // Highlight the bold segments 85 | text = text.replace(/(\*\*)((?:.|[\n])*?)\1/g, ($0, $1, $2) => { 86 | return format.bold($1 + $2 + $1); 87 | }); 88 | 89 | return text ? `${text}\n` : ``; 90 | } 91 | -------------------------------------------------------------------------------- /sources/advanced/options/utils.ts: -------------------------------------------------------------------------------- 1 | import {Coercion, CoercionFn, StrictValidator} from 'typanion'; 2 | 3 | import {CommandBuilder, RunState} from '../../core'; 4 | import {UsageError} from '../../errors'; 5 | import {BaseContext, CliContext} from '../Cli'; 6 | 7 | export const isOptionSymbol = Symbol(`clipanion/isOption`); 8 | 9 | export type GeneralOptionFlags = { 10 | description?: string, 11 | hidden?: boolean, 12 | required?: boolean; 13 | }; 14 | 15 | // https://stackoverflow.com/a/52490977 16 | 17 | export type TupleOf> = Accumulator['length'] extends Arity 18 | ? Accumulator 19 | : TupleOf; 20 | 21 | export type Tuple = Arity extends Arity 22 | ? number extends Arity 23 | ? Array 24 | : TupleOf 25 | : never; 26 | 27 | export type WithArity = 28 | number extends Type['length'] 29 | ? Arity extends 0 30 | ? boolean 31 | : Arity extends 1 32 | ? Type 33 | : number extends Arity 34 | ? boolean | Type | Tuple 35 | : Tuple 36 | : Type; 37 | 38 | export type CommandOption = { 39 | [isOptionSymbol]: true, 40 | definition: (builder: CommandBuilder>, key: string) => void, 41 | transformer: (builder: CommandBuilder>, key: string, state: RunState, context: Context) => T, 42 | }; 43 | 44 | export type CommandOptionReturn = T; 45 | 46 | export function makeCommandOption(spec: Omit, typeof isOptionSymbol>) { 47 | // We lie! But it's for the good cause: the cli engine will turn the specs into proper values after instantiation. 48 | return {...spec, [isOptionSymbol]: true} as any as CommandOptionReturn; 49 | } 50 | 51 | export function rerouteArguments(a: A | B, b: B): [Exclude, B]; 52 | export function rerouteArguments(a: A | B | undefined, b: B): [Exclude | undefined, B]; 53 | export function rerouteArguments(a: A | B | undefined, b: B): [Exclude, B] { 54 | if (typeof a === `undefined`) 55 | return [a, b] as any; 56 | 57 | if (typeof a === `object` && a !== null && !Array.isArray(a)) { 58 | return [undefined, a as B] as any; 59 | } else { 60 | return [a, b] as any; 61 | } 62 | } 63 | 64 | export function cleanValidationError(message: string, {mergeName = false}: {mergeName?: boolean} = {}) { 65 | const match = message.match(/^([^:]+): (.*)$/m); 66 | if (!match) 67 | return `validation failed`; 68 | 69 | let [, path, line] = match; 70 | if (mergeName) 71 | line = line[0].toLowerCase() + line.slice(1); 72 | 73 | line = path !== `.` || !mergeName 74 | ? `${path.replace(/^\.(\[|$)/, `$1`)}: ${line}` 75 | : `: ${line}`; 76 | 77 | return line; 78 | } 79 | 80 | export function formatError(message: string, errors: Array) { 81 | if (errors.length === 1) { 82 | return new UsageError(`${message}${cleanValidationError(errors[0], {mergeName: true})}`); 83 | } else { 84 | return new UsageError(`${message}:\n${errors.map(error => `\n- ${cleanValidationError(error)}`).join(``)}`); 85 | } 86 | } 87 | 88 | export function applyValidator(name: string, value: U, validator?: StrictValidator) { 89 | if (typeof validator === `undefined`) 90 | return value; 91 | 92 | const errors: Array = []; 93 | const coercions: Array = []; 94 | 95 | const coercion: CoercionFn = (v: any) => { 96 | const orig = value; 97 | value = v; 98 | return coercion.bind(null, orig); 99 | }; 100 | 101 | const check = validator(value, {errors, coercions, coercion}); 102 | if (!check) 103 | throw formatError(`Invalid value for ${name}`, errors); 104 | 105 | for (const [, op] of coercions) 106 | op(); 107 | 108 | return value; 109 | } 110 | -------------------------------------------------------------------------------- /website/docs/options.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: options 3 | title: Option Types 4 | --- 5 | 6 | Clipanion supports many different types of options. In most cases both short-style and long-style options are supported, although they each have their own characteristics that slightly affect when they can be used. 7 | 8 | ## Arrays 9 | 10 | Arrays are just string options that support being set multiple times: 11 | 12 | ``` 13 | --email foo@baz --email bar@baz 14 | => Command {"email": ["foo@baz", "bar@baz"]} 15 | ``` 16 | 17 | Just like string options, they also support tuples, so you can declare them such as the following becomes possible: 18 | 19 | ``` 20 | --point x1 y1 --point x2 y2 21 | => Command {"point": [["x1", "y1"], ["x2", "y2"]]} 22 | ``` 23 | 24 | ## Batches 25 | 26 | Batches are a "free" feature, you don't have to specify them explicitly. As long as you configure shorthands to boolean options, you can then reference them in a single argument. For example, assuming that `-p` / `-i` / `-e` are valid boolean options, the following is accepted: 27 | 28 | ``` 29 | -pie 30 | => Command {"p": true, "i": true, "e": true} 31 | ``` 32 | 33 | ## Booleans 34 | 35 | Booleans are the most classic type of option; they are mapped to regular booleans based on their sole presence. 36 | 37 | ``` 38 | --foo 39 | => Command {"point": true} 40 | --no-foo 41 | => Command {"point": false} 42 | ``` 43 | 44 | ## Counters 45 | 46 | Counters are boolean options that keep track of how many times they have been enabled. Passing a `--no-` prefix will reset the counter. 47 | 48 | ``` 49 | -vvvv 50 | => Command {"v": 4} 51 | -vvvv --no-verbose 52 | => Command {"v": 0} 53 | ``` 54 | 55 | ## Positionals 56 | 57 | Positional options don't require any particular tagging, but relying on a strict ordering. They can be made required or not. To accept an arbitrary number of positional arguments, see [Rests](#rests). 58 | 59 | ## Proxies 60 | 61 | Proxies are kinda like rests in that they accept an arbitrary number of positional arguments. The difference is that when encountered, proxies stop any further parsing of the command line. You typically only need proxies if your command is acting as a proxy to another command. 62 | 63 | In the following example, the proxy kicks off once `yarn run foo` has finished being parsed. Without it, a syntax error would be emitted because neither `--hello` nor `--world` are valid options for `yarn run`: 64 | 65 | ``` 66 | yarn run foo --hello --world 67 | => Command {"proxy": ["--hello", "--world"]} 68 | ``` 69 | 70 | ## Rests 71 | 72 | Rests are positional arguments taken to the extreme, as they by default accept an arbitrary amount of data. In the following example everything that follows `yarn add` is aggregated into the rest option: 73 | 74 | ``` 75 | yarn add webpack webpack-cli 76 | => Command {"rest": ["webpack", "webpack-cli"]} 77 | ``` 78 | 79 | Unlike most other CLI frameworks, Clipanion supports positional arguments on either side of the rest option, meaning that you can implement the `cp` command by adding a required positional argument after the rest option: 80 | 81 | ``` 82 | cp src1 src2 src3 dest/ 83 | => Command {"srcs": ["src1", "src2", "src3"], "dest": "dest/"} 84 | ``` 85 | 86 | ## Strings 87 | 88 | String options accept an arbitrary argument. 89 | 90 | ``` 91 | --path /path/to/foo 92 | => Command {"path": "/path/to/foo"} 93 | --path=/path/to/foo 94 | => Command {"path": "/path/to/foo"} 95 | ``` 96 | 97 | By default this argument is mandatory, but it can be made optional by using the `tolerateBoolean` flag. If this flag is set, then the `=` separator is mandatory when passing an argument (since otherwise it'd be ambiguous whether the parameter is intended as an argument or a positional option). 98 | 99 | ``` 100 | --inspect 101 | => Command {"inspect": true} 102 | --inspect=9009 103 | => Command {"inspect": "9009"} 104 | ``` 105 | 106 | Note that Clipanion won't automatically try to deduce the variable types - for instance, in the example above, `--inspect=9009` yields `"9009"` (a string), and not `9009` (a number). To explicitly coerce values, check the page about [validators](validation.md). 107 | -------------------------------------------------------------------------------- /website/docs/tips.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: tips 3 | title: Tips & Tricks 4 | --- 5 | 6 | ## Inheritance 7 | 8 | Because they're just plain old ES6 classes, commands can easily extend each other and inherit options: 9 | 10 | ```ts twoslash 11 | import {Command, Option} from 'clipanion'; 12 | // ---cut--- 13 | abstract class BaseCommand extends Command { 14 | cwd = Option.String(`--cwd`, {hidden: true}); 15 | 16 | abstract execute(): Promise; 17 | } 18 | 19 | class FooCommand extends BaseCommand { 20 | foo = Option.String(`-f,--foo`); 21 | 22 | async execute() { 23 | this.context.stdout.write(`Hello from ${this.cwd ?? process.cwd()}!\n`); 24 | this.context.stdout.write(`This is foo: ${this.foo}.\n`); 25 | } 26 | } 27 | ``` 28 | 29 | Positionals can also be inherited. They will be consumed in order starting from the superclass: 30 | 31 | ```ts twoslash 32 | import {Command, Option} from 'clipanion'; 33 | // ---cut--- 34 | abstract class BaseCommand extends Command { 35 | foo = Option.String(); 36 | 37 | abstract execute(): Promise; 38 | } 39 | 40 | class FooCommand extends BaseCommand { 41 | bar = Option.String(); 42 | 43 | async execute() { 44 | this.context.stdout.write(`This is foo: ${this.foo}.\n`); 45 | this.context.stdout.write(`This is bar: ${this.bar}.\n`); 46 | } 47 | } 48 | ``` 49 | 50 | ``` 51 | hello world 52 | => Command {"foo": "hello", "bar": "world"} 53 | ``` 54 | 55 | ## Adding options to existing commands 56 | 57 | Adding options to existing commands can be achieved via inheritance and required options: 58 | 59 | ```ts twoslash 60 | import {Command, Option} from 'clipanion'; 61 | // ---cut--- 62 | class GreetCommand extends Command { 63 | static paths = [[`greet`]]; 64 | 65 | name = Option.String(); 66 | 67 | greeting = Option.String(`--greeting`, `Hello`); 68 | 69 | async execute(): Promise { 70 | this.context.stdout.write(`${this.greeting} ${this.name}!\n`); 71 | } 72 | } 73 | 74 | class GreetWithReverseCommand extends GreetCommand { 75 | reverse = Option.Boolean(`--reverse`, {required: true}); 76 | 77 | async execute() { 78 | return await this.cli.run([`greet`, this.reverse ? this.name.split(``).reverse().join(``) : this.name, `--greeting`, this.greeting]); 79 | } 80 | } 81 | ``` 82 | 83 | ``` 84 | greet john 85 | => "Hello john!\n" 86 | 87 | greet john --greeting hey 88 | => "hey john!\n" 89 | 90 | greet john --reverse 91 | => "Hello nhoj!\n" 92 | 93 | greet john --greeting hey --reverse 94 | => "hey nhoj!\n" 95 | ``` 96 | 97 | :::danger 98 | To add an option to an existing command, you need to know its `Command` class. This means that if you want to add 2 options by using 2 different commands (e.g. if your application uses different plugins that can register their own commands), you need one of the `Command` classes to extend the other one and not the base. 99 | ::: 100 | 101 | ## Lazy evaluation 102 | 103 | Many commands have the following form: 104 | 105 | ```ts twoslash 106 | import {Command} from 'clipanion'; 107 | // ---cut--- 108 | import {uniqBy} from 'lodash'; 109 | 110 | class MyCommand extends Command { 111 | async execute() { 112 | // ... 113 | } 114 | } 115 | ``` 116 | 117 | While it works just fine, if you have a lot of commands that each have their own sets of dependencies (here `lodash`), the overall startup time may suffer. This is because the `import` statements will always be eagerly evaluated, even if the command doesn't end up being selected for execution. 118 | 119 | To solve this problem you can move your imports inside the body of the `execute` function - thus making sure they'll only be evaluated if actually relevant: 120 | 121 | ```ts twoslash 122 | import {Command} from 'clipanion'; 123 | // ---cut--- 124 | class MyCommand extends Command { 125 | async execute() { 126 | const {uniqBy} = await import(`lodash`); 127 | // ... 128 | } 129 | } 130 | ``` 131 | 132 | This strategy is slightly harder to read, so it may not be necessary in every situation. If you like living on the edge, the [`babel-plugin-lazy-import`](https://github.com/arcanis/babel-plugin-lazy-import) plugin is meant to automatically apply this kind of transformation - although it requires you to run Babel on your sources. 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clipanion 2 | 3 | > Type-safe CLI library with no runtime dependencies 4 | 5 | [![npm version](https://img.shields.io/npm/v/clipanion.svg)](https://yarnpkg.com/package/clipanion) [![Licence](https://img.shields.io/npm/l/clipanion.svg)](https://github.com/arcanis/clipanion#license-mit) [![Yarn](https://img.shields.io/badge/developed%20with-Yarn%202-blue)](https://github.com/yarnpkg/berry) 6 | 7 | ## Installation 8 | 9 | ```sh 10 | yarn add clipanion 11 | ``` 12 | 13 | ## Why 14 | 15 | - Clipanion supports advanced typing mechanisms 16 | - Clipanion supports nested commands (`yarn workspaces list`) 17 | - Clipanion supports transparent option proxying without `--` (for example `yarn dlx eslint --fix`) 18 | - Clipanion supports all option types you could think of (including negations, batches, ...) 19 | - Clipanion offers a [Typanion](https://github.com/arcanis/typanion) integration for increased validation capabilities 20 | - Clipanion generates an optimized state machine out of your commands 21 | - Clipanion generates good-looking help pages out of the box 22 | - Clipanion offers common optional command entries out-of-the-box (e.g. version command, help command) 23 | 24 | Clipanion is used in [Yarn](https://github.com/yarnpkg/berry) with great success. 25 | 26 | ## Documentation 27 | 28 | Check the website for our documentation: [mael.dev/clipanion](https://mael.dev/clipanion/). 29 | 30 | ## Migration 31 | 32 | You can use [`clipanion-v3-codemod`](https://github.com/paul-soporan/clipanion-v3-codemod) to migrate a Clipanion v2 codebase to v3. 33 | 34 | ## Overview 35 | 36 | Commands are declared by extending from the `Command` abstract base class, and more specifically by implementing its `execute` method which will then be called by Clipanion. Whatever exit code it returns will then be set as the exit code for the process: 37 | 38 | ```ts 39 | class SuccessCommand extends Command { 40 | async execute() { 41 | return 0; 42 | } 43 | } 44 | ``` 45 | 46 | Commands can also be exposed via one or many arbitrary paths using the `paths` static property: 47 | 48 | ```ts 49 | class FooCommand extends Command { 50 | static paths = [[`foo`]]; 51 | async execute() { 52 | this.context.stdout.write(`Foo\n`); 53 | } 54 | } 55 | 56 | class BarCommand extends Command { 57 | static paths = [[`bar`]]; 58 | async execute() { 59 | this.context.stdout.write(`Bar\n`); 60 | } 61 | } 62 | ``` 63 | 64 | Options are defined as regular class properties, annotated by the helpers provided in the `Option` namespace. If you use TypeScript, all property types will then be properly inferred with no extra work required: 65 | 66 | ```ts 67 | class HelloCommand extends Command { 68 | // Positional option 69 | name = Option.String(); 70 | 71 | async execute() { 72 | this.context.stdout.write(`Hello ${this.name}!\n`); 73 | } 74 | } 75 | ``` 76 | 77 | Option arguments can be validated and coerced using the [Typanion](https://mael.dev/typanion/) library: 78 | 79 | ```ts 80 | class AddCommand extends Command { 81 | a = Option.String({required: true, validator: t.isNumber()}); 82 | b = Option.String({required: true, validator: t.isNumber()}); 83 | 84 | async execute() { 85 | this.context.stdout.write(`${this.a + this.b}\n`); 86 | } 87 | } 88 | ``` 89 | 90 | ## License (MIT) 91 | 92 | > **Copyright © 2019 Mael Nison** 93 | > 94 | > Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 95 | > 96 | > The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 97 | > 98 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 99 | -------------------------------------------------------------------------------- /sources/advanced/Command.ts: -------------------------------------------------------------------------------- 1 | import {Coercion, LooseTest} from 'typanion'; 2 | 3 | import {BaseContext, MiniCli} from './Cli'; 4 | import {formatError, isOptionSymbol} from './options/utils'; 5 | 6 | /** 7 | * The usage of a Command. 8 | */ 9 | export type Usage = { 10 | /** 11 | * The category of the command. 12 | * 13 | * Included in the detailed usage. 14 | */ 15 | category?: string; 16 | 17 | /** 18 | * The short description of the command, formatted as Markdown. 19 | * 20 | * Included in the detailed usage. 21 | */ 22 | description?: string; 23 | 24 | /** 25 | * The extended details of the command, formatted as Markdown. 26 | * 27 | * Included in the detailed usage. 28 | */ 29 | details?: string; 30 | 31 | /** 32 | * Examples of the command represented as an Array of tuples. 33 | * 34 | * The first element of the tuple represents the description of the example. 35 | * 36 | * The second element of the tuple represents the command of the example. 37 | * If present, the leading `$0` is replaced with `cli.binaryName`. 38 | */ 39 | examples?: Array<[string, string]>; 40 | }; 41 | 42 | /** 43 | * The definition of a Command. 44 | */ 45 | export type Definition = Usage & { 46 | /** 47 | * The path of the command, starting with `cli.binaryName`. 48 | */ 49 | path: string; 50 | 51 | /** 52 | * The detailed usage of the command. 53 | */ 54 | usage: string; 55 | 56 | /** 57 | * The various options registered on the command. 58 | */ 59 | options: Array<{ 60 | definition: string; 61 | description?: string; 62 | required: boolean; 63 | }>; 64 | }; 65 | 66 | export type CommandClass = { 67 | new(): Command; 68 | paths?: Array>; 69 | schema?: Array>; 70 | usage?: Usage; 71 | }; 72 | 73 | /** 74 | * Base abstract class for CLI commands. The main thing to remember is to 75 | * declare an async `execute` member function that will be called when the 76 | * command is invoked from the CLI, and optionally a `paths` property to 77 | * declare the set of paths under which the command should be exposed. 78 | */ 79 | export abstract class Command { 80 | /** 81 | * @deprecated Do not use this; prefer the static `paths` property instead. 82 | */ 83 | paths?: undefined; 84 | 85 | /** 86 | * Defined to prevent a common typo. 87 | */ 88 | static path: never; 89 | 90 | /** 91 | * Paths under which the command should be exposed. 92 | */ 93 | static paths?: Array>; 94 | 95 | /** 96 | * Defines the usage information for the given command. 97 | */ 98 | static Usage(usage: Usage) { 99 | return usage; 100 | } 101 | 102 | /** 103 | * Contains the usage information for the command. If undefined, the 104 | * command will be hidden from the general listing. 105 | */ 106 | static usage?: Usage; 107 | 108 | /** 109 | * Defines a schema to apply before running the `execute` method. The 110 | * schema is expected to be generated by Typanion. 111 | * 112 | * @see https://github.com/arcanis/typanion 113 | */ 114 | static schema?: Array>; 115 | 116 | /** 117 | * Standard function that'll get executed by `Cli#run` and `Cli#runExit`. 118 | * 119 | * Expected to return an exit code or nothing (which Clipanion will treat 120 | * as if 0 had been returned). 121 | */ 122 | abstract execute(): Promise; 123 | 124 | /** 125 | * Standard error handler which will simply rethrow the error. Can be used 126 | * to add custom logic to handle errors from the command or simply return 127 | * the parent class error handling. 128 | */ 129 | async catch(error: any): Promise { 130 | throw error; 131 | } 132 | 133 | /** 134 | * Predefined that will be set to true if `-h,--help` has been used, in 135 | * which case `Command#execute` won't be called. 136 | */ 137 | help: boolean = false; 138 | 139 | /** 140 | * Predefined variable that will be populated with a miniature API that can 141 | * be used to query Clipanion and forward commands. 142 | */ 143 | cli!: MiniCli; 144 | 145 | /** 146 | * Predefined variable that will be populated with the context of the 147 | * application. 148 | */ 149 | context!: Context; 150 | 151 | /** 152 | * Predefined variable that will be populated with the path that got used 153 | * to access the command currently being executed. 154 | */ 155 | path!: Array; 156 | 157 | async validateAndExecute(): Promise { 158 | const commandClass = this.constructor as CommandClass; 159 | const cascade = commandClass.schema; 160 | 161 | if (Array.isArray(cascade)) { 162 | const {isDict, isUnknown, applyCascade} = await import(`typanion`); 163 | const schema = applyCascade(isDict(isUnknown()), cascade); 164 | 165 | const errors: Array = []; 166 | const coercions: Array = []; 167 | 168 | const check = schema(this, {errors, coercions}); 169 | if (!check) 170 | throw formatError(`Invalid option schema`, errors); 171 | 172 | for (const [, op] of coercions) { 173 | op(); 174 | } 175 | } else if (cascade != null) { 176 | throw new Error(`Invalid command schema`); 177 | } 178 | 179 | const exitCode = await this.execute(); 180 | if (typeof exitCode !== `undefined`) { 181 | return exitCode; 182 | } else { 183 | return 0; 184 | } 185 | } 186 | 187 | /** 188 | * Used to detect option definitions. 189 | */ 190 | static isOption: typeof isOptionSymbol = isOptionSymbol; 191 | 192 | /** 193 | * Just an helper to use along with the `paths` fields, to make it 194 | * clearer that a command is the default one. 195 | * 196 | * @example 197 | * class MyCommand extends Command { 198 | * static paths = [Command.Default]; 199 | * } 200 | */ 201 | static Default = []; 202 | } 203 | -------------------------------------------------------------------------------- /demos/advanced.ts: -------------------------------------------------------------------------------- 1 | import * as t from 'typanion'; 2 | 3 | import {Option, Cli, Command, Builtins, BaseContext} from '../sources/advanced'; 4 | 5 | type Context = BaseContext & { 6 | cwd: string; 7 | }; 8 | 9 | class YarnDefaultRun extends Command { 10 | scriptName = Option.String(); 11 | rest = Option.Proxy(); 12 | 13 | async execute() { 14 | return await this.cli.run([`run`, this.scriptName, ...this.rest], {}); 15 | } 16 | } 17 | 18 | const isPositiveInteger = t.applyCascade(t.isNumber(), [ 19 | t.isInteger(), 20 | t.isAtLeast(1), 21 | ]); 22 | 23 | class YarnInstall extends Command { 24 | frozenLockfile = Option.Boolean(`--frozen-lockfile`, false); 25 | maxRetries = Option.String(`--max-retries`, `0`, {validator: isPositiveInteger}); 26 | 27 | static paths = [Command.Default, [`install`]]; 28 | async execute() { 29 | this.context.stdout.write(`Running an install: ${this.context.cwd}, with ${this.maxRetries} max retries\n`); 30 | } 31 | } 32 | 33 | class YarnRunListing extends Command { 34 | json = Option.Boolean(`--json`, false); 35 | 36 | static paths = [[`run`]]; 37 | async execute() { 38 | this.context.stdout.write(`Listing all the commands (json = ${this.json})\n`); 39 | } 40 | } 41 | 42 | class YarnRunExec extends Command { 43 | scriptName = Option.String(); 44 | rest = Option.Proxy(); 45 | 46 | static usage = Command.Usage({ 47 | category: `Script-related commands`, 48 | }); 49 | 50 | static paths = [[`run`]]; 51 | async execute() { 52 | this.context.stdout.write(`Executing a script named ${this.scriptName} ${this.rest}\n`); 53 | } 54 | } 55 | 56 | // eslint-disable-next-line arca/no-default-export 57 | export default class YarnAdd extends Command { 58 | dev = Option.Boolean(`-D,--dev`, false, {description: `Use dev mode`}); 59 | peer = Option.Boolean(`-P,--peer`, false, {description: `Use peer mode`}); 60 | 61 | exact = Option.Boolean(`-E,--exact`, false, {description: `Don't add ^ nor ~`}); 62 | tilde = Option.Boolean(`-T,--tilde`, false, {description: `Use ~`}); 63 | caret = Option.Boolean(`-C,--caret`, false, {description: `Use ^`}); 64 | 65 | pkgs = Option.Rest({required: 1}); 66 | 67 | static schema = [ 68 | t.hasMutuallyExclusiveKeys([`dev`, `peer`]), 69 | t.hasMutuallyExclusiveKeys([`exact`, `tilde`, `caret`]), 70 | ]; 71 | 72 | static usage = Command.Usage({ 73 | description: ` 74 | add dependencies to the project 75 | `, 76 | details: ` 77 | This command adds a package to the package.json for the nearest workspace. 78 | 79 | - The package will by default be added to the regular \`dependencies\` field, but this behavior can be overriden thanks to the \`-D,--dev\` flag (which will cause the dependency to be added to the \`devDependencies\` field instead) and the \`-P,--peer\` flag (which will do the same but for \`peerDependencies\`). 80 | - If the added package **doesn't** specify a range at all its \`latest\` tag will be resolved and the returned version will be used to generate a new semver range (using the \`^\` modifier by default, or the \`~\` modifier if \`-T,--tilde\` is specified, or no modifier at all if \`-E,--exact\` is specified). Two exceptions to this rule: the first one is that if the package is a workspace then its local version will be used, and the second one is that if you use \`-P,--peer\` the default range will be \`*\` and won't be resolved at all. 81 | - If the added package specifies a tag range (such as \`latest\` or \`rc\`), Yarn will resolve this tag to a semver version and use that in the resulting package.json entry (meaning that \`yarn add foo@latest\` will have exactly the same effect as \`yarn add foo\`). 82 | 83 | If the \`-i,--interactive\` option is used (or if the \`preferInteractive\` settings is toggled on) the command will first try to check whether other workspaces in the project use the specified package and, if so, will offer to reuse them. 84 | 85 | If the \`--cached\` option is used, Yarn will preferably reuse the highest version already used somewhere within the project, even if through a transitive dependency. 86 | 87 | For a compilation of all the supported protocols, please consult the dedicated page from our website: http://example.org. 88 | `, 89 | examples: [[ 90 | `Add the latest version of a package`, 91 | `$0 add lodash`, 92 | ], [ 93 | `Add a specific version of a package`, 94 | `$0 add lodash@3.0.0`, 95 | ]], 96 | }); 97 | 98 | static paths = [[`add`]]; 99 | async execute() { 100 | if (this.dev) { 101 | this.context.stdout.write(`Adding a dev dependency\n`); 102 | } else if (this.peer) { 103 | this.context.stdout.write(`Adding a peer dependency\n`); 104 | } else { 105 | this.context.stdout.write(`Adding a dependency\n`); 106 | } 107 | } 108 | } 109 | 110 | class YarnRemove extends Command { 111 | packages = Option.Rest(); 112 | 113 | static usage = Command.Usage({ 114 | description: `remove dependencies from the project`, 115 | details: ` 116 | This command will remove the specified packages from the current workspace. If the \`-A,--all\` option is set, the operation will be applied to all workspaces from the current project. 117 | `, 118 | examples: [[ 119 | `Remove a dependency from the current project`, 120 | `$0 remove lodash`, 121 | ], [ 122 | `Remove a dependency from all workspaces at once`, 123 | `$0 remove lodash --all`, 124 | ]], 125 | }); 126 | 127 | static paths = [[`remove`]]; 128 | async execute() { 129 | } 130 | } 131 | 132 | class YarnWorkspacesForeachCommand extends Command { 133 | include = Option.Array(`--include`); 134 | 135 | static paths = [[`workspaces`, `foreach`]]; 136 | async execute() { 137 | } 138 | } 139 | 140 | const cli = new Cli({ 141 | binaryLabel: `Yarn Project Manager`, 142 | binaryName: `yarn`, 143 | binaryVersion: `0.0.0`, 144 | }); 145 | 146 | cli.register(Builtins.DefinitionsCommand); 147 | cli.register(Builtins.HelpCommand); 148 | cli.register(Builtins.VersionCommand); 149 | 150 | cli.register(YarnDefaultRun); 151 | cli.register(YarnInstall); 152 | cli.register(YarnRemove); 153 | cli.register(YarnRunListing); 154 | cli.register(YarnRunExec); 155 | cli.register(YarnAdd); 156 | cli.register(YarnWorkspacesForeachCommand); 157 | 158 | cli.runExit(process.argv.slice(2), { 159 | cwd: process.cwd(), 160 | }); 161 | -------------------------------------------------------------------------------- /sources/advanced/options/String.ts: -------------------------------------------------------------------------------- 1 | import {StrictValidator} from "typanion"; 2 | 3 | import {NoLimits} from "../../core"; 4 | 5 | import {applyValidator, CommandOptionReturn, GeneralOptionFlags, makeCommandOption, rerouteArguments, WithArity} from "./utils"; 6 | 7 | export type StringOptionNoBoolean = GeneralOptionFlags & { 8 | env?: string, 9 | validator?: StrictValidator, 10 | tolerateBoolean?: false, 11 | arity?: Arity, 12 | }; 13 | 14 | export type StringOptionTolerateBoolean = GeneralOptionFlags & { 15 | env?: string, 16 | validator?: StrictValidator, 17 | tolerateBoolean: boolean, 18 | arity?: 0, 19 | }; 20 | 21 | export type StringOption = 22 | | StringOptionNoBoolean 23 | | StringOptionTolerateBoolean; 24 | 25 | export type StringPositionalFlags = { 26 | validator?: StrictValidator, 27 | name?: string, 28 | required?: boolean, 29 | }; 30 | 31 | function StringOption(descriptor: string, opts: StringOptionNoBoolean & {required: true}): CommandOptionReturn>; 32 | function StringOption(descriptor: string, opts?: StringOptionNoBoolean): CommandOptionReturn | undefined>; 33 | function StringOption(descriptor: string, initialValue: WithArity, opts?: Omit, 'required'>): CommandOptionReturn>; 34 | function StringOption(descriptor: string, opts: StringOptionTolerateBoolean & {required: true}): CommandOptionReturn; 35 | function StringOption(descriptor: string, opts: StringOptionTolerateBoolean): CommandOptionReturn; 36 | function StringOption(descriptor: string, initialValue: string | boolean, opts: Omit, 'required'>): CommandOptionReturn; 37 | function StringOption(descriptor: string, initialValueBase: StringOption | WithArity | string | boolean | undefined, optsBase?: StringOption) { 38 | const [initialValue, opts] = rerouteArguments(initialValueBase, optsBase ?? {}); 39 | const {arity = 1} = opts; 40 | 41 | const optNames = descriptor.split(`,`); 42 | const nameSet = new Set(optNames); 43 | 44 | return makeCommandOption({ 45 | definition(builder) { 46 | builder.addOption({ 47 | names: optNames, 48 | 49 | arity: opts.tolerateBoolean ? 0 : arity, 50 | 51 | hidden: opts.hidden, 52 | description: opts.description, 53 | required: opts.required, 54 | }); 55 | }, 56 | 57 | transformer(builder, key, state, context) { 58 | let usedName; 59 | let currentValue = initialValue; 60 | 61 | if (typeof opts.env !== `undefined` && context.env[opts.env]) { 62 | usedName = opts.env; 63 | currentValue = context.env[opts.env]; 64 | } 65 | 66 | for (const {name, value} of state.options) { 67 | if (!nameSet.has(name)) 68 | continue; 69 | 70 | usedName = name; 71 | currentValue = value; 72 | } 73 | 74 | if (typeof currentValue === `string`) { 75 | return applyValidator(usedName ?? key, currentValue, opts.validator); 76 | } else { 77 | return currentValue; 78 | } 79 | }, 80 | }); 81 | } 82 | 83 | function StringPositional(): CommandOptionReturn; 84 | function StringPositional(opts: StringPositionalFlags & {required: false}): CommandOptionReturn; 85 | function StringPositional(opts: StringPositionalFlags): CommandOptionReturn; 86 | function StringPositional(opts: StringPositionalFlags = {}) { 87 | const {required = true} = opts; 88 | 89 | return makeCommandOption({ 90 | definition(builder, key) { 91 | builder.addPositional({ 92 | name: opts.name ?? key, 93 | required: opts.required, 94 | }); 95 | }, 96 | 97 | transformer(builder, key, state) { 98 | for (let i = 0; i < state.positionals.length; ++i) { 99 | // We skip NoLimits extras. We only care about 100 | // required and optional finite positionals. 101 | if (state.positionals[i].extra === NoLimits) 102 | continue; 103 | 104 | // We skip optional positionals when we only 105 | // care about required positionals. 106 | if (required && state.positionals[i].extra === true) 107 | continue; 108 | 109 | // We skip required positionals when we only 110 | // care about optional positionals. 111 | if (!required && state.positionals[i].extra === false) 112 | continue; 113 | 114 | // We remove the positional from the list 115 | const [positional] = state.positionals.splice(i, 1); 116 | 117 | return applyValidator(opts.name ?? key, positional.value, opts.validator); 118 | } 119 | 120 | return undefined; 121 | }, 122 | }); 123 | } 124 | 125 | 126 | /** 127 | * Used to annotate positional options. Such options will be strings 128 | * unless they are provided a schema, which will then be used for coercion. 129 | * 130 | * Be careful: this function is order-dependent! Make sure to define your 131 | * positional options in the same order you expect to find them on the 132 | * command line. 133 | */ 134 | export function String(): CommandOptionReturn; 135 | export function String(opts: StringPositionalFlags & {required: false}): CommandOptionReturn; 136 | export function String(opts: StringPositionalFlags): CommandOptionReturn; 137 | 138 | /** 139 | * Used to annotate string options. Such options will be typed as strings 140 | * unless they are provided a schema, which will then be used for coercion. 141 | * 142 | * @example 143 | * --foo=hello --bar world 144 | * ► {"foo": "hello", "bar": "world"} 145 | */ 146 | export function String(descriptor: string, opts: StringOptionNoBoolean & {required: true}): CommandOptionReturn>; 147 | export function String(descriptor: string, opts?: StringOptionNoBoolean): CommandOptionReturn | undefined>; 148 | export function String(descriptor: string, initialValue: WithArity, opts?: Omit, 'required'>): CommandOptionReturn>; 149 | export function String(descriptor: string, opts: StringOptionTolerateBoolean & {required: true}): CommandOptionReturn; 150 | export function String(descriptor: string, opts: StringOptionTolerateBoolean): CommandOptionReturn; 151 | export function String(descriptor: string, initialValue: string | boolean, opts: Omit, 'required'>): CommandOptionReturn; 152 | 153 | // This function is badly typed, but it doesn't matter because the overloads provide the true public typings 154 | export function String(descriptor?: unknown, ...args: Array) { 155 | if (typeof descriptor === `string`) { 156 | return StringOption(descriptor, ...args); 157 | } else { 158 | return StringPositional(descriptor as any); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /tests/specs/inference.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import * as t from 'typanion'; 3 | 4 | import {runExit} from '../../sources/advanced/Cli'; 5 | import {Command, Option} from '../../sources/advanced'; 6 | 7 | type AssertEqual = [T, Expected] extends [Expected, T] ? true : false; 8 | 9 | function assertEqual() { 10 | return (val: V, expected: AssertEqual) => {}; 11 | } 12 | 13 | class MyCommand extends Command { 14 | defaultPositional = Option.String(); 15 | requiredPositional = Option.String({required: true}); 16 | optionalPositional = Option.String({required: false}); 17 | 18 | boolean = Option.Boolean(`--foo`); 19 | booleanWithDefault = Option.Boolean(`--foo`, false); 20 | booleanWithRequired = Option.Boolean(`--foo`, {required: true}); 21 | // @ts-expect-error: Overload prevents this 22 | booleanWithRequiredAndDefault = Option.Boolean(`--foo`, false, {required: true}); 23 | 24 | string = Option.String(`--foo`); 25 | stringWithDefault = Option.String(`--foo`, `foo`); 26 | stringWithValidator = Option.String(`--foo`, {validator: t.isNumber()}); 27 | stringWithValidatorAndDefault = Option.String(`--foo`, `0`, {validator: t.isNumber()}); 28 | stringWithValidatorAndRequired = Option.String(`--foo`, {validator: t.isNumber(), required: true}); 29 | stringWithRequired = Option.String(`--foo`, {required: true}); 30 | // @ts-expect-error: Overload prevents this 31 | stringWithRequiredAndDefault = Option.String(`--foo`, false, {required: true}); 32 | stringWithArity0 = Option.String(`--foo`, {arity: 0}); 33 | stringWithArity1 = Option.String(`--foo`, {arity: 1}); 34 | stringWithArity2 = Option.String(`--foo`, {arity: 2}); 35 | stringWithArity3 = Option.String(`--foo`, {arity: 3}); 36 | stringWithArity3AndDefault = Option.String(`--foo`, [`bar`, `baz`, `qux`], {arity: 3}); 37 | // @ts-expect-error: Overload prevents this 38 | stringWithArity3AndWrongDefault = Option.String(`--foo`, `bar`, {arity: 3}); 39 | stringWithArity3AndValidator = Option.String(`--foo`, {arity: 3, validator: t.isNumber()}); 40 | stringWithArity3AndValidatorAndDefault = Option.String(`--foo`, [`1`, `2`, `3`], {arity: 3, validator: t.isNumber()}); 41 | stringWithArity3AndValidatorAndRequired = Option.String(`--foo`, {arity: 3, validator: t.isNumber(), required: true}); 42 | stringWithArity3AndRequired = Option.String(`--foo`, {arity: 3, required: true}); 43 | // @ts-expect-error: Overload prevents this 44 | stringWithArity3AndRequiredAndDefault = Option.String(`--foo`, [`bar`, `baz`, `qux`], {arity: 3, required: true}); 45 | 46 | stringWithTolerateBoolean = Option.String(`--foo`, {tolerateBoolean: true}); 47 | stringWithTolerateBooleanFalse = Option.String(`--foo`, {tolerateBoolean: false}); 48 | stringWithTolerateBooleanAndRequired = Option.String(`--foo`, {tolerateBoolean: true, required: true}); 49 | stringWithTolerateBooleanAndDefault = Option.String(`--foo`, false, {tolerateBoolean: true}); 50 | stringWithTolerateBooleanAndValidator = Option.String(`--foo`, false, {tolerateBoolean: true, validator: t.isNumber()}); 51 | // @ts-expect-error: Overload prevents this 52 | stringWithTolerateBooleanAndRequiredAndDefault = Option.String(`--foo`, false, {tolerateBoolean: true, required: true}); 53 | 54 | counter = Option.Counter(`--foo`); 55 | counterWithDefault = Option.Counter(`--foo`, 0); 56 | counterWithRequired = Option.Counter(`--foo`, {required: true}); 57 | // @ts-expect-error: Overload prevents this 58 | counterWithRequiredAndDefault = Option.Counter(`--foo`, 0, {required: true}); 59 | 60 | array = Option.Array(`--foo`); 61 | arrayWithDefault = Option.Array(`--foo`, []); 62 | arrayWithRequired = Option.Array(`--foo`, {required: true}); 63 | // @ts-expect-error: Overload prevents this 64 | arrayWithRequiredAndDefault = Option.Array(`--foo`, [], {required: true}); 65 | arrayWithArity0 = Option.Array(`--foo`, {arity: 0}); 66 | arrayWithArity1 = Option.Array(`--foo`, {arity: 1}); 67 | arrayWithArity2 = Option.Array(`--foo`, {arity: 2}); 68 | arrayWithArity3 = Option.Array(`--foo`, {arity: 3}); 69 | arrayWithArity3AndDefault = Option.Array(`--foo`, [], {arity: 3}); 70 | arrayWithArity3AndRequired = Option.Array(`--foo`, {arity: 3, required: true}); 71 | // @ts-expect-error: Overload prevents this 72 | arrayWithArity3AndRequiredAndDefault = Option.Array(`--foo`, [], {arity: 3, required: true}); 73 | arrayWithValidator = Option.Array(`--foo`, {validator: t.isArray(t.isNumber())}); 74 | arrayWithTupleValidator = Option.Array(`--foo`, {arity: 2, validator: t.isArray(t.isTuple([t.isNumber(), t.isBoolean()]))}); 75 | 76 | rest = Option.Rest(); 77 | proxy = Option.Proxy(); 78 | 79 | async execute() { 80 | assertEqual()(this.defaultPositional, true); 81 | assertEqual()(this.requiredPositional, true); 82 | assertEqual()(this.optionalPositional, true); 83 | 84 | assertEqual()(this.boolean, true); 85 | assertEqual()(this.booleanWithDefault, true); 86 | assertEqual()(this.booleanWithRequired, true); 87 | 88 | assertEqual()(this.string, true); 89 | assertEqual()(this.stringWithDefault, true); 90 | assertEqual()(this.stringWithValidator, true); 91 | assertEqual()(this.stringWithValidatorAndRequired, true); 92 | assertEqual()(this.stringWithValidatorAndDefault, true); 93 | assertEqual()(this.stringWithRequired, true); 94 | assertEqual()(this.stringWithArity0, true); 95 | assertEqual()(this.stringWithArity1, true); 96 | assertEqual<[string, string] | undefined>()(this.stringWithArity2, true); 97 | assertEqual<[string, string, string] | undefined>()(this.stringWithArity3, true); 98 | assertEqual<[string, string, string]>()(this.stringWithArity3AndDefault, true); 99 | assertEqual<[string, string, string]>()(this.stringWithArity3AndRequired, true); 100 | assertEqual<[number, number, number] | undefined>()(this.stringWithArity3AndValidator, true); 101 | assertEqual<[number, number, number]>()(this.stringWithArity3AndValidatorAndDefault, true); 102 | assertEqual<[number, number, number]>()(this.stringWithArity3AndValidatorAndRequired, true); 103 | 104 | assertEqual()(this.stringWithTolerateBooleanFalse, true); 105 | assertEqual()(this.stringWithTolerateBoolean, true); 106 | assertEqual()(this.stringWithTolerateBooleanAndDefault, true); 107 | assertEqual()(this.stringWithTolerateBooleanAndRequired, true); 108 | 109 | assertEqual()(this.counter, true); 110 | assertEqual()(this.counterWithDefault, true); 111 | assertEqual()(this.counterWithRequired, true); 112 | 113 | assertEqual | undefined>()(this.array, true); 114 | assertEqual>()(this.arrayWithDefault, true); 115 | assertEqual>()(this.arrayWithRequired, true); 116 | assertEqual | undefined>()(this.arrayWithArity0, true); 117 | assertEqual | undefined>()(this.arrayWithArity1, true); 118 | assertEqual | undefined>()(this.arrayWithArity2, true); 119 | assertEqual | undefined>()(this.arrayWithArity3, true); 120 | assertEqual>()(this.arrayWithArity3AndDefault, true); 121 | assertEqual>()(this.arrayWithArity3AndRequired, true); 122 | assertEqual | undefined>()(this.arrayWithValidator, true); 123 | assertEqual | undefined>()(this.arrayWithTupleValidator, true); 124 | 125 | assertEqual>()(this.rest, true); 126 | assertEqual>()(this.proxy, true); 127 | } 128 | } 129 | 130 | if (eval(`false`)) { 131 | runExit(class FooCommand extends Command { 132 | async execute() {} 133 | }); 134 | 135 | runExit(class FooCommand extends Command { 136 | async execute() {} 137 | }, { 138 | stdin: process.stdin, 139 | }); 140 | 141 | runExit({ 142 | binaryLabel: `Foo`, 143 | }, class FooCommand extends Command { 144 | async execute() {} 145 | }); 146 | 147 | runExit({ 148 | binaryLabel: `Foo`, 149 | }, class FooCommand extends Command { 150 | async execute() {} 151 | }, { 152 | stdin: process.stdin, 153 | }); 154 | 155 | runExit(class FooCommand extends Command { 156 | async execute() {} 157 | }, []); 158 | 159 | runExit(class FooCommand extends Command { 160 | async execute() {} 161 | }, [], { 162 | stdin: process.stdin, 163 | }); 164 | 165 | runExit({ 166 | binaryLabel: `Foo`, 167 | }, class FooCommand extends Command { 168 | async execute() {} 169 | }, []); 170 | 171 | runExit({ 172 | binaryLabel: `Foo`, 173 | }, class FooCommand extends Command { 174 | async execute() {} 175 | }, [], { 176 | stdin: process.stdin, 177 | }); 178 | } 179 | -------------------------------------------------------------------------------- /website/docs/api/option.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: option 3 | title: Option 4 | --- 5 | 6 | The following functions allow you to define new options for your cli commands. They must be registered into each command via regular public class properties (private class properties aren't supported): 7 | 8 | ```ts 9 | class MyCommand extends Command { 10 | flag = Option.Boolean(`--flag`); 11 | } 12 | ``` 13 | 14 | ## `Option.Array` 15 | 16 | ```ts 17 | Option.Array(optionNames: string, default?: string[], opts?: {...}) 18 | ``` 19 | 20 | | Option | type | Description | 21 | | --- | --- | --- | 22 | | `arity` | `number` | Number of arguments for the option | 23 | | `description` | `string`| Short description for the help message | 24 | | `hidden` | `boolean` | Hide the option from any usage list | 25 | | `required` | `boolean` | Whether at least a single occurrence of the option is required or not | 26 | 27 | Specifies that the command accepts a set of string arguments. The `arity` parameter defines how many values need to be accepted for each item. If no default value is provided, the option will start as `undefined`. 28 | 29 | ```ts 30 | class RunCommand extends Command { 31 | args = Option.Array(`--arg`); 32 | points = Option.Array(`--point`, {arity: 3}); 33 | // ... 34 | } 35 | ``` 36 | 37 | Generates: 38 | 39 | ```bash 40 | run --arg value1 --arg value2 41 | # => TestCommand {"args": ["value1", "value2"]} 42 | 43 | run --point x y z --point a b c 44 | # => TestCommand {"points": [["x", "y", "z"], ["a", "b", "c"]]} 45 | ``` 46 | 47 | ## `Option.Boolean` 48 | 49 | ```ts 50 | Option.Boolean(optionNames: string, default?: boolean, opts?: {...}) 51 | ``` 52 | 53 | | Option | type | Description | 54 | | --- | --- | --- | 55 | | `description` | `string`| Short description for the help message | 56 | | `hidden` | `boolean` | Hide the option from any usage list | 57 | | `required` | `boolean` | Whether at least a single occurrence of the option is required or not | 58 | 59 | Specifies that the command accepts a boolean flag as an option. If no default value is provided, the option will start as `undefined`. 60 | 61 | ```ts 62 | class TestCommand extends Command { 63 | flag = Option.Boolean(`--flag`); 64 | // ... 65 | } 66 | ``` 67 | 68 | Generates: 69 | 70 | ```bash 71 | run --flag 72 | # => TestCommand {"flag": true} 73 | ``` 74 | 75 | ## `Option.Counter` 76 | 77 | ```ts 78 | Option.Counter(optionNames: string, default?: number, opts?: {...}) 79 | ``` 80 | 81 | | Option | type | Description | 82 | | --- | --- | --- | 83 | | `description` | `string`| Short description for the help message | 84 | | `hidden` | `boolean` | Hide the option from any usage list | 85 | | `required` | `boolean` | Whether at least a single occurrence of the option is required or not | 86 | 87 | Specifies that the command accepts a boolean flag as an option. Contrary to classic boolean options, each detected occurence will cause the counter to be incremented. Each time the argument is negated (`--no-`), the counter will be reset to `0`. If no default value is provided, the option will start as `undefined`. 88 | 89 | ```ts 90 | class TestCommand extends Command { 91 | verbose = Option.Counter(`-v,--verbose`); 92 | // ... 93 | } 94 | ``` 95 | 96 | Generates: 97 | 98 | ```bash 99 | run -v 100 | # => TestCommand {"verbose": 1} 101 | 102 | run -vv 103 | # => TestCommand {"verbose": 2} 104 | 105 | run --verbose -v --verbose -v 106 | # => TestCommand {"verbose": 4} 107 | 108 | run --verbose -v --verbose -v --no-verbose 109 | # => TestCommand {"verbose": 0} 110 | ``` 111 | 112 | ## `Option.Proxy` 113 | 114 | ```ts 115 | Option.Proxy(opts?: {...}) 116 | ``` 117 | 118 | | Option | type | Description | 119 | | --- | --- | --- | 120 | | `required` | `number` | Number of required trailing arguments | 121 | 122 | Specifies that the command accepts an infinite set of positional arguments that will not be consumed by the options of the `Command` instance. Use this decorator instead of `Option.Rest` when you wish to forward arguments to another command parsing them in any way. By default no arguments are required, but this can be changed by setting the `required` option. 123 | 124 | ```ts 125 | class RunCommand extends Command { 126 | args = Option.Proxy(); 127 | // ... 128 | } 129 | ``` 130 | 131 | Generates: 132 | 133 | ```bash 134 | run 135 | # => TestCommand {"values": []} 136 | 137 | run value1 value2 138 | # => TestCommand {"values": ["value1", "value2"]} 139 | 140 | run value1 --foo 141 | # => TestCommand {"values": ["value1", "--foo"]} 142 | 143 | run --bar=baz 144 | # => TestCommand {"values": ["--bar=baz"]} 145 | ``` 146 | 147 | **Note:** Proxying can only happen once per command. Once triggered, a command can't get out of "proxy mode", all remaining arguments being proxied into a list. "Proxy mode" can be triggered in the following ways: 148 | 149 | - By passing a positional or an option that doesn't have any listeners attached to it. This happens when the listeners don't exist in the first place. 150 | 151 | - By passing a positional that doesn't have any *remaining* listeners attached to it. This happens when the listeners have already consumed a positional. 152 | 153 | - By passing the `--` separator before an option that has a listener attached to it. This will cause Clipanion to activate "proxy mode" for all arguments after the separator, *without* proxying the separator itself. In all other cases, the separator *will* be proxied and *not* consumed by Clipanion. 154 | 155 | ## `Option.Rest` 156 | 157 | ```ts 158 | Option.Rest(opts?: {...}) 159 | ``` 160 | 161 | | Option | type | Description | 162 | | --- | --- | --- | 163 | | `required` | `number` | Number of required trailing arguments | 164 | 165 | Specifies that the command accepts an unlimited number of positional arguments. By default no arguments are required, but this can be changed by setting the `required` option. 166 | 167 | ```ts 168 | class RunCommand extends Command { 169 | values = Option.Rest(); 170 | // ... 171 | } 172 | ``` 173 | 174 | Generates: 175 | 176 | ```bash 177 | run 178 | # => TestCommand {"values": []} 179 | 180 | run value1 value2 181 | # => TestCommand {"values": ["value1", "value2"]} 182 | 183 | run value1 184 | # => TestCommand {"values": ["value1"]} 185 | 186 | run 187 | # => TestCommand {"values": []} 188 | ``` 189 | 190 | **Note:** Rest arguments are strictly positionals. All options found between rest arguments will be consumed as options of the `Command` instance. If you wish to forward a list of option to another command without having to parse them yourself, use `Option.Proxy` instead. 191 | 192 | **Note:** Rest arguments can be surrounded by other *finite* *non-optional* positionals such as `Option.String({required: true})`. Having multiple rest arguments in the same command is however invalid. 193 | 194 | **Advanced Example:** 195 | 196 | ```ts 197 | class CopyCommand extends Command { 198 | sources = Option.Rest({required: 1}); 199 | destination = Option.String(); 200 | force = Option.Boolean(`-f,--force`); 201 | reflink = Option.String(`--reflink`, {tolerateBoolean: true}); 202 | // ... 203 | } 204 | ``` 205 | 206 | Generates: 207 | 208 | ```bash 209 | run src dest 210 | # => CopyCommand {"sources": ["src"], "destination": "dest"} 211 | 212 | run src1 src2 dest 213 | # => CopyCommand {"sources": ["src1", "src2"], "destination": "dest"} 214 | 215 | run src1 --force src2 dest 216 | # => CopyCommand {"sources": ["src1", "src2"], "destination": "dest", "force": true} 217 | 218 | run src1 src2 --reflink=always dest 219 | # => CopyCommand {"sources": ["src1", "src2"], "destination": "dest", "reflink": "always"} 220 | 221 | run src 222 | # => Invalid! - Not enough positional arguments. 223 | 224 | run dest 225 | # => Invalid! - Not enough positional arguments. 226 | ``` 227 | 228 | ## `Option.String` (option) 229 | 230 | ```ts 231 | Option.String(optionNames: string, default?: string, opts?: {...}) 232 | ``` 233 | 234 | | Option | type | Description | 235 | | --- | --- | --- | 236 | | `arity` | `number` | Number of arguments for the option | 237 | | `description` | `string`| Short description for the help message | 238 | | `env` | `string` | Name of an environment variable | 239 | | `hidden` | `boolean` | Hide the option from any usage list | 240 | | `tolerateBoolean` | `boolean` | Accept the option even if no argument is provided | 241 | | `required` | `boolean` | Whether at least a single occurrence of the option is required or not | 242 | 243 | Specifies that the command accepts an option that takes arguments (by default one, unless overriden via `arity`). If no default value is provided, the option will start as `undefined`. 244 | 245 | If `env` is set and the specified environment variable is non-empty, it'll override the default value if necessary. Note that explicit options still take precedence over env values. 246 | 247 | ```ts 248 | class TestCommand extends Command { 249 | arg = Option.String(`-a,--arg`); 250 | // ... 251 | } 252 | ``` 253 | 254 | Generates: 255 | 256 | ```bash 257 | run --arg value 258 | run --arg=value 259 | run -a value 260 | run -a=value 261 | # => TestCommand {"arg": "value"} 262 | 263 | run --arg=-42 264 | # => TestCommand {"arg": "-42"} 265 | 266 | run --arg -42 267 | # => Invalid! Option `-42` doesn't exist. 268 | ``` 269 | 270 | Be careful, by default, options that accept an argument must receive one on the CLI (ie `--foo --bar` wouldn't be valid if `--foo` accepts an argument). 271 | 272 | This behaviour can be toggled off if the `tolerateBoolean` option is set. In this case, the option will act like a boolean flag if it doesn't have a value. Note that with this option on, arguments values can only be specified using the `--foo=ARG` syntax, which makes this option incompatible with arities higher than one. 273 | 274 | ```ts 275 | class TestCommand extends Command { 276 | debug = Option.String(`--inspect`, {tolerateBoolean: true}); 277 | // ... 278 | } 279 | ``` 280 | 281 | Generates: 282 | 283 | ```bash 284 | run --inspect 285 | # => TestCommand {"debug": true} 286 | 287 | run --inspect=1234 288 | # => TestCommand {"debug": "1234"} 289 | 290 | run --inspect 1234 291 | # Invalid! 292 | ``` 293 | 294 | ## `Option.String` (positional) 295 | 296 | ```ts 297 | Option.String(opts: {...}) 298 | ``` 299 | 300 | | Option | type | Description | 301 | | --- | --- | --- | 302 | | `required` | `boolean` | Whether the positional argument is required or not | 303 | 304 | Specifies that the command accepts a positional argument. By default it will be required, but this can be toggled off using `required`. 305 | 306 | ```ts 307 | class TestCommand extends Command { 308 | foo = Option.String(); 309 | // ... 310 | } 311 | ``` 312 | 313 | Generates: 314 | 315 | ```bash 316 | run value 317 | # => TestCommand {"foo": "value"} 318 | ``` 319 | 320 | Note that Clipanion supports required positional arguments both at the beginning and the end of the positional argument list (which allows you to build CLI for things like `cp`). For that to work, make sure to list your arguments in the right order: 321 | 322 | ```ts 323 | class TestCommand extends Command { 324 | foo = Option.String({required: false}); 325 | bar = Option.String(); 326 | // ... 327 | } 328 | ``` 329 | 330 | Generates: 331 | 332 | ```bash 333 | run value1 value2 334 | # => TestCommand {"foo": "value1", "bar": "value2"} 335 | 336 | run value 337 | # => TestCommand {"foo": undefined, "bar": "value"} 338 | 339 | run 340 | # Invalid! 341 | ``` 342 | -------------------------------------------------------------------------------- /tests/specs/core.test.ts: -------------------------------------------------------------------------------- 1 | import {HELP_COMMAND_INDEX} from '../../sources/constants'; 2 | import {CliBuilderCallback, CliBuilder, NoLimits} from '../../sources/core'; 3 | import {expect} from '../expect'; 4 | 5 | const makeCli = (definitions: Array>) => { 6 | return CliBuilder.build<{}>(definitions.map(cb => { 7 | return builder => { 8 | builder.setContext({}); 9 | cb(builder); 10 | }; 11 | })); 12 | }; 13 | 14 | describe(`Core`, () => { 15 | it(`should select the default command when using no arguments`, () => { 16 | const cli = makeCli([ 17 | () => {}, 18 | ]); 19 | 20 | const {selectedIndex} = cli.process([]); 21 | expect(selectedIndex).to.equal(0); 22 | }); 23 | 24 | it(`should select the default command when using mandatory positional arguments`, () => { 25 | const cli = makeCli([ 26 | b => { 27 | b.addPositional(); 28 | b.addPositional(); 29 | }, 30 | ]); 31 | 32 | const {selectedIndex} = cli.process([`foo`, `bar`]); 33 | expect(selectedIndex).to.equal(0); 34 | }); 35 | 36 | it(`should select commands by their path`, () => { 37 | const cli = makeCli([ 38 | b => { 39 | b.addPath([`foo`]); 40 | }, 41 | b => { 42 | b.addPath([`bar`]); 43 | }, 44 | ]); 45 | 46 | const {selectedIndex: selectedIndex1} = cli.process([`foo`]); 47 | expect(selectedIndex1).to.equal(0); 48 | 49 | const {selectedIndex: selectedIndex2} = cli.process([`bar`]); 50 | expect(selectedIndex2).to.equal(1); 51 | }); 52 | 53 | it(`should select commands by their mandatory positional arguments`, () => { 54 | const cli = makeCli([ 55 | () => { 56 | // Nothing to do 57 | }, 58 | b => { 59 | b.addPositional(); 60 | }, 61 | ]); 62 | 63 | const {selectedIndex} = cli.process([`foo`]); 64 | expect(selectedIndex).to.equal(1); 65 | }); 66 | 67 | it(`should select commands by their simple options`, () => { 68 | const cli = makeCli([ 69 | b => { 70 | b.addOption({names: [`-x`]}); 71 | }, 72 | b => { 73 | b.addOption({names: [`-y`]}); 74 | }, 75 | ]); 76 | 77 | const {selectedIndex: selectedIndex1} = cli.process([`-x`]); 78 | expect(selectedIndex1).to.equal(0); 79 | 80 | const {selectedIndex: selectedIndex2} = cli.process([`-y`]); 81 | expect(selectedIndex2).to.equal(1); 82 | }); 83 | 84 | it(`should allow options to precede the command paths`, () => { 85 | const cli = makeCli([ 86 | b => { 87 | b.addPath([`foo`]); 88 | b.addOption({names: [`-x`]}); 89 | }, 90 | b => { 91 | b.addPath([`bar`]); 92 | b.addOption({names: [`-y`]}); 93 | }, 94 | ]); 95 | 96 | const {selectedIndex: selectedIndex1} = cli.process([`-x`, `foo`]); 97 | expect(selectedIndex1).to.equal(0); 98 | 99 | const {selectedIndex: selectedIndex2} = cli.process([`-y`, `bar`]); 100 | expect(selectedIndex2).to.equal(1); 101 | }); 102 | 103 | it(`should select commands by their complex values`, () => { 104 | const cli = makeCli([ 105 | b => { 106 | b.addOption({names: [`-x`], arity: 1}); 107 | }, 108 | b => { 109 | b.addOption({names: [`-y`], arity: 1}); 110 | }, 111 | ]); 112 | 113 | const {selectedIndex: selectedIndex1} = cli.process([`-x`, `foo`]); 114 | expect(selectedIndex1).to.equal(0); 115 | 116 | const {selectedIndex: selectedIndex2} = cli.process([`-y`, `bar`]); 117 | expect(selectedIndex2).to.equal(1); 118 | }); 119 | 120 | it(`should prefer longer paths over mandatory arguments`, () => { 121 | const cli = makeCli([ 122 | b => { 123 | b.addPath([`foo`]); 124 | }, 125 | b => { 126 | b.addPositional(); 127 | }, 128 | ]); 129 | 130 | const {selectedIndex} = cli.process([`foo`]); 131 | expect(selectedIndex).to.equal(0); 132 | }); 133 | 134 | it(`should prefer longer paths over mandatory arguments (reversed)`, () => { 135 | const cli = makeCli([ 136 | b => { 137 | b.addPositional(); 138 | }, 139 | b => { 140 | b.addPath([`foo`]); 141 | }, 142 | ]); 143 | 144 | const {selectedIndex} = cli.process([`foo`]); 145 | expect(selectedIndex).to.equal(1); 146 | }); 147 | 148 | it(`should prefer longer paths over mandatory arguments (prefixed)`, () => { 149 | const cli = makeCli([ 150 | b => { 151 | b.addPath([`prfx`, `foo`]); 152 | }, 153 | b => { 154 | b.addPath([`prfx`]); 155 | b.addPositional(); 156 | }, 157 | ]); 158 | 159 | const {selectedIndex} = cli.process([`prfx`, `foo`]); 160 | expect(selectedIndex).to.equal(0); 161 | }); 162 | 163 | it(`should prefer longer paths over optional arguments`, () => { 164 | const cli = makeCli([ 165 | b => { 166 | b.addPath([`foo`]); 167 | }, 168 | b => { 169 | b.addPositional({required: false}); 170 | }, 171 | ]); 172 | 173 | const {selectedIndex} = cli.process([`foo`]); 174 | expect(selectedIndex).to.equal(0); 175 | }); 176 | 177 | it(`should prefer longer paths over optional arguments (reversed)`, () => { 178 | const cli = makeCli([ 179 | b => { 180 | b.addPositional({required: false}); 181 | }, 182 | b => { 183 | b.addPath([`foo`]); 184 | }, 185 | ]); 186 | 187 | const {selectedIndex} = cli.process([`foo`]); 188 | expect(selectedIndex).to.equal(1); 189 | }); 190 | 191 | it(`should prefer longer paths over optional arguments (prefixed)`, () => { 192 | const cli = makeCli([ 193 | b => { 194 | b.addPath([`prfx`, `foo`]); 195 | }, 196 | b => { 197 | b.addPath([`prfx`]); 198 | b.addPositional({required: false}); 199 | }, 200 | ]); 201 | 202 | const {selectedIndex} = cli.process([`prfx`, `foo`]); 203 | expect(selectedIndex).to.equal(0); 204 | }); 205 | 206 | it(`should prefer mandatory arguments over optional arguments`, () => { 207 | const cli = makeCli([ 208 | b => { 209 | b.addPositional(); 210 | }, 211 | b => { 212 | b.addPositional({required: false}); 213 | }, 214 | ]); 215 | 216 | const {selectedIndex} = cli.process([`foo`]); 217 | expect(selectedIndex).to.equal(0); 218 | }); 219 | 220 | it(`should prefer mandatory arguments over optional arguments (reversed)`, () => { 221 | const cli = makeCli([ 222 | b => { 223 | b.addPositional({required: false}); 224 | }, 225 | b => { 226 | b.addPositional(); 227 | }, 228 | ]); 229 | 230 | const {selectedIndex} = cli.process([`foo`]); 231 | expect(selectedIndex).to.equal(1); 232 | }); 233 | 234 | it(`should fallback from path to mandatory arguments if needed`, () => { 235 | const cli = makeCli([ 236 | b => { 237 | b.addPath([`foo`]); 238 | }, 239 | b => { 240 | b.addPositional(); 241 | }, 242 | ]); 243 | 244 | const {selectedIndex} = cli.process([`bar`]); 245 | expect(selectedIndex).to.equal(1); 246 | }); 247 | 248 | it(`should fallback from path to mandatory arguments if needed (reversed)`, () => { 249 | const cli = makeCli([ 250 | b => { 251 | b.addPositional(); 252 | }, 253 | b => { 254 | b.addPath([`foo`]); 255 | }, 256 | ]); 257 | 258 | const {selectedIndex} = cli.process([`bar`]); 259 | expect(selectedIndex).to.equal(0); 260 | }); 261 | 262 | it(`should fallback from path to mandatory arguments if needed (prefixed)`, () => { 263 | const cli = makeCli([ 264 | b => { 265 | b.addPath([`prfx`, `foo`]); 266 | }, 267 | b => { 268 | b.addPath([`prfx`]); 269 | b.addPositional(); 270 | }, 271 | ]); 272 | 273 | const {selectedIndex} = cli.process([`prfx`, `bar`]); 274 | expect(selectedIndex).to.equal(1); 275 | }); 276 | 277 | it(`should fallback from path to optional arguments if needed`, () => { 278 | const cli = makeCli([ 279 | b => { 280 | b.addPath([`foo`]); 281 | }, 282 | b => { 283 | b.addPositional({required: false}); 284 | }, 285 | ]); 286 | 287 | const {selectedIndex} = cli.process([`bar`]); 288 | expect(selectedIndex).to.equal(1); 289 | }); 290 | 291 | it(`should fallback from path to optional arguments if needed (reversed)`, () => { 292 | const cli = makeCli([ 293 | b => { 294 | b.addPositional({required: false}); 295 | }, 296 | b => { 297 | b.addPath([`foo`]); 298 | }, 299 | ]); 300 | 301 | const {selectedIndex} = cli.process([`bar`]); 302 | expect(selectedIndex).to.equal(0); 303 | }); 304 | 305 | it(`should fallback from path to optional arguments if needed (prefixed)`, () => { 306 | const cli = makeCli([ 307 | b => { 308 | b.addPath([`prfx`, `foo`]); 309 | }, 310 | b => { 311 | b.addPath([`prfx`]); 312 | b.addPositional(); 313 | }, 314 | ]); 315 | 316 | const {selectedIndex} = cli.process([`prfx`, `bar`]); 317 | expect(selectedIndex).to.equal(1); 318 | }); 319 | 320 | it(`should extract booleans from simple options`, () => { 321 | const cli = makeCli([ 322 | b => { 323 | b.addOption({names: [`-x`]}); 324 | }, 325 | ]); 326 | 327 | const {options} = cli.process([`-x`]); 328 | expect(options).to.deep.equal([ 329 | {name: `-x`, value: true}, 330 | ]); 331 | }); 332 | 333 | it(`should extract booleans from batch options`, () => { 334 | const cli = makeCli([ 335 | b => { 336 | b.addOption({names: [`-x`, `-y`]}); 337 | }, 338 | ]); 339 | 340 | const {options} = cli.process([`-xy`]); 341 | expect(options).to.deep.equal([ 342 | {name: `-x`, value: true}, 343 | {name: `-y`, value: true}, 344 | ]); 345 | }); 346 | 347 | it(`should invert booleans when using --no-`, () => { 348 | const cli = makeCli([ 349 | b => { 350 | b.addOption({names: [`--foo`]}); 351 | }, 352 | ]); 353 | 354 | const {options} = cli.process([`--no-foo`]); 355 | expect(options).to.deep.equal([ 356 | {name: `--foo`, value: false}, 357 | ]); 358 | }); 359 | 360 | it(`should extract strings from complex options`, () => { 361 | const cli = makeCli([ 362 | b => { 363 | b.addOption({names: [`-x`], arity: 1}); 364 | }, 365 | ]); 366 | 367 | const {options} = cli.process([`-x`, `foo`]); 368 | expect(options).to.deep.equal([ 369 | {name: `-x`, value: `foo`}, 370 | ]); 371 | }); 372 | 373 | it(`should extract strings from complex options (=)`, () => { 374 | const cli = makeCli([ 375 | b => { 376 | b.addOption({names: [`--foo`], arity: 1}); 377 | }, 378 | ]); 379 | 380 | const {options} = cli.process([`--foo=foo`]); 381 | expect(options).to.deep.equal([ 382 | {name: `--foo`, value: `foo`}, 383 | ]); 384 | }); 385 | 386 | it(`shouldn't consider '-' as an option`, () => { 387 | const cli = makeCli([ 388 | b => { 389 | b.addOption({names: [`--foo`], arity: 1}); 390 | }, 391 | ]); 392 | 393 | const {options} = cli.process([`--foo`, `-`]); 394 | expect(options).to.deep.equal([ 395 | {name: `--foo`, value: `-`}, 396 | ]); 397 | }); 398 | 399 | it(`should extract arrays from complex options`, () => { 400 | const cli = makeCli([ 401 | b => { 402 | b.addOption({names: [`--foo`], arity: 1}); 403 | }, 404 | ]); 405 | 406 | const {options} = cli.process([`--foo`, `bar`, `--foo`, `baz`]); 407 | expect(options).to.deep.equal([ 408 | {name: `--foo`, value: `bar`}, 409 | {name: `--foo`, value: `baz`}, 410 | ]); 411 | }); 412 | 413 | it(`should extract arrays from complex options (=)`, () => { 414 | const cli = makeCli([ 415 | b => { 416 | b.addOption({names: [`--foo`], arity: 1}); 417 | }, 418 | ]); 419 | 420 | const {options} = cli.process([`--foo=bar`, `--foo=baz`]); 421 | expect(options).to.deep.equal([ 422 | {name: `--foo`, value: `bar`}, 423 | {name: `--foo`, value: `baz`}, 424 | ]); 425 | }); 426 | 427 | it(`should extract arrays from complex options (mixed)`, () => { 428 | const cli = makeCli([ 429 | b => { 430 | b.addOption({names: [`--foo`], arity: 1}); 431 | }, 432 | ]); 433 | 434 | const {options} = cli.process([`--foo`, `bar`, `--foo=baz`]); 435 | expect(options).to.deep.equal([ 436 | {name: `--foo`, value: `bar`}, 437 | {name: `--foo`, value: `baz`}, 438 | ]); 439 | }); 440 | 441 | it(`should support rest arguments`, () => { 442 | const cli = makeCli([ 443 | b => { 444 | b.addRest(); 445 | }, 446 | ]); 447 | 448 | const {positionals} = cli.process([`foo`, `bar`, `baz`]); 449 | expect(positionals).to.deep.equal([ 450 | {value: `foo`, extra: NoLimits}, 451 | {value: `bar`, extra: NoLimits}, 452 | {value: `baz`, extra: NoLimits}, 453 | ]); 454 | }); 455 | 456 | it(`should support rest arguments followed by mandatory arguments`, () => { 457 | const cli = makeCli([ 458 | b => { 459 | b.addRest(); 460 | b.addPositional(); 461 | }, 462 | ]); 463 | 464 | const {positionals} = cli.process([`src1`, `src2`, `src3`, `dest`]); 465 | expect(positionals).to.deep.equal([ 466 | {value: `src1`, extra: NoLimits}, 467 | {value: `src2`, extra: NoLimits}, 468 | {value: `src3`, extra: NoLimits}, 469 | {value: `dest`, extra: false}, 470 | ]); 471 | }); 472 | 473 | it(`should support rest arguments between mandatory arguments`, () => { 474 | const cli = makeCli([ 475 | b => { 476 | b.addPositional(); 477 | b.addRest(); 478 | b.addPositional(); 479 | }, 480 | ]); 481 | 482 | const {positionals} = cli.process([`foo`, `src1`, `src2`, `src3`, `dest`]); 483 | expect(positionals).to.deep.equal([ 484 | {value: `foo`, extra: false}, 485 | {value: `src1`, extra: NoLimits}, 486 | {value: `src2`, extra: NoLimits}, 487 | {value: `src3`, extra: NoLimits}, 488 | {value: `dest`, extra: false}, 489 | ]); 490 | }); 491 | 492 | it(`should support option arguments in between rest arguments`, () => { 493 | const cli = makeCli([ 494 | b => { 495 | b.addOption({names: [`--foo`]}); 496 | b.addOption({names: [`--bar`], arity: 1}); 497 | b.addRest(); 498 | }, 499 | ]); 500 | 501 | const {options, positionals} = cli.process([`src1`, `--foo`, `src2`, `--bar`, `baz`, `src3`]); 502 | 503 | expect(options).to.deep.equal([ 504 | {name: `--foo`, value: true}, 505 | {name: `--bar`, value: `baz`}, 506 | ]); 507 | 508 | expect(positionals).to.deep.equal([ 509 | {value: `src1`, extra: NoLimits}, 510 | {value: `src2`, extra: NoLimits}, 511 | {value: `src3`, extra: NoLimits}, 512 | ]); 513 | }); 514 | 515 | it(`should ignore options when they follow the -- separator`, () => { 516 | const cli = makeCli([ 517 | b => { 518 | b.addPath([`foo`]); 519 | b.addOption({names: [`-x`]}); 520 | b.addPositional({required: false}); 521 | }, 522 | ]); 523 | 524 | const {options, positionals} = cli.process([`foo`, `--`, `-x`]); 525 | 526 | expect(options).to.deep.equal([ 527 | ]); 528 | 529 | expect(positionals).to.deep.equal([ 530 | {value: `-x`, extra: true}, 531 | ]); 532 | }); 533 | 534 | it(`should ignore options when they appear after a required positional from a proxy`, () => { 535 | const cli = makeCli([ 536 | b => { 537 | b.addPath([`foo`]); 538 | b.addOption({names: [`-x`]}); 539 | b.addPositional(); 540 | b.addProxy(); 541 | }, 542 | ]); 543 | 544 | const {options, positionals} = cli.process([`foo`, `foo`, `-x`]); 545 | 546 | expect(options).to.deep.equal([ 547 | ]); 548 | 549 | expect(positionals).to.deep.equal([ 550 | {value: `foo`, extra: false}, 551 | {value: `-x`, extra: NoLimits}, 552 | ]); 553 | }); 554 | 555 | it(`should interpret options when they appear between required positionals proxy`, () => { 556 | const cli = makeCli([ 557 | b => { 558 | b.addPath([`foo`]); 559 | b.addOption({names: [`-x`]}); 560 | b.addPositional(); 561 | b.addPositional(); 562 | b.addProxy(); 563 | }, 564 | ]); 565 | 566 | const {options, positionals} = cli.process([`foo`, `pos1`, `-x`, `pos2`, `proxy`]); 567 | 568 | expect(options).to.deep.equal([ 569 | {name: `-x`, value: true}, 570 | ]); 571 | 572 | expect(positionals).to.deep.equal([ 573 | {value: `pos1`, extra: false}, 574 | {value: `pos2`, extra: false}, 575 | {value: `proxy`, extra: NoLimits}, 576 | ]); 577 | }); 578 | 579 | it(`should ignore options when they appear in a proxy extra`, () => { 580 | const cli = makeCli([ 581 | b => { 582 | b.addPath([`foo`]); 583 | b.addOption({names: [`-x`]}); 584 | b.addProxy(); 585 | }, 586 | ]); 587 | 588 | const {selectedIndex, options, positionals} = cli.process([`foo`, `-x`]); 589 | expect(selectedIndex).to.equal(0); 590 | 591 | expect(options).to.deep.equal([ 592 | ]); 593 | 594 | expect(positionals).to.deep.equal([ 595 | {value: `-x`, extra: NoLimits}, 596 | ]); 597 | }); 598 | 599 | it(`should prefer exact commands over empty proxies`, () => { 600 | const cli = makeCli([ 601 | b => { 602 | b.addPath([`foo`]); 603 | }, 604 | b => { 605 | b.addPath([`foo`]); 606 | b.addProxy({required: 1}); 607 | }, 608 | ]); 609 | 610 | const {selectedIndex} = cli.process([`foo`]); 611 | expect(selectedIndex).to.equal(0); 612 | }); 613 | 614 | it(`should aggregate the options as they are found`, () => { 615 | const cli = makeCli([ 616 | b => { 617 | b.addOption({names: [`-x`]}); 618 | b.addOption({names: [`-y`]}); 619 | b.addOption({names: [`-z`]}); 620 | b.addOption({names: [`-u`], arity: 1}); 621 | b.addOption({names: [`-v`], arity: 1}); 622 | b.addOption({names: [`-w`], arity: 1}); 623 | }, 624 | ]); 625 | 626 | const {options: options1} = cli.process([`-x`, `-u`, `foo`, `-y`, `-v`, `bar`, `-y`]); 627 | expect(options1).to.deep.equal([ 628 | {name: `-x`, value: true}, 629 | {name: `-u`, value: `foo`}, 630 | {name: `-y`, value: true}, 631 | {name: `-v`, value: `bar`}, 632 | {name: `-y`, value: true}, 633 | ]); 634 | 635 | const {options: options2} = cli.process([`-z`, `-y`, `-x`]); 636 | expect(options2).to.deep.equal([ 637 | {name: `-z`, value: true}, 638 | {name: `-y`, value: true}, 639 | {name: `-x`, value: true}, 640 | ]); 641 | }); 642 | 643 | it(`should aggregate the mandatory arguments`, () => { 644 | const cli = makeCli([ 645 | b => { 646 | b.addPositional(); 647 | b.addPositional(); 648 | }, 649 | ]); 650 | 651 | const {positionals} = cli.process([`foo`, `bar`]); 652 | expect(positionals).to.deep.equal([ 653 | {value: `foo`, extra: false}, 654 | {value: `bar`, extra: false}, 655 | ]); 656 | }); 657 | 658 | it(`should aggregate the optional arguments`, () => { 659 | const cli = makeCli([ 660 | b => { 661 | b.addPositional({required: false}); 662 | b.addPositional({required: false}); 663 | }, 664 | ]); 665 | 666 | const {positionals} = cli.process([`foo`, `bar`]); 667 | expect(positionals).to.deep.equal([ 668 | {value: `foo`, extra: true}, 669 | {value: `bar`, extra: true}, 670 | ]); 671 | }); 672 | 673 | it(`should accept as few optional arguments as possible`, () => { 674 | const cli = makeCli([ 675 | b => { 676 | b.addPositional({required: false}); 677 | b.addPositional({required: false}); 678 | }, 679 | ]); 680 | 681 | const {positionals: positionals1} = cli.process([]); 682 | expect(positionals1).to.deep.equal([]); 683 | 684 | const {positionals: positionals2} = cli.process([`foo`]); 685 | expect(positionals2).to.deep.equal([ 686 | {value: `foo`, extra: true}, 687 | ]); 688 | }); 689 | 690 | it(`should accept a mix of mandatory and optional arguments`, () => { 691 | const cli = makeCli([ 692 | b => { 693 | b.addPositional(); 694 | b.addPositional({required: false}); 695 | }, 696 | ]); 697 | 698 | const {positionals: positionals1} = cli.process([`foo`]); 699 | expect(positionals1).to.deep.equal([ 700 | {value: `foo`, extra: false}, 701 | ]); 702 | 703 | const {positionals: positionals2} = cli.process([`foo`, `bar`]); 704 | expect(positionals2).to.deep.equal([ 705 | {value: `foo`, extra: false}, 706 | {value: `bar`, extra: true}, 707 | ]); 708 | }); 709 | 710 | it(`should accept any option as positional argument when proxies are enabled`, () => { 711 | const cli = makeCli([ 712 | b => { 713 | b.addProxy(); 714 | }, 715 | ]); 716 | 717 | const {positionals} = cli.process([`--foo`, `--bar`]); 718 | expect(positionals).to.deep.equal([ 719 | {value: `--foo`, extra: NoLimits}, 720 | {value: `--bar`, extra: NoLimits}, 721 | ]); 722 | }); 723 | 724 | it(`should throw acceptable errors when passing an extraneous option`, () => { 725 | const cli = makeCli([ 726 | () => { 727 | // Nothing to do 728 | }, 729 | ]); 730 | 731 | expect(() => { 732 | cli.process([`--foo`]); 733 | }).to.throw(`Unsupported option name ("--foo")`); 734 | }); 735 | 736 | it(`should throw acceptable errors when passing extraneous arguments`, () => { 737 | const cli = makeCli([ 738 | b => { 739 | // Nothing to do 740 | }, 741 | ]); 742 | 743 | expect(() => { 744 | cli.process([`foo`]); 745 | }).to.throw(`Extraneous positional argument ("foo")`); 746 | }); 747 | 748 | it(`should print the help when there's no argv on a CLI without default command`, () => { 749 | const cli = makeCli([ 750 | b => { 751 | b.addPath([`foo`]); 752 | }, 753 | ]); 754 | 755 | const {selectedIndex} = cli.process([]); 756 | expect(selectedIndex).to.equal(HELP_COMMAND_INDEX); 757 | }); 758 | 759 | it(`should throw acceptable errors when a command is incomplete (multiple choices)`, () => { 760 | const cli = makeCli([ 761 | b => { 762 | b.addPath([`foo`]); 763 | }, 764 | b => { 765 | b.addPath([`bar`]); 766 | }, 767 | ]); 768 | 769 | const {selectedIndex} = cli.process([]); 770 | expect(selectedIndex).to.equal(HELP_COMMAND_INDEX); 771 | }); 772 | 773 | it(`should throw acceptable errors when using an incomplete path`, () => { 774 | const cli = makeCli([ 775 | b => { 776 | b.addPath([`foo`, `bar`]); 777 | }, 778 | ]); 779 | 780 | expect(() => { 781 | cli.process([`foo`]); 782 | }).to.throw(`Command not found; did you mean`); 783 | }); 784 | 785 | it(`should throw acceptable errors when omitting mandatory positional arguments`, () => { 786 | const cli = makeCli([ 787 | b => { 788 | b.addPositional(); 789 | }, 790 | ]); 791 | 792 | expect(() => { 793 | cli.process([]); 794 | }).to.throw(`Not enough positional arguments`); 795 | }); 796 | 797 | it(`should throw acceptable errors when writing invalid arguments`, () => { 798 | const cli = makeCli([ 799 | b => { 800 | // Nothing to do 801 | }, 802 | ]); 803 | 804 | expect(() => { 805 | cli.process([`-%#@$%#()@`]); 806 | }).to.throw(`Invalid option name ("-%#@$%#()@")`); 807 | }); 808 | 809 | it(`should throw acceptable errors when writing bound boolean arguments`, () => { 810 | const cli = makeCli([ 811 | b => { 812 | b.addOption({names: [`--foo`], allowBinding: false}); 813 | }, 814 | ]); 815 | 816 | expect(() => { 817 | cli.process([`--foo=bar`]); 818 | }).to.throw(`Invalid option name ("--foo=bar")`); 819 | }); 820 | 821 | it(`should suggest simple commands (no input)`, () => { 822 | const cli = makeCli([ 823 | b => { 824 | b.addPath([`foo`]); 825 | }, 826 | ]); 827 | 828 | const suggestions = cli.suggest([], false); 829 | expect([...suggestions]).to.deep.equal([[`foo`]]); 830 | }); 831 | 832 | it(`should suggest simple commands (partial match)`, () => { 833 | const cli = makeCli([ 834 | b => { 835 | b.addPath([`foo`]); 836 | }, 837 | ]); 838 | 839 | const suggestions = cli.suggest([`fo`], true); 840 | expect([...suggestions]).to.deep.equal([[`o`]]); 841 | }); 842 | 843 | it(`should suggest simple commands (partial path)`, () => { 844 | const cli = makeCli([ 845 | b => { 846 | b.addPath([`foo`, `bar`]); 847 | }, 848 | ]); 849 | 850 | const suggestions = cli.suggest([`foo`], false); 851 | expect([...suggestions]).to.deep.equal([[`bar`]]); 852 | }); 853 | 854 | it(`should add a leading space for exact matches on partial paths`, () => { 855 | const cli = makeCli([ 856 | b => { 857 | b.addPath([`foo`, `bar`]); 858 | }, 859 | ]); 860 | 861 | const suggestions = cli.suggest([`foo`], true); 862 | expect([...suggestions]).to.deep.equal([[``, `bar`]]); 863 | }); 864 | 865 | it(`should return multiple suggestions when relevant (partial match)`, () => { 866 | const cli = makeCli([ 867 | b => { 868 | b.addPath([`foo1`]); 869 | }, 870 | b => { 871 | b.addPath([`foo2`]); 872 | }, 873 | ]); 874 | 875 | const suggestions = cli.suggest([`fo`], true); 876 | expect([...suggestions]).to.deep.equal([[`o1`], [`o2`]]); 877 | }); 878 | 879 | it(`should return multiple suggestions when relevant (no input)`, () => { 880 | const cli = makeCli([ 881 | b => { 882 | b.addPath([`foo1`]); 883 | }, 884 | b => { 885 | b.addPath([`foo2`]); 886 | }, 887 | ]); 888 | 889 | const suggestions = cli.suggest([], false); 890 | expect([...suggestions]).to.deep.equal([[`foo1`], [`foo2`]]); 891 | }); 892 | 893 | it(`should return multiple suggestions when relevant (partial paths)`, () => { 894 | const cli = makeCli([ 895 | b => { 896 | b.addPath([`foo`, `bar1`]); 897 | }, 898 | b => { 899 | b.addPath([`foo`, `bar2`]); 900 | }, 901 | ]); 902 | 903 | const suggestions = cli.suggest([`foo`], false); 904 | expect([...suggestions]).to.deep.equal([[`bar1`], [`bar2`]]); 905 | }); 906 | 907 | it(`should suggest options`, () => { 908 | const cli = makeCli([ 909 | b => { 910 | b.addPath([`foo`]); 911 | b.addOption({names: [`--bar`]}); 912 | }, 913 | ]); 914 | 915 | const suggestions = cli.suggest([`foo`], false); 916 | expect([...suggestions]).to.deep.equal([[`--bar`]]); 917 | }); 918 | 919 | it(`should suggest deep paths`, () => { 920 | const cli = makeCli([ 921 | b => { 922 | b.addPath([`foo`, `bar`]); 923 | }, 924 | ]); 925 | 926 | const suggestions = cli.suggest([], false); 927 | expect([...suggestions]).to.deep.equal([[`foo`, `bar`]]); 928 | }); 929 | 930 | it(`should suggest deep paths and stop at options`, () => { 931 | const cli = makeCli([ 932 | b => { 933 | b.addPath([`foo`, `bar`]); 934 | b.addOption({names: [`--hello`]}); 935 | }, 936 | ]); 937 | 938 | const suggestions = cli.suggest([], false); 939 | expect([...suggestions]).to.deep.equal([[`foo`, `bar`]]); 940 | }); 941 | 942 | it(`should suggest as many options as needed`, () => { 943 | const cli = makeCli([ 944 | b => { 945 | b.addPath([`foo`]); 946 | b.addOption({names: [`--hello`]}); 947 | b.addOption({names: [`--world`]}); 948 | }, 949 | ]); 950 | 951 | const suggestions = cli.suggest([`foo`], false); 952 | expect([...suggestions]).to.deep.equal([[`--hello`], [`--world`]]); 953 | }); 954 | 955 | it(`shouldn't suggest hidden options`, () => { 956 | const cli = makeCli([ 957 | b => { 958 | b.addPath([`foo`]); 959 | b.addOption({names: [`--hello`], hidden: true}); 960 | b.addOption({names: [`--world`]}); 961 | }, 962 | ]); 963 | 964 | const suggestions = cli.suggest([`foo`], false); 965 | expect([...suggestions]).to.deep.equal([[`--world`]]); 966 | }); 967 | 968 | it(`should only suggest the longest options`, () => { 969 | const cli = makeCli([ 970 | b => { 971 | b.addPath([`foo`]); 972 | b.addOption({names: [`-h`, `--hello`]}); 973 | }, 974 | ]); 975 | 976 | const suggestions = cli.suggest([`foo`], false); 977 | expect([...suggestions]).to.deep.equal([[`--hello`]]); 978 | }); 979 | }); 980 | -------------------------------------------------------------------------------- /sources/advanced/Cli.ts: -------------------------------------------------------------------------------- 1 | import {Readable, Writable} from 'stream'; 2 | 3 | import {HELP_COMMAND_INDEX} from '../constants'; 4 | import {CliBuilder, CommandBuilder} from '../core'; 5 | import {ErrorMeta} from '../errors'; 6 | import {formatMarkdownish, ColorFormat, richFormat, textFormat} from '../format'; 7 | import * as platform from '../platform'; 8 | 9 | import {CommandClass, Command, Definition} from './Command'; 10 | import {HelpCommand} from './HelpCommand'; 11 | import {CommandOption} from './options/utils'; 12 | 13 | const errorCommandSymbol = Symbol(`clipanion/errorCommand`); 14 | 15 | type MakeOptional = Omit & Partial>; 16 | type VoidIfEmpty = keyof T extends never ? void : never; 17 | 18 | /** 19 | * The base context of the CLI. 20 | * 21 | * All Contexts have to extend it. 22 | */ 23 | export type BaseContext = { 24 | /** 25 | * Environment variables. 26 | * 27 | * @default 28 | * process.env 29 | */ 30 | env: Record; 31 | 32 | /** 33 | * The input stream of the CLI. 34 | * 35 | * @default 36 | * process.stdin 37 | */ 38 | stdin: Readable; 39 | 40 | /** 41 | * The output stream of the CLI. 42 | * 43 | * @default 44 | * process.stdout 45 | */ 46 | stdout: Writable; 47 | 48 | /** 49 | * The error stream of the CLI. 50 | * 51 | * @default 52 | * process.stderr 53 | */ 54 | stderr: Writable; 55 | 56 | /** 57 | * Whether colors should be enabled. 58 | */ 59 | colorDepth: number; 60 | }; 61 | 62 | export type CliContext = { 63 | commandClass: CommandClass; 64 | }; 65 | 66 | export type UserContextKeys = Exclude; 67 | export type UserContext = Pick>; 68 | 69 | export type PartialContext = UserContextKeys extends never 70 | ? Partial> | undefined | void 71 | : Partial> & UserContext; 72 | 73 | export type RunContext = 74 | & Partial> 75 | & UserContext; 76 | 77 | export type RunCommand = 78 | | Array> 79 | | CommandClass; 80 | 81 | export type RunCommandNoContext = 82 | UserContextKeys extends never 83 | ? RunCommand 84 | : never; 85 | 86 | export type CliOptions = Readonly<{ 87 | /** 88 | * The label of the binary. 89 | * 90 | * Shown at the top of the usage information. 91 | */ 92 | binaryLabel?: string, 93 | 94 | /** 95 | * The name of the binary. 96 | * 97 | * Included in the path and the examples of the definitions. 98 | */ 99 | binaryName: string, 100 | 101 | /** 102 | * The version of the binary. 103 | * 104 | * Shown at the top of the usage information. 105 | */ 106 | binaryVersion?: string, 107 | 108 | /** 109 | * If `true`, the Cli will hook into the process standard streams to catch 110 | * the output produced by console.log and redirect them into the context 111 | * streams. Note: stdin isn't captured at the moment. 112 | * 113 | * @default 114 | * false 115 | */ 116 | enableCapture: boolean, 117 | 118 | /** 119 | * If `true`, the Cli will use colors in the output. If `false`, it won't. 120 | * If `undefined`, Clipanion will infer the correct value from the env. 121 | */ 122 | enableColors?: boolean, 123 | }>; 124 | 125 | export type MiniCli = CliOptions & { 126 | /** 127 | * Returns an Array representing the definitions of all registered commands. 128 | */ 129 | definitions(): Array; 130 | 131 | /** 132 | * Formats errors using colors. 133 | * 134 | * @param error The error to format. If `error.name` is `'Error'`, it is replaced with `'Internal Error'`. 135 | * @param opts.command The command whose usage will be included in the formatted error. 136 | */ 137 | error(error: Error, opts?: {command?: Command | null}): string; 138 | 139 | /** 140 | * Returns a rich color format if colors are enabled, or a plain text format otherwise. 141 | * 142 | * @param colored Forcefully enable or disable colors. 143 | */ 144 | format(colored?: boolean): ColorFormat; 145 | 146 | /** 147 | * Compiles a command and its arguments using the `CommandBuilder`. 148 | * 149 | * @param input An array containing the name of the command and its arguments 150 | * 151 | * @returns The compiled `Command`, with its properties populated with the arguments. 152 | */ 153 | process(input: Array, context?: Partial): Command; 154 | 155 | /** 156 | * Runs a command. 157 | * 158 | * @param input An array containing the name of the command and its arguments 159 | * @param context Overrides the Context of the main `Cli` instance 160 | * 161 | * @returns The exit code of the command 162 | */ 163 | run(input: Array, context?: Partial): Promise; 164 | 165 | /** 166 | * Returns the usage of a command. 167 | * 168 | * @param command The `Command` whose usage will be returned or `null` to return the usage of all commands. 169 | * @param opts.detailed If `true`, the usage of a command will also include its description, details, and examples. Doesn't have any effect if `command` is `null` or doesn't have a `usage` property. 170 | * @param opts.prefix The prefix displayed before each command. Defaults to `$`. 171 | */ 172 | usage(command?: CommandClass | Command | null, opts?: {detailed?: boolean, prefix?: string}): string; 173 | }; 174 | 175 | /** 176 | * An all-in-one helper that simultaneously instantiate a CLI and immediately 177 | * executes it. All parameters are optional except the command classes and 178 | * will be filled by sensible values for the current environment (for example 179 | * the argv argument will default to `process.argv`, etc). 180 | * 181 | * Just like `Cli#runExit`, this function will set the `process.exitCode` value 182 | * before returning. 183 | */ 184 | export async function runExit(commandClasses: RunCommandNoContext): Promise; 185 | export async function runExit(commandClasses: RunCommand, context: RunContext): Promise; 186 | 187 | export async function runExit(options: Partial, commandClasses: RunCommandNoContext): Promise; 188 | export async function runExit(options: Partial, commandClasses: RunCommand, context: RunContext): Promise; 189 | 190 | export async function runExit(commandClasses: RunCommandNoContext, argv: Array): Promise; 191 | export async function runExit(commandClasses: RunCommand, argv: Array, context: RunContext): Promise; 192 | 193 | export async function runExit(options: Partial, commandClasses: RunCommandNoContext, argv: Array): Promise; 194 | export async function runExit(options: Partial, commandClasses: RunCommand, argv: Array, context: RunContext): Promise; 195 | 196 | export async function runExit(...args: Array) { 197 | const { 198 | resolvedOptions, 199 | resolvedCommandClasses, 200 | resolvedArgv, 201 | resolvedContext, 202 | } = resolveRunParameters(args); 203 | 204 | const cli = Cli.from(resolvedCommandClasses, resolvedOptions); 205 | return cli.runExit(resolvedArgv, resolvedContext); 206 | } 207 | 208 | /** 209 | * An all-in-one helper that simultaneously instantiate a CLI and immediately 210 | * executes it. All parameters are optional except the command classes and 211 | * will be filled by sensible values for the current environment (for example 212 | * the argv argument will default to `process.argv`, etc). 213 | * 214 | * Unlike `runExit`, this function won't set the `process.exitCode` value 215 | * before returning. 216 | */ 217 | export async function run(commandClasses: RunCommandNoContext): Promise; 218 | export async function run(commandClasses: RunCommand, context: RunContext): Promise; 219 | 220 | export async function run(options: Partial, commandClasses: RunCommandNoContext): Promise; 221 | export async function run(options: Partial, commandClasses: RunCommand, context: RunContext): Promise; 222 | 223 | export async function run(commandClasses: RunCommandNoContext, argv: Array): Promise; 224 | export async function run(commandClasses: RunCommand, argv: Array, context: RunContext): Promise; 225 | 226 | export async function run(options: Partial, commandClasses: RunCommandNoContext, argv: Array): Promise; 227 | export async function run(options: Partial, commandClasses: RunCommand, argv: Array, context: RunContext): Promise; 228 | 229 | export async function run(...args: Array) { 230 | const { 231 | resolvedOptions, 232 | resolvedCommandClasses, 233 | resolvedArgv, 234 | resolvedContext, 235 | } = resolveRunParameters(args); 236 | 237 | const cli = Cli.from(resolvedCommandClasses, resolvedOptions); 238 | return cli.run(resolvedArgv, resolvedContext); 239 | } 240 | 241 | function resolveRunParameters(args: Array) { 242 | let resolvedOptions: any; 243 | let resolvedCommandClasses: any; 244 | let resolvedArgv: any; 245 | let resolvedContext: any; 246 | 247 | if (typeof process !== `undefined` && typeof process.argv !== `undefined`) 248 | resolvedArgv = process.argv.slice(2); 249 | 250 | switch (args.length) { 251 | case 1: { 252 | resolvedCommandClasses = args[0]; 253 | } break; 254 | 255 | case 2: { 256 | if (args[0] && (args[0].prototype instanceof Command) || Array.isArray(args[0])) { 257 | resolvedCommandClasses = args[0]; 258 | if (Array.isArray(args[1])) { 259 | resolvedArgv = args[1]; 260 | } else { 261 | resolvedContext = args[1]; 262 | } 263 | } else { 264 | resolvedOptions = args[0]; 265 | resolvedCommandClasses = args[1]; 266 | } 267 | } break; 268 | 269 | case 3: { 270 | if (Array.isArray(args[2])) { 271 | resolvedOptions = args[0]; 272 | resolvedCommandClasses = args[1]; 273 | resolvedArgv = args[2]; 274 | } else if (args[0] && (args[0].prototype instanceof Command) || Array.isArray(args[0])) { 275 | resolvedCommandClasses = args[0]; 276 | resolvedArgv = args[1]; 277 | resolvedContext = args[2]; 278 | } else { 279 | resolvedOptions = args[0]; 280 | resolvedCommandClasses = args[1]; 281 | resolvedContext = args[2]; 282 | } 283 | } break; 284 | 285 | default: { 286 | resolvedOptions = args[0]; 287 | resolvedCommandClasses = args[1]; 288 | resolvedArgv = args[2]; 289 | resolvedContext = args[3]; 290 | } break; 291 | } 292 | 293 | if (typeof resolvedArgv === `undefined`) 294 | throw new Error(`The argv parameter must be provided when running Clipanion outside of a Node context`); 295 | 296 | return { 297 | resolvedOptions, 298 | resolvedCommandClasses, 299 | resolvedArgv, 300 | resolvedContext, 301 | }; 302 | } 303 | 304 | /** 305 | * @template Context The context shared by all commands. Contexts are a set of values, defined when calling the `run`/`runExit` functions from the CLI instance, that will be made available to the commands via `this.context`. 306 | */ 307 | export class Cli implements Omit, `process` | `run`> { 308 | /** 309 | * The default context of the CLI. 310 | * 311 | * Contains the stdio of the current `process`. 312 | */ 313 | static defaultContext = { 314 | env: process.env, 315 | stdin: process.stdin, 316 | stdout: process.stdout, 317 | stderr: process.stderr, 318 | colorDepth: platform.getDefaultColorDepth(), 319 | }; 320 | 321 | private readonly builder: CliBuilder>; 322 | 323 | protected readonly registrations: Map, { 324 | index: number, 325 | builder: CommandBuilder>, 326 | specs: Map>, 327 | }> = new Map(); 328 | 329 | public readonly binaryLabel?: string; 330 | public readonly binaryName: string; 331 | public readonly binaryVersion?: string; 332 | 333 | public readonly enableCapture: boolean; 334 | public readonly enableColors?: boolean; 335 | 336 | /** 337 | * Creates a new Cli and registers all commands passed as parameters. 338 | * 339 | * @param commandClasses The Commands to register 340 | * @returns The created `Cli` instance 341 | */ 342 | static from(commandClasses: RunCommand, options: Partial = {}) { 343 | const cli = new Cli(options); 344 | 345 | const resolvedCommandClasses = Array.isArray(commandClasses) 346 | ? commandClasses 347 | : [commandClasses]; 348 | 349 | for (const commandClass of resolvedCommandClasses) 350 | cli.register(commandClass); 351 | 352 | return cli; 353 | } 354 | 355 | constructor({binaryLabel, binaryName: binaryNameOpt = `...`, binaryVersion, enableCapture = false, enableColors}: Partial = {}) { 356 | this.builder = new CliBuilder({binaryName: binaryNameOpt}); 357 | 358 | this.binaryLabel = binaryLabel; 359 | this.binaryName = binaryNameOpt; 360 | this.binaryVersion = binaryVersion; 361 | 362 | this.enableCapture = enableCapture; 363 | this.enableColors = enableColors; 364 | } 365 | 366 | /** 367 | * Registers a command inside the CLI. 368 | */ 369 | register(commandClass: CommandClass) { 370 | const specs = new Map>(); 371 | 372 | const command = new commandClass(); 373 | for (const key in command) { 374 | const value = (command as any)[key]; 375 | if (typeof value === `object` && value !== null && value[Command.isOption]) { 376 | specs.set(key, value); 377 | } 378 | } 379 | 380 | const builder = this.builder.command(); 381 | const index = builder.cliIndex; 382 | 383 | const paths = commandClass.paths ?? command.paths; 384 | if (typeof paths !== `undefined`) 385 | for (const path of paths) 386 | builder.addPath(path); 387 | 388 | this.registrations.set(commandClass, {specs, builder, index}); 389 | 390 | for (const [key, {definition}] of specs.entries()) 391 | definition(builder, key); 392 | 393 | builder.setContext({ 394 | commandClass, 395 | }); 396 | } 397 | 398 | process(input: Array, context: VoidIfEmpty>): Command; 399 | process(input: Array, context: MakeOptional): Command; 400 | process(input: Array, userContext: any) { 401 | const {contexts, process} = this.builder.compile(); 402 | const state = process(input); 403 | 404 | const context = { 405 | ...Cli.defaultContext, 406 | ...userContext, 407 | } as Context; 408 | 409 | switch (state.selectedIndex) { 410 | case HELP_COMMAND_INDEX: { 411 | const command = HelpCommand.from(state, contexts); 412 | command.context = context; 413 | 414 | return command; 415 | } break; 416 | 417 | default: { 418 | const {commandClass} = contexts[state.selectedIndex!]; 419 | 420 | const record = this.registrations.get(commandClass); 421 | if (typeof record === `undefined`) 422 | throw new Error(`Assertion failed: Expected the command class to have been registered.`); 423 | 424 | const command = new commandClass(); 425 | command.context = context; 426 | command.path = state.path; 427 | 428 | try { 429 | for (const [key, {transformer}] of record.specs.entries()) 430 | (command as any)[key] = transformer(record.builder, key, state, context); 431 | 432 | return command; 433 | } catch (error: any) { 434 | error[errorCommandSymbol] = command; 435 | throw error; 436 | } 437 | } break; 438 | } 439 | } 440 | 441 | async run(input: Command | Array, context: VoidIfEmpty>): Promise; 442 | async run(input: Command | Array, context: MakeOptional): Promise; 443 | async run(input: Command | Array, userContext: any) { 444 | let command: Command; 445 | 446 | const context = { 447 | ...Cli.defaultContext, 448 | ...userContext, 449 | } as Context; 450 | 451 | const colored = this.enableColors ?? context.colorDepth > 1; 452 | 453 | if (!Array.isArray(input)) { 454 | command = input; 455 | } else { 456 | try { 457 | command = this.process(input, context); 458 | } catch (error) { 459 | context.stdout.write(this.error(error, {colored})); 460 | return 1; 461 | } 462 | } 463 | 464 | if (command.help) { 465 | context.stdout.write(this.usage(command, {colored, detailed: true})); 466 | return 0; 467 | } 468 | 469 | command.context = context; 470 | command.cli = { 471 | binaryLabel: this.binaryLabel, 472 | binaryName: this.binaryName, 473 | binaryVersion: this.binaryVersion, 474 | enableCapture: this.enableCapture, 475 | enableColors: this.enableColors, 476 | definitions: () => this.definitions(), 477 | error: (error, opts) => this.error(error, opts), 478 | format: colored => this.format(colored), 479 | process: (input, subContext?) => this.process(input, {...context, ...subContext}), 480 | run: (input, subContext?) => this.run(input, {...context, ...subContext} as Context), 481 | usage: (command, opts) => this.usage(command, opts), 482 | }; 483 | 484 | const activate = this.enableCapture 485 | ? platform.getCaptureActivator(context) ?? noopCaptureActivator 486 | : noopCaptureActivator; 487 | 488 | let exitCode; 489 | try { 490 | exitCode = await activate(() => command.validateAndExecute().catch(error => command.catch(error).then(() => 0))); 491 | } catch (error) { 492 | context.stdout.write(this.error(error, {colored, command})); 493 | return 1; 494 | } 495 | 496 | return exitCode; 497 | } 498 | 499 | /** 500 | * Runs a command and exits the current `process` with the exit code returned by the command. 501 | * 502 | * @param input An array containing the name of the command and its arguments. 503 | * 504 | * @example 505 | * cli.runExit(process.argv.slice(2)) 506 | */ 507 | async runExit(input: Command | Array, context: VoidIfEmpty>): Promise; 508 | async runExit(input: Command | Array, context: MakeOptional): Promise; 509 | async runExit(input: Command | Array, context: any) { 510 | process.exitCode = await this.run(input, context); 511 | } 512 | 513 | suggest(input: Array, partial: boolean) { 514 | const {suggest} = this.builder.compile(); 515 | return suggest(input, partial); 516 | } 517 | 518 | definitions({colored = false}: {colored?: boolean} = {}): Array { 519 | const data: Array = []; 520 | 521 | for (const [commandClass, {index}] of this.registrations) { 522 | if (typeof commandClass.usage === `undefined`) 523 | continue; 524 | 525 | const {usage: path} = this.getUsageByIndex(index, {detailed: false}); 526 | const {usage, options} = this.getUsageByIndex(index, {detailed: true, inlineOptions: false}); 527 | 528 | const category = typeof commandClass.usage.category !== `undefined` 529 | ? formatMarkdownish(commandClass.usage.category, {format: this.format(colored), paragraphs: false}) 530 | : undefined; 531 | 532 | const description = typeof commandClass.usage.description !== `undefined` 533 | ? formatMarkdownish(commandClass.usage.description, {format: this.format(colored), paragraphs: false}) 534 | : undefined; 535 | 536 | const details = typeof commandClass.usage.details !== `undefined` 537 | ? formatMarkdownish(commandClass.usage.details, {format: this.format(colored), paragraphs: true}) 538 | : undefined; 539 | 540 | const examples: Definition['examples'] = typeof commandClass.usage.examples !== `undefined` 541 | ? commandClass.usage.examples.map(([label, cli]) => [formatMarkdownish(label, {format: this.format(colored), paragraphs: false}), cli.replace(/\$0/g, this.binaryName)]) 542 | : undefined; 543 | 544 | data.push({path, usage, category, description, details, examples, options}); 545 | } 546 | 547 | return data; 548 | } 549 | 550 | usage(command: CommandClass | Command | null = null, {colored, detailed = false, prefix = `$ `}: {colored?: boolean, detailed?: boolean, prefix?: string} = {}) { 551 | // In case the default command is the only one, we can just show the command help rather than the general one 552 | if (command === null) { 553 | for (const commandClass of this.registrations.keys()) { 554 | const paths = commandClass.paths; 555 | 556 | const isDocumented = typeof commandClass.usage !== `undefined`; 557 | const isExclusivelyDefault = !paths || paths.length === 0 || (paths.length === 1 && paths[0].length === 0); 558 | const isDefault = isExclusivelyDefault || (paths?.some(path => path.length === 0) ?? false); 559 | 560 | if (isDefault) { 561 | if (command) { 562 | command = null; 563 | break; 564 | } else { 565 | command = commandClass; 566 | } 567 | } else { 568 | if (isDocumented) { 569 | command = null; 570 | continue; 571 | } 572 | } 573 | } 574 | 575 | if (command) { 576 | detailed = true; 577 | } 578 | } 579 | 580 | // @ts-ignore 581 | const commandClass = command !== null && command instanceof Command 582 | ? command.constructor as CommandClass 583 | : command as CommandClass | null; 584 | 585 | let result = ``; 586 | 587 | if (!commandClass) { 588 | const commandsByCategories = new Map; 590 | usage: string; 591 | }>>(); 592 | 593 | for (const [commandClass, {index}] of this.registrations.entries()) { 594 | if (typeof commandClass.usage === `undefined`) 595 | continue; 596 | 597 | const category = typeof commandClass.usage.category !== `undefined` 598 | ? formatMarkdownish(commandClass.usage.category, {format: this.format(colored), paragraphs: false}) 599 | : null; 600 | 601 | let categoryCommands = commandsByCategories.get(category); 602 | if (typeof categoryCommands === `undefined`) 603 | commandsByCategories.set(category, categoryCommands = []); 604 | 605 | const {usage} = this.getUsageByIndex(index); 606 | categoryCommands.push({commandClass, usage}); 607 | } 608 | 609 | const categoryNames = Array.from(commandsByCategories.keys()).sort((a, b) => { 610 | if (a === null) return -1; 611 | if (b === null) return +1; 612 | return a.localeCompare(b, `en`, {usage: `sort`, caseFirst: `upper`}); 613 | }); 614 | 615 | const hasLabel = typeof this.binaryLabel !== `undefined`; 616 | const hasVersion = typeof this.binaryVersion !== `undefined`; 617 | 618 | if (hasLabel || hasVersion) { 619 | if (hasLabel && hasVersion) 620 | result += `${this.format(colored).header(`${this.binaryLabel} - ${this.binaryVersion}`)}\n\n`; 621 | else if (hasLabel) 622 | result += `${this.format(colored).header(`${this.binaryLabel}`)}\n`; 623 | else 624 | result += `${this.format(colored).header(`${this.binaryVersion}`)}\n`; 625 | 626 | result += ` ${this.format(colored).bold(prefix)}${this.binaryName} \n`; 627 | } else { 628 | result += `${this.format(colored).bold(prefix)}${this.binaryName} \n`; 629 | } 630 | 631 | for (const categoryName of categoryNames) { 632 | const commands = commandsByCategories.get(categoryName)!.slice().sort((a, b) => { 633 | return a.usage.localeCompare(b.usage, `en`, {usage: `sort`, caseFirst: `upper`}); 634 | }); 635 | 636 | const header = categoryName !== null 637 | ? categoryName.trim() 638 | : `General commands`; 639 | 640 | result += `\n`; 641 | result += `${this.format(colored).header(`${header}`)}\n`; 642 | 643 | for (const {commandClass, usage} of commands) { 644 | const doc = commandClass.usage!.description || `undocumented`; 645 | 646 | result += `\n`; 647 | result += ` ${this.format(colored).bold(usage)}\n`; 648 | result += ` ${formatMarkdownish(doc, {format: this.format(colored), paragraphs: false})}`; 649 | } 650 | } 651 | 652 | result += `\n`; 653 | result += formatMarkdownish(`You can also print more details about any of these commands by calling them with the \`-h,--help\` flag right after the command name.`, {format: this.format(colored), paragraphs: true}); 654 | } else { 655 | if (!detailed) { 656 | const {usage} = this.getUsageByRegistration(commandClass); 657 | result += `${this.format(colored).bold(prefix)}${usage}\n`; 658 | } else { 659 | const { 660 | description = ``, 661 | details = ``, 662 | examples = [], 663 | } = commandClass.usage || {}; 664 | 665 | if (description !== ``) { 666 | result += formatMarkdownish(description, {format: this.format(colored), paragraphs: false}).replace(/^./, $0 => $0.toUpperCase()); 667 | result += `\n`; 668 | } 669 | 670 | if (details !== `` || examples.length > 0) { 671 | result += `${this.format(colored).header(`Usage`)}\n`; 672 | result += `\n`; 673 | } 674 | 675 | const {usage, options} = this.getUsageByRegistration(commandClass, {inlineOptions: false}); 676 | 677 | result += `${this.format(colored).bold(prefix)}${usage}\n`; 678 | 679 | if (options.length > 0) { 680 | result += `\n`; 681 | result += `${this.format(colored).header(`Options`)}\n`; 682 | 683 | const maxDefinitionLength = options.reduce((length, option) => { 684 | return Math.max(length, option.definition.length); 685 | }, 0); 686 | 687 | result += `\n`; 688 | 689 | for (const {definition, description} of options) { 690 | result += ` ${this.format(colored).bold(definition.padEnd(maxDefinitionLength))} ${formatMarkdownish(description, {format: this.format(colored), paragraphs: false})}`; 691 | } 692 | } 693 | 694 | if (details !== ``) { 695 | result += `\n`; 696 | result += `${this.format(colored).header(`Details`)}\n`; 697 | result += `\n`; 698 | 699 | result += formatMarkdownish(details, {format: this.format(colored), paragraphs: true}); 700 | } 701 | 702 | if (examples.length > 0) { 703 | result += `\n`; 704 | result += `${this.format(colored).header(`Examples`)}\n`; 705 | 706 | for (const [description, example] of examples) { 707 | result += `\n`; 708 | result += formatMarkdownish(description, {format: this.format(colored), paragraphs: false}); 709 | result += `${example 710 | .replace(/^/m, ` ${this.format(colored).bold(prefix)}`) 711 | .replace(/\$0/g, this.binaryName) 712 | }\n`; 713 | } 714 | } 715 | } 716 | } 717 | 718 | return result; 719 | } 720 | 721 | error(error: Error | any, {colored, command = error[errorCommandSymbol] ?? null}: {colored?: boolean, command?: Command | null} = {}) { 722 | if (!(error instanceof Error)) 723 | error = new Error(`Execution failed with a non-error rejection (rejected value: ${JSON.stringify(error)})`); 724 | 725 | let result = ``; 726 | 727 | let name = error.name.replace(/([a-z])([A-Z])/g, `$1 $2`); 728 | if (name === `Error`) 729 | name = `Internal Error`; 730 | 731 | result += `${this.format(colored).error(name)}: ${error.message}\n`; 732 | 733 | const meta = error.clipanion as ErrorMeta | undefined; 734 | 735 | if (typeof meta !== `undefined`) { 736 | if (meta.type === `usage`) { 737 | result += `\n`; 738 | result += this.usage(command); 739 | } 740 | } else { 741 | if (error.stack) { 742 | result += `${error.stack.replace(/^.*\n/, ``)}\n`; 743 | } 744 | } 745 | 746 | return result; 747 | } 748 | 749 | format(colored?: boolean): ColorFormat { 750 | return colored ?? this.enableColors ?? Cli.defaultContext.colorDepth > 1 ? richFormat : textFormat; 751 | } 752 | 753 | protected getUsageByRegistration(klass: CommandClass, opts?: {detailed?: boolean; inlineOptions?: boolean}) { 754 | const record = this.registrations.get(klass); 755 | if (typeof record === `undefined`) 756 | throw new Error(`Assertion failed: Unregistered command`); 757 | 758 | return this.getUsageByIndex(record.index, opts); 759 | } 760 | 761 | protected getUsageByIndex(n: number, opts?: {detailed?: boolean; inlineOptions?: boolean}) { 762 | return this.builder.getBuilderByIndex(n).usage(opts); 763 | } 764 | } 765 | 766 | function noopCaptureActivator(fn: () => Promise) { 767 | return fn(); 768 | } 769 | -------------------------------------------------------------------------------- /sources/core.ts: -------------------------------------------------------------------------------- 1 | import * as errors from './errors'; 2 | 3 | import { 4 | BATCH_REGEX, BINDING_REGEX, END_OF_INPUT, 5 | HELP_COMMAND_INDEX, HELP_REGEX, NODE_ERRORED, 6 | NODE_INITIAL, NODE_SUCCESS, OPTION_REGEX, 7 | START_OF_INPUT, DEBUG, 8 | } from './constants'; 9 | 10 | declare const console: any; 11 | 12 | // ------------------------------------------------------------------------ 13 | 14 | export function debug(str: string) { 15 | if (DEBUG) { 16 | console.log(str); 17 | } 18 | } 19 | 20 | // ------------------------------------------------------------------------ 21 | 22 | export type StateMachine = { 23 | nodes: Array; 24 | }; 25 | 26 | export type RunState = { 27 | candidateUsage: string | null; 28 | requiredOptions: Array>; 29 | errorMessage: string | null; 30 | ignoreOptions: boolean; 31 | options: Array<{name: string, value: any}>; 32 | path: Array; 33 | positionals: Array<{value: string, extra: boolean | typeof NoLimits}>; 34 | remainder: string | null; 35 | selectedIndex: number | null; 36 | }; 37 | 38 | const basicHelpState: RunState = { 39 | candidateUsage: null, 40 | requiredOptions: [], 41 | errorMessage: null, 42 | ignoreOptions: false, 43 | path: [], 44 | positionals: [], 45 | options: [], 46 | remainder: null, 47 | selectedIndex: HELP_COMMAND_INDEX, 48 | }; 49 | 50 | export function makeStateMachine(): StateMachine { 51 | return { 52 | nodes: [makeNode(), makeNode(), makeNode()], 53 | }; 54 | } 55 | 56 | export function makeAnyOfMachine(inputs: Array) { 57 | const output = makeStateMachine(); 58 | const heads = []; 59 | 60 | let offset = output.nodes.length; 61 | 62 | for (const input of inputs) { 63 | heads.push(offset); 64 | 65 | for (let t = 0; t < input.nodes.length; ++t) 66 | if (!isTerminalNode(t)) 67 | output.nodes.push(cloneNode(input.nodes[t], offset)); 68 | 69 | offset += input.nodes.length - 2; 70 | } 71 | 72 | for (const head of heads) 73 | registerShortcut(output, NODE_INITIAL, head); 74 | 75 | return output; 76 | } 77 | 78 | export function injectNode(machine: StateMachine, node: Node) { 79 | machine.nodes.push(node); 80 | return machine.nodes.length - 1; 81 | } 82 | 83 | export function simplifyMachine(input: StateMachine) { 84 | const visited = new Set(); 85 | 86 | const process = (node: number) => { 87 | if (visited.has(node)) 88 | return; 89 | 90 | visited.add(node); 91 | 92 | const nodeDef = input.nodes[node]; 93 | 94 | for (const transitions of Object.values(nodeDef.statics)) 95 | for (const {to} of transitions) 96 | process(to); 97 | for (const [,{to}] of nodeDef.dynamics) 98 | process(to); 99 | for (const {to} of nodeDef.shortcuts) 100 | process(to); 101 | 102 | const shortcuts = new Set(nodeDef.shortcuts.map(({to}) => to)); 103 | 104 | while (nodeDef.shortcuts.length > 0) { 105 | const {to} = nodeDef.shortcuts.shift()!; 106 | const toDef = input.nodes[to]; 107 | 108 | for (const [segment, transitions] of Object.entries(toDef.statics)) { 109 | const store = !Object.prototype.hasOwnProperty.call(nodeDef.statics, segment) 110 | ? nodeDef.statics[segment] = [] 111 | : nodeDef.statics[segment]; 112 | 113 | for (const transition of transitions) { 114 | if (!store.some(({to}) => transition.to === to)) { 115 | store.push(transition); 116 | } 117 | } 118 | } 119 | 120 | for (const [test, transition] of toDef.dynamics) 121 | if (!nodeDef.dynamics.some(([otherTest, {to}]) => test === otherTest && transition.to === to)) 122 | nodeDef.dynamics.push([test, transition]); 123 | 124 | for (const transition of toDef.shortcuts) { 125 | if (!shortcuts.has(transition.to)) { 126 | nodeDef.shortcuts.push(transition); 127 | shortcuts.add(transition.to); 128 | } 129 | } 130 | } 131 | }; 132 | 133 | process(NODE_INITIAL); 134 | } 135 | 136 | export function debugMachine(machine: StateMachine, {prefix = ``}: {prefix?: string} = {}) { 137 | // Don't iterate unless it's needed 138 | if (DEBUG) { 139 | debug(`${prefix}Nodes are:`); 140 | for (let t = 0; t < machine.nodes.length; ++t) { 141 | debug(`${prefix} ${t}: ${JSON.stringify(machine.nodes[t])}`); 142 | } 143 | } 144 | } 145 | 146 | export function runMachineInternal(machine: StateMachine, input: Array, partial: boolean = false) { 147 | debug(`Running a vm on ${JSON.stringify(input)}`); 148 | let branches: Array<{node: number, state: RunState}> = [{node: NODE_INITIAL, state: { 149 | candidateUsage: null, 150 | requiredOptions: [], 151 | errorMessage: null, 152 | ignoreOptions: false, 153 | options: [], 154 | path: [], 155 | positionals: [], 156 | remainder: null, 157 | selectedIndex: null, 158 | }}]; 159 | 160 | debugMachine(machine, {prefix: ` `}); 161 | 162 | const tokens = [START_OF_INPUT, ...input]; 163 | for (let t = 0; t < tokens.length; ++t) { 164 | const segment = tokens[t]; 165 | 166 | debug(` Processing ${JSON.stringify(segment)}`); 167 | const nextBranches: Array<{node: number, state: RunState}> = []; 168 | 169 | for (const {node, state} of branches) { 170 | debug(` Current node is ${node}`); 171 | const nodeDef = machine.nodes[node]; 172 | 173 | if (node === NODE_ERRORED) { 174 | nextBranches.push({node, state}); 175 | continue; 176 | } 177 | 178 | console.assert( 179 | nodeDef.shortcuts.length === 0, 180 | `Shortcuts should have been eliminated by now`, 181 | ); 182 | 183 | const hasExactMatch = Object.prototype.hasOwnProperty.call(nodeDef.statics, segment); 184 | if (!partial || t < tokens.length - 1 || hasExactMatch) { 185 | if (hasExactMatch) { 186 | const transitions = nodeDef.statics[segment]; 187 | for (const {to, reducer} of transitions) { 188 | nextBranches.push({node: to, state: typeof reducer !== `undefined` ? execute(reducers, reducer, state, segment) : state}); 189 | debug(` Static transition to ${to} found`); 190 | } 191 | } else { 192 | debug(` No static transition found`); 193 | } 194 | } else { 195 | let hasMatches = false; 196 | 197 | for (const candidate of Object.keys(nodeDef.statics)) { 198 | if (!candidate.startsWith(segment)) 199 | continue; 200 | 201 | if (segment === candidate) { 202 | for (const {to, reducer} of nodeDef.statics[candidate]) { 203 | nextBranches.push({node: to, state: typeof reducer !== `undefined` ? execute(reducers, reducer, state, segment) : state}); 204 | debug(` Static transition to ${to} found`); 205 | } 206 | } else { 207 | for (const {to} of nodeDef.statics[candidate]) { 208 | nextBranches.push({node: to, state: {...state, remainder: candidate.slice(segment.length)}}); 209 | debug(` Static transition to ${to} found (partial match)`); 210 | } 211 | } 212 | 213 | hasMatches = true; 214 | } 215 | 216 | if (!hasMatches) { 217 | debug(` No partial static transition found`); 218 | } 219 | } 220 | 221 | if (segment !== END_OF_INPUT) { 222 | for (const [test, {to, reducer}] of nodeDef.dynamics) { 223 | if (execute(tests, test, state, segment)) { 224 | nextBranches.push({node: to, state: typeof reducer !== `undefined` ? execute(reducers, reducer, state, segment) : state}); 225 | debug(` Dynamic transition to ${to} found (via ${test})`); 226 | } 227 | } 228 | } 229 | } 230 | 231 | if (nextBranches.length === 0 && segment === END_OF_INPUT && input.length === 1) { 232 | return [{ 233 | node: NODE_INITIAL, 234 | state: basicHelpState, 235 | }]; 236 | } 237 | 238 | if (nextBranches.length === 0) { 239 | throw new errors.UnknownSyntaxError(input, branches.filter(({node}) => { 240 | return node !== NODE_ERRORED; 241 | }).map(({state}) => { 242 | return {usage: state.candidateUsage!, reason: null}; 243 | })); 244 | } 245 | 246 | if (nextBranches.every(({node}) => node === NODE_ERRORED)) { 247 | throw new errors.UnknownSyntaxError(input, nextBranches.map(({state}) => { 248 | return {usage: state.candidateUsage!, reason: state.errorMessage}; 249 | })); 250 | } 251 | 252 | branches = trimSmallerBranches(nextBranches); 253 | } 254 | 255 | if (branches.length > 0) { 256 | debug(` Results:`); 257 | for (const branch of branches) { 258 | debug(` - ${branch.node} -> ${JSON.stringify(branch.state)}`); 259 | } 260 | } else { 261 | debug(` No results`); 262 | } 263 | 264 | return branches; 265 | } 266 | 267 | function checkIfNodeIsFinished(node: Node, state: RunState) { 268 | if (state.selectedIndex !== null) 269 | return true; 270 | 271 | if (Object.prototype.hasOwnProperty.call(node.statics, END_OF_INPUT)) 272 | for (const {to} of node.statics[END_OF_INPUT]) 273 | if (to === NODE_SUCCESS) 274 | return true; 275 | 276 | return false; 277 | } 278 | 279 | function suggestMachine(machine: StateMachine, input: Array, partial: boolean) { 280 | // If we're accepting partial matches, then exact matches need to be 281 | // prefixed with an extra space. 282 | const prefix = partial && input.length > 0 ? [``] : []; 283 | 284 | const branches = runMachineInternal(machine, input, partial); 285 | 286 | const suggestions: Array> = []; 287 | const suggestionsJson = new Set(); 288 | 289 | const traverseSuggestion = (suggestion: Array, node: number, skipFirst: boolean = true) => { 290 | let nextNodes = [node]; 291 | 292 | while (nextNodes.length > 0) { 293 | const currentNodes = nextNodes; 294 | nextNodes = []; 295 | 296 | for (const node of currentNodes) { 297 | const nodeDef = machine.nodes[node]; 298 | const keys = Object.keys(nodeDef.statics); 299 | 300 | // The fact that `key` is unused is likely a bug, but no one has investigated it yet. 301 | // TODO: Investigate it. 302 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 303 | for (const key of Object.keys(nodeDef.statics)) { 304 | const segment = keys[0]; 305 | 306 | for (const {to, reducer} of nodeDef.statics[segment]) { 307 | if (reducer !== `pushPath`) 308 | continue; 309 | 310 | if (!skipFirst) 311 | suggestion.push(segment); 312 | 313 | nextNodes.push(to); 314 | } 315 | } 316 | } 317 | 318 | skipFirst = false; 319 | } 320 | 321 | const json = JSON.stringify(suggestion); 322 | if (suggestionsJson.has(json)) 323 | return; 324 | 325 | suggestions.push(suggestion); 326 | suggestionsJson.add(json); 327 | }; 328 | 329 | for (const {node, state} of branches) { 330 | if (state.remainder !== null) { 331 | traverseSuggestion([state.remainder], node); 332 | continue; 333 | } 334 | 335 | const nodeDef = machine.nodes[node]; 336 | const isFinished = checkIfNodeIsFinished(nodeDef, state); 337 | 338 | for (const [candidate, transitions] of Object.entries(nodeDef.statics)) 339 | if ((isFinished && candidate !== END_OF_INPUT) || (!candidate.startsWith(`-`) && transitions.some(({reducer}) => reducer === `pushPath`))) 340 | traverseSuggestion([...prefix, candidate], node); 341 | 342 | if (!isFinished) 343 | continue; 344 | 345 | for (const [test, {to}] of nodeDef.dynamics) { 346 | if (to === NODE_ERRORED) 347 | continue; 348 | 349 | const tokens = suggest(test, state); 350 | if (tokens === null) 351 | continue; 352 | 353 | for (const token of tokens) { 354 | traverseSuggestion([...prefix, token], node); 355 | } 356 | } 357 | } 358 | 359 | return [...suggestions].sort(); 360 | } 361 | 362 | function runMachine(machine: StateMachine, input: Array) { 363 | const branches = runMachineInternal(machine, [...input, END_OF_INPUT]); 364 | 365 | return selectBestState(input, branches.map(({state}) => { 366 | return state; 367 | })); 368 | } 369 | 370 | export function trimSmallerBranches(branches: Array<{node: number, state: RunState}>) { 371 | let maxPathSize = 0; 372 | for (const {state} of branches) 373 | if (state.path.length > maxPathSize) 374 | maxPathSize = state.path.length; 375 | 376 | return branches.filter(({state}) => { 377 | return state.path.length === maxPathSize; 378 | }); 379 | } 380 | 381 | export function selectBestState(input: Array, states: Array) { 382 | const terminalStates = states.filter(state => { 383 | return state.selectedIndex !== null; 384 | }); 385 | 386 | if (terminalStates.length === 0) 387 | throw new Error(); 388 | 389 | const requiredOptionsSetStates = terminalStates.filter(state => 390 | state.selectedIndex === HELP_COMMAND_INDEX || state.requiredOptions.every(names => 391 | names.some(name => 392 | state.options.find(opt => opt.name === name) 393 | ) 394 | ) 395 | ); 396 | 397 | if (requiredOptionsSetStates.length === 0) { 398 | throw new errors.UnknownSyntaxError(input, terminalStates.map(state => ({ 399 | usage: state.candidateUsage!, 400 | reason: null, 401 | }))); 402 | } 403 | 404 | let maxPathSize = 0; 405 | for (const state of requiredOptionsSetStates) 406 | if (state.path.length > maxPathSize) 407 | maxPathSize = state.path.length; 408 | 409 | const bestPathBranches = requiredOptionsSetStates.filter(state => { 410 | return state.path.length === maxPathSize; 411 | }); 412 | 413 | const getPositionalCount = (state: RunState) => state.positionals.filter(({extra}) => { 414 | return !extra; 415 | }).length + state.options.length; 416 | 417 | const statesWithPositionalCount = bestPathBranches.map(state => { 418 | return {state, positionalCount: getPositionalCount(state)}; 419 | }); 420 | 421 | let maxPositionalCount = 0; 422 | for (const {positionalCount} of statesWithPositionalCount) 423 | if (positionalCount > maxPositionalCount) 424 | maxPositionalCount = positionalCount; 425 | 426 | const bestPositionalStates = statesWithPositionalCount.filter(({positionalCount}) => { 427 | return positionalCount === maxPositionalCount; 428 | }).map(({state}) => { 429 | return state; 430 | }); 431 | 432 | const fixedStates = aggregateHelpStates(bestPositionalStates); 433 | if (fixedStates.length > 1) 434 | throw new errors.AmbiguousSyntaxError(input, fixedStates.map(state => state.candidateUsage!)); 435 | 436 | return fixedStates[0]; 437 | } 438 | 439 | export function aggregateHelpStates(states: Array) { 440 | const notHelps: Array = []; 441 | const helps: Array = []; 442 | 443 | for (const state of states) { 444 | if (state.selectedIndex === HELP_COMMAND_INDEX) { 445 | helps.push(state); 446 | } else { 447 | notHelps.push(state); 448 | } 449 | } 450 | 451 | if (helps.length > 0) { 452 | notHelps.push({ 453 | ...basicHelpState, 454 | path: findCommonPrefix(...helps.map(state => state.path)), 455 | options: helps.reduce((options, state) => options.concat(state.options), [] as RunState['options']), 456 | }); 457 | } 458 | 459 | return notHelps; 460 | } 461 | 462 | function findCommonPrefix(...paths: Array>): Array; 463 | function findCommonPrefix(firstPath: Array, secondPath: Array|undefined, ...rest: Array>): Array { 464 | if (secondPath === undefined) 465 | return Array.from(firstPath); 466 | 467 | return findCommonPrefix( 468 | firstPath.filter((segment, i) => segment === secondPath[i]), 469 | ...rest 470 | ); 471 | } 472 | 473 | // ------------------------------------------------------------------------ 474 | 475 | type Transition = { 476 | to: number; 477 | reducer?: Callback; 478 | }; 479 | 480 | type Node = { 481 | dynamics: Array<[Callback, Transition]>; 482 | shortcuts: Array; 483 | statics: {[segment: string]: Array}; 484 | }; 485 | 486 | export function makeNode(): Node { 487 | return { 488 | dynamics: [], 489 | shortcuts: [], 490 | statics: {}, 491 | }; 492 | } 493 | 494 | export function isTerminalNode(node: number) { 495 | return node === NODE_SUCCESS || node === NODE_ERRORED; 496 | } 497 | 498 | export function cloneTransition(input: Transition, offset: number = 0) { 499 | return { 500 | to: !isTerminalNode(input.to) ? input.to > 2 ? input.to + offset - 2 : input.to + offset : input.to, 501 | reducer: input.reducer, 502 | }; 503 | } 504 | 505 | export function cloneNode(input: Node, offset: number = 0) { 506 | const output = makeNode(); 507 | 508 | for (const [test, transition] of input.dynamics) 509 | output.dynamics.push([test, cloneTransition(transition, offset)]); 510 | 511 | for (const transition of input.shortcuts) 512 | output.shortcuts.push(cloneTransition(transition, offset)); 513 | 514 | for (const [segment, transitions] of Object.entries(input.statics)) 515 | output.statics[segment] = transitions.map(transition => cloneTransition(transition, offset)); 516 | 517 | return output; 518 | } 519 | 520 | export function registerDynamic(machine: StateMachine, from: number, test: Callback, to: number, reducer?: Callback) { 521 | machine.nodes[from].dynamics.push([ 522 | test as Callback, 523 | {to, reducer: reducer as Callback}, 524 | ]); 525 | } 526 | 527 | export function registerShortcut(machine: StateMachine, from: number, to: number, reducer?: Callback) { 528 | machine.nodes[from].shortcuts.push( 529 | {to, reducer: reducer as Callback} 530 | ); 531 | } 532 | 533 | export function registerStatic(machine: StateMachine, from: number, test: string, to: number, reducer?: Callback) { 534 | const store = !Object.prototype.hasOwnProperty.call(machine.nodes[from].statics, test) 535 | ? machine.nodes[from].statics[test] = [] 536 | : machine.nodes[from].statics[test]; 537 | 538 | store.push({to, reducer: reducer as Callback}); 539 | } 540 | 541 | // ------------------------------------------------------------------------ 542 | 543 | type UndefinedKeys = {[P in keyof T]-?: undefined extends T[P] ? P : never}[keyof T]; 544 | type UndefinedTupleKeys> = UndefinedKeys>; 545 | type TupleKeys = Exclude; 546 | 547 | export type CallbackFn

, R> = (state: RunState, segment: string, ...args: P) => R; 548 | export type CallbackFnParameters> = T extends ((state: RunState, segment: string, ...args: infer P) => any) ? P : never; 549 | export type CallbackStore = Record>; 550 | export type Callback> = 551 | [TupleKeys>] extends [UndefinedTupleKeys>] 552 | ? (T | [T, ...CallbackFnParameters]) 553 | : [T, ...CallbackFnParameters]; 554 | 555 | export function execute>(store: S, callback: Callback, state: RunState, segment: string) { 556 | // TypeScript's control flow can't properly narrow 557 | // generic conditionals for some mysterious reason 558 | if (Array.isArray(callback)) { 559 | const [name, ...args] = callback as [T, ...CallbackFnParameters]; 560 | return store[name](state, segment, ...args); 561 | } else { 562 | return store[callback as T](state, segment); 563 | } 564 | } 565 | 566 | export function suggest(callback: Callback, state: RunState): Array | null { 567 | const fn = Array.isArray(callback) 568 | ? tests[callback[0]] 569 | : tests[callback]; 570 | 571 | // @ts-ignore 572 | if (typeof fn.suggest === `undefined`) 573 | return null; 574 | 575 | const args = Array.isArray(callback) 576 | ? callback.slice(1) 577 | : []; 578 | 579 | // @ts-ignore 580 | return fn.suggest(state, ...args); 581 | } 582 | 583 | export const tests = { 584 | always: () => { 585 | return true; 586 | }, 587 | isOptionLike: (state: RunState, segment: string) => { 588 | return !state.ignoreOptions && (segment !== `-` && segment.startsWith(`-`)); 589 | }, 590 | isNotOptionLike: (state: RunState, segment: string) => { 591 | return state.ignoreOptions || segment === `-` || !segment.startsWith(`-`); 592 | }, 593 | isOption: (state: RunState, segment: string, name: string, hidden?: boolean) => { 594 | return !state.ignoreOptions && segment === name; 595 | }, 596 | isBatchOption: (state: RunState, segment: string, names: Array) => { 597 | return !state.ignoreOptions && BATCH_REGEX.test(segment) && [...segment.slice(1)].every(name => names.includes(`-${name}`)); 598 | }, 599 | isBoundOption: (state: RunState, segment: string, names: Array, options: Array) => { 600 | const optionParsing = segment.match(BINDING_REGEX); 601 | return !state.ignoreOptions && !!optionParsing && OPTION_REGEX.test(optionParsing[1]) && names.includes(optionParsing[1]) 602 | // Disallow bound options with no arguments (i.e. booleans) 603 | && options.filter(opt => opt.names.includes(optionParsing[1])).every(opt => opt.allowBinding); 604 | }, 605 | isNegatedOption: (state: RunState, segment: string, name: string) => { 606 | return !state.ignoreOptions && segment === `--no-${name.slice(2)}`; 607 | }, 608 | isHelp: (state: RunState, segment: string) => { 609 | return !state.ignoreOptions && HELP_REGEX.test(segment); 610 | }, 611 | isUnsupportedOption: (state: RunState, segment: string, names: Array) => { 612 | return !state.ignoreOptions && segment.startsWith(`-`) && OPTION_REGEX.test(segment) && !names.includes(segment); 613 | }, 614 | isInvalidOption: (state: RunState, segment: string) => { 615 | return !state.ignoreOptions && segment.startsWith(`-`) && !OPTION_REGEX.test(segment); 616 | }, 617 | }; 618 | 619 | // @ts-ignore 620 | tests.isOption.suggest = (state: RunState, name: string, hidden: boolean = true) => { 621 | return !hidden ? [name] : null; 622 | }; 623 | 624 | export const reducers = { 625 | setCandidateState: (state: RunState, segment: string, candidateState: Partial) => { 626 | return {...state, ...candidateState}; 627 | }, 628 | setSelectedIndex: (state: RunState, segment: string, index: number) => { 629 | return {...state, selectedIndex: index}; 630 | }, 631 | pushBatch: (state: RunState, segment: string) => { 632 | return {...state, options: state.options.concat([...segment.slice(1)].map(name => ({name: `-${name}`, value: true})))}; 633 | }, 634 | pushBound: (state: RunState, segment: string) => { 635 | const [, name, value] = segment.match(BINDING_REGEX)!; 636 | return {...state, options: state.options.concat({name, value})}; 637 | }, 638 | pushPath: (state: RunState, segment: string) => { 639 | return {...state, path: state.path.concat(segment)}; 640 | }, 641 | pushPositional: (state: RunState, segment: string) => { 642 | return {...state, positionals: state.positionals.concat({value: segment, extra: false})}; 643 | }, 644 | pushExtra: (state: RunState, segment: string) => { 645 | return {...state, positionals: state.positionals.concat({value: segment, extra: true})}; 646 | }, 647 | pushExtraNoLimits: (state: RunState, segment: string) => { 648 | return {...state, positionals: state.positionals.concat({value: segment, extra: NoLimits})}; 649 | }, 650 | pushTrue: (state: RunState, segment: string, name: string = segment) => { 651 | return {...state, options: state.options.concat({name: segment, value: true})}; 652 | }, 653 | pushFalse: (state: RunState, segment: string, name: string = segment) => { 654 | return {...state, options: state.options.concat({name, value: false})}; 655 | }, 656 | pushUndefined: (state: RunState, segment: string) => { 657 | return {...state, options: state.options.concat({name: segment, value: undefined})}; 658 | }, 659 | pushStringValue: (state: RunState, segment: string) => { 660 | const copy = {...state, options: [...state.options]}; 661 | const lastOption = state.options[state.options.length - 1]; 662 | lastOption.value = (lastOption.value ?? []).concat([segment]); 663 | return copy; 664 | }, 665 | setStringValue: (state: RunState, segment: string) => { 666 | const copy = {...state, options: [...state.options]}; 667 | const lastOption = state.options[state.options.length - 1]; 668 | lastOption.value = segment; 669 | return copy; 670 | }, 671 | inhibateOptions: (state: RunState) => { 672 | return {...state, ignoreOptions: true}; 673 | }, 674 | useHelp: (state: RunState, segment: string, command: number) => { 675 | const [, /* name */, index] = segment.match(HELP_REGEX)!; 676 | 677 | if (typeof index !== `undefined`) { 678 | return {...state, options: [{name: `-c`, value: String(command)}, {name: `-i`, value: index}]}; 679 | } else { 680 | return {...state, options: [{name: `-c`, value: String(command)}]}; 681 | } 682 | }, 683 | setError: (state: RunState, segment: string, errorMessage: string) => { 684 | if (segment === END_OF_INPUT) { 685 | return {...state, errorMessage: `${errorMessage}.`}; 686 | } else { 687 | return {...state, errorMessage: `${errorMessage} ("${segment}").`}; 688 | } 689 | }, 690 | setOptionArityError: (state: RunState, segment: string) => { 691 | const lastOption = state.options[state.options.length - 1]; 692 | 693 | return {...state, errorMessage: `Not enough arguments to option ${lastOption.name}.`}; 694 | }, 695 | }; 696 | 697 | // ------------------------------------------------------------------------ 698 | export const NoLimits = Symbol(); 699 | export type ArityDefinition = { 700 | leading: Array; 701 | extra: Array | typeof NoLimits; 702 | trailing: Array; 703 | proxy: boolean; 704 | }; 705 | 706 | export type OptDefinition = { 707 | names: Array; 708 | description?: string; 709 | arity: number; 710 | hidden: boolean; 711 | required: boolean; 712 | allowBinding: boolean; 713 | }; 714 | 715 | export class CommandBuilder { 716 | public readonly cliIndex: number; 717 | public readonly cliOpts: Readonly; 718 | 719 | public readonly allOptionNames: Array = []; 720 | public readonly arity: ArityDefinition = {leading: [], trailing: [], extra: [], proxy: false}; 721 | public readonly options: Array = []; 722 | public readonly paths: Array> = []; 723 | 724 | private context?: Context; 725 | 726 | constructor(cliIndex: number, cliOpts: CliOptions) { 727 | this.cliIndex = cliIndex; 728 | this.cliOpts = cliOpts; 729 | } 730 | 731 | addPath(path: Array) { 732 | this.paths.push(path); 733 | } 734 | 735 | setArity({leading = this.arity.leading, trailing = this.arity.trailing, extra = this.arity.extra, proxy = this.arity.proxy}: Partial) { 736 | Object.assign(this.arity, {leading, trailing, extra, proxy}); 737 | } 738 | 739 | addPositional({name = `arg`, required = true}: {name?: string, required?: boolean} = {}) { 740 | if (!required && this.arity.extra === NoLimits) 741 | throw new Error(`Optional parameters cannot be declared when using .rest() or .proxy()`); 742 | if (!required && this.arity.trailing.length > 0) 743 | throw new Error(`Optional parameters cannot be declared after the required trailing positional arguments`); 744 | 745 | if (!required && this.arity.extra !== NoLimits) { 746 | this.arity.extra.push(name); 747 | } else if (this.arity.extra !== NoLimits && this.arity.extra.length === 0) { 748 | this.arity.leading.push(name); 749 | } else { 750 | this.arity.trailing.push(name); 751 | } 752 | } 753 | 754 | addRest({name = `arg`, required = 0}: {name?: string, required?: number} = {}) { 755 | if (this.arity.extra === NoLimits) 756 | throw new Error(`Infinite lists cannot be declared multiple times in the same command`); 757 | if (this.arity.trailing.length > 0) 758 | throw new Error(`Infinite lists cannot be declared after the required trailing positional arguments`); 759 | 760 | for (let t = 0; t < required; ++t) 761 | this.addPositional({name}); 762 | 763 | this.arity.extra = NoLimits; 764 | } 765 | 766 | addProxy({required = 0}: {name?: string, required?: number} = {}) { 767 | this.addRest({required}); 768 | this.arity.proxy = true; 769 | } 770 | 771 | addOption({names, description, arity = 0, hidden = false, required = false, allowBinding = true}: Partial & {names: Array}) { 772 | if (!allowBinding && arity > 1) 773 | throw new Error(`The arity cannot be higher than 1 when the option only supports the --arg=value syntax`); 774 | if (!Number.isInteger(arity)) 775 | throw new Error(`The arity must be an integer, got ${arity}`); 776 | if (arity < 0) 777 | throw new Error(`The arity must be positive, got ${arity}`); 778 | 779 | this.allOptionNames.push(...names); 780 | this.options.push({names, description, arity, hidden, required, allowBinding}); 781 | } 782 | 783 | setContext(context: Context) { 784 | this.context = context; 785 | } 786 | 787 | usage({detailed = true, inlineOptions = true}: {detailed?: boolean; inlineOptions?: boolean} = {}) { 788 | const segments = [this.cliOpts.binaryName]; 789 | 790 | const detailedOptionList: Array<{ 791 | definition: string; 792 | description: string; 793 | required: boolean; 794 | }> = []; 795 | 796 | if (this.paths.length > 0) 797 | segments.push(...this.paths[0]); 798 | 799 | if (detailed) { 800 | for (const {names, arity, hidden, description, required} of this.options) { 801 | if (hidden) 802 | continue; 803 | 804 | const args = []; 805 | for (let t = 0; t < arity; ++t) 806 | args.push(` #${t}`); 807 | 808 | const definition = `${names.join(`,`)}${args.join(``)}`; 809 | 810 | if (!inlineOptions && description) { 811 | detailedOptionList.push({definition, description, required}); 812 | } else { 813 | segments.push(required ? `<${definition}>` : `[${definition}]`); 814 | } 815 | } 816 | 817 | segments.push(...this.arity.leading.map(name => `<${name}>`)); 818 | 819 | if (this.arity.extra === NoLimits) 820 | segments.push(`...`); 821 | else 822 | segments.push(...this.arity.extra.map(name => `[${name}]`)); 823 | 824 | segments.push(...this.arity.trailing.map(name => `<${name}>`)); 825 | } 826 | 827 | const usage = segments.join(` `); 828 | 829 | return {usage, options: detailedOptionList}; 830 | } 831 | 832 | compile() { 833 | if (typeof this.context === `undefined`) 834 | throw new Error(`Assertion failed: No context attached`); 835 | 836 | const machine = makeStateMachine(); 837 | let firstNode = NODE_INITIAL; 838 | 839 | const candidateUsage = this.usage().usage; 840 | const requiredOptions = this.options 841 | .filter(opt => opt.required) 842 | .map(opt => opt.names); 843 | 844 | firstNode = injectNode(machine, makeNode()); 845 | registerStatic(machine, NODE_INITIAL, START_OF_INPUT, firstNode, [`setCandidateState`, {candidateUsage, requiredOptions}]); 846 | 847 | const positionalArgument = this.arity.proxy 848 | ? `always` 849 | : `isNotOptionLike`; 850 | 851 | const paths = this.paths.length > 0 852 | ? this.paths 853 | : [[]]; 854 | 855 | for (const path of paths) { 856 | let lastPathNode = firstNode; 857 | 858 | // We allow options to be specified before the path. Note that we 859 | // only do this when there is a path, otherwise there would be 860 | // some redundancy with the options attached later. 861 | if (path.length > 0) { 862 | const optionPathNode = injectNode(machine, makeNode()); 863 | registerShortcut(machine, lastPathNode, optionPathNode); 864 | this.registerOptions(machine, optionPathNode); 865 | lastPathNode = optionPathNode; 866 | } 867 | 868 | for (let t = 0; t < path.length; ++t) { 869 | const nextPathNode = injectNode(machine, makeNode()); 870 | registerStatic(machine, lastPathNode, path[t], nextPathNode, `pushPath`); 871 | lastPathNode = nextPathNode; 872 | } 873 | 874 | if (this.arity.leading.length > 0 || !this.arity.proxy) { 875 | const helpNode = injectNode(machine, makeNode()); 876 | registerDynamic(machine, lastPathNode, `isHelp`, helpNode, [`useHelp`, this.cliIndex]); 877 | registerDynamic(machine, helpNode, `always`, helpNode, `pushExtra`); 878 | registerStatic(machine, helpNode, END_OF_INPUT, NODE_SUCCESS, [`setSelectedIndex`, HELP_COMMAND_INDEX]); 879 | 880 | this.registerOptions(machine, lastPathNode); 881 | } 882 | 883 | if (this.arity.leading.length > 0) 884 | registerStatic(machine, lastPathNode, END_OF_INPUT, NODE_ERRORED, [`setError`, `Not enough positional arguments`]); 885 | 886 | let lastLeadingNode = lastPathNode; 887 | for (let t = 0; t < this.arity.leading.length; ++t) { 888 | const nextLeadingNode = injectNode(machine, makeNode()); 889 | 890 | if (!this.arity.proxy || t + 1 !== this.arity.leading.length) 891 | this.registerOptions(machine, nextLeadingNode); 892 | 893 | if (this.arity.trailing.length > 0 || t + 1 !== this.arity.leading.length) 894 | registerStatic(machine, nextLeadingNode, END_OF_INPUT, NODE_ERRORED, [`setError`, `Not enough positional arguments`]); 895 | 896 | registerDynamic(machine, lastLeadingNode, `isNotOptionLike`, nextLeadingNode, `pushPositional`); 897 | lastLeadingNode = nextLeadingNode; 898 | } 899 | 900 | let lastExtraNode = lastLeadingNode; 901 | if (this.arity.extra === NoLimits || this.arity.extra.length > 0) { 902 | const extraShortcutNode = injectNode(machine, makeNode()); 903 | registerShortcut(machine, lastLeadingNode, extraShortcutNode); 904 | 905 | if (this.arity.extra === NoLimits) { 906 | const extraNode = injectNode(machine, makeNode()); 907 | 908 | if (!this.arity.proxy) 909 | this.registerOptions(machine, extraNode); 910 | 911 | registerDynamic(machine, lastLeadingNode, positionalArgument, extraNode, `pushExtraNoLimits`); 912 | registerDynamic(machine, extraNode, positionalArgument, extraNode, `pushExtraNoLimits`); 913 | registerShortcut(machine, extraNode, extraShortcutNode); 914 | } else { 915 | for (let t = 0; t < this.arity.extra.length; ++t) { 916 | const nextExtraNode = injectNode(machine, makeNode()); 917 | 918 | if (!this.arity.proxy || t > 0) 919 | this.registerOptions(machine, nextExtraNode); 920 | 921 | registerDynamic(machine, lastExtraNode, positionalArgument, nextExtraNode, `pushExtra`); 922 | registerShortcut(machine, nextExtraNode, extraShortcutNode); 923 | lastExtraNode = nextExtraNode; 924 | } 925 | } 926 | 927 | lastExtraNode = extraShortcutNode; 928 | } 929 | 930 | if (this.arity.trailing.length > 0) 931 | registerStatic(machine, lastExtraNode, END_OF_INPUT, NODE_ERRORED, [`setError`, `Not enough positional arguments`]); 932 | 933 | let lastTrailingNode = lastExtraNode; 934 | for (let t = 0; t < this.arity.trailing.length; ++t) { 935 | const nextTrailingNode = injectNode(machine, makeNode()); 936 | 937 | if (!this.arity.proxy) 938 | this.registerOptions(machine, nextTrailingNode); 939 | 940 | if (t + 1 < this.arity.trailing.length) 941 | registerStatic(machine, nextTrailingNode, END_OF_INPUT, NODE_ERRORED, [`setError`, `Not enough positional arguments`]); 942 | 943 | registerDynamic(machine, lastTrailingNode, `isNotOptionLike`, nextTrailingNode, `pushPositional`); 944 | lastTrailingNode = nextTrailingNode; 945 | } 946 | 947 | registerDynamic(machine, lastTrailingNode, positionalArgument, NODE_ERRORED, [`setError`, `Extraneous positional argument`]); 948 | registerStatic(machine, lastTrailingNode, END_OF_INPUT, NODE_SUCCESS, [`setSelectedIndex`, this.cliIndex]); 949 | } 950 | 951 | return { 952 | machine, 953 | context: this.context, 954 | }; 955 | } 956 | 957 | private registerOptions(machine: StateMachine, node: number) { 958 | registerDynamic(machine, node, [`isOption`, `--`], node, `pushExtraNoLimits`); 959 | registerDynamic(machine, node, [`isBatchOption`, this.allOptionNames], node, `pushBatch`); 960 | registerDynamic(machine, node, [`isBoundOption`, this.allOptionNames, this.options], node, `pushBound`); 961 | registerDynamic(machine, node, [`isUnsupportedOption`, this.allOptionNames], NODE_ERRORED, [`setError`, `Unsupported option name`]); 962 | registerDynamic(machine, node, [`isInvalidOption`], NODE_ERRORED, [`setError`, `Invalid option name`]); 963 | 964 | for (const option of this.options) { 965 | const longestName = option.names.reduce((longestName, name) => { 966 | return name.length > longestName.length ? name : longestName; 967 | }, ``); 968 | 969 | if (option.arity === 0) { 970 | for (const name of option.names) { 971 | registerDynamic(machine, node, [`isOption`, name, option.hidden || name !== longestName], node, `pushTrue`); 972 | 973 | if (name.startsWith(`--`) && !name.startsWith(`--no-`)) { 974 | registerDynamic(machine, node, [`isNegatedOption`, name], node, [`pushFalse`, name]); 975 | } 976 | } 977 | } else { 978 | // We inject a new node at the end of the state machine 979 | let lastNode = injectNode(machine, makeNode()); 980 | 981 | // We register transitions from the starting node to this new node 982 | for (const name of option.names) 983 | registerDynamic(machine, node, [`isOption`, name, option.hidden || name !== longestName], lastNode, `pushUndefined`); 984 | 985 | 986 | // For each argument, we inject a new node at the end and we 987 | // register a transition from the current node to this new node 988 | for (let t = 0; t < option.arity; ++t) { 989 | const nextNode = injectNode(machine, makeNode()); 990 | 991 | // We can provide better errors when another option or END_OF_INPUT is encountered 992 | registerStatic(machine, lastNode, END_OF_INPUT, NODE_ERRORED, `setOptionArityError`); 993 | registerDynamic(machine, lastNode, `isOptionLike`, NODE_ERRORED, `setOptionArityError`); 994 | 995 | // If the option has a single argument, no need to store it in an array 996 | const action: keyof typeof reducers = option.arity === 1 997 | ? `setStringValue` 998 | : `pushStringValue`; 999 | 1000 | registerDynamic(machine, lastNode, `isNotOptionLike`, nextNode, action); 1001 | 1002 | lastNode = nextNode; 1003 | } 1004 | 1005 | // In the end, we register a shortcut from 1006 | // the last node back to the starting node 1007 | registerShortcut(machine, lastNode, node); 1008 | } 1009 | } 1010 | } 1011 | } 1012 | 1013 | export type CliOptions = { 1014 | binaryName: string; 1015 | }; 1016 | 1017 | export type CliBuilderCallback = 1018 | (command: CommandBuilder) => CommandBuilder | void; 1019 | 1020 | export class CliBuilder { 1021 | private readonly opts: CliOptions; 1022 | private readonly builders: Array> = []; 1023 | 1024 | static build(cbs: Array>, opts: Partial = {}) { 1025 | return new CliBuilder(opts).commands(cbs).compile(); 1026 | } 1027 | 1028 | constructor({binaryName = `...`}: Partial = {}) { 1029 | this.opts = {binaryName}; 1030 | } 1031 | 1032 | getBuilderByIndex(n: number) { 1033 | if (!(n >= 0 && n < this.builders.length)) 1034 | throw new Error(`Assertion failed: Out-of-bound command index (${n})`); 1035 | 1036 | return this.builders[n]; 1037 | } 1038 | 1039 | commands(cbs: Array>) { 1040 | for (const cb of cbs) 1041 | cb(this.command()); 1042 | 1043 | return this; 1044 | } 1045 | 1046 | command() { 1047 | const builder = new CommandBuilder(this.builders.length, this.opts); 1048 | this.builders.push(builder); 1049 | 1050 | return builder; 1051 | } 1052 | 1053 | compile() { 1054 | const machines = []; 1055 | const contexts = []; 1056 | 1057 | for (const builder of this.builders) { 1058 | const {machine, context} = builder.compile(); 1059 | 1060 | machines.push(machine); 1061 | contexts.push(context); 1062 | } 1063 | 1064 | const machine = makeAnyOfMachine(machines); 1065 | simplifyMachine(machine); 1066 | 1067 | return { 1068 | machine, 1069 | contexts, 1070 | process: (input: Array) => { 1071 | return runMachine(machine, input); 1072 | }, 1073 | suggest: (input: Array, partial: boolean) => { 1074 | return suggestMachine(machine, input, partial); 1075 | }, 1076 | }; 1077 | } 1078 | } 1079 | --------------------------------------------------------------------------------