Repository: Schniz/cmd-ts Branch: main Commit: bbe46910c43a Files: 107 Total size: 182.1 KB Directory structure: gitextract_xv3i1bqp/ ├── .changeset/ │ ├── README.md │ └── config.json ├── .github/ │ └── workflows/ │ ├── build.yml │ └── release.yml ├── .gitignore ├── .kodiak.toml ├── .node-version ├── CHANGELOG.md ├── LICENSE ├── README.md ├── batteries/ │ ├── fs/ │ │ └── package.json │ ├── url/ │ │ └── package.json │ └── vercel-formatter/ │ └── package.json ├── biome.json ├── book.toml ├── docs/ │ ├── SUMMARY.md │ ├── batteries.md │ ├── batteries_file_system.md │ ├── batteries_url.md │ ├── custom_types.md │ ├── getting_started.md │ ├── included_types.md │ ├── introduction.md │ ├── parsers/ │ │ ├── binary.md │ │ ├── command.md │ │ ├── custom.md │ │ ├── flags.md │ │ ├── options.md │ │ ├── positionals.md │ │ └── subcommands.md │ └── parsers.md ├── example/ │ ├── app.ts │ ├── app2.ts │ ├── app3.ts │ ├── app4.ts │ ├── app5.ts │ ├── app6.ts │ ├── app7.ts │ ├── negative-numbers.ts │ ├── rest-example.ts │ ├── test-types.ts │ ├── tsconfig.json │ └── vercel-formatter.ts ├── jest.config.js ├── package.json ├── renovate.json ├── scripts/ │ ├── print-all-states.js │ ├── ts-node │ └── tsconfig.json ├── src/ │ ├── Result.ts │ ├── argparser.ts │ ├── batteries/ │ │ ├── fs.ts │ │ ├── url.ts │ │ └── vercel-formatter.ts │ ├── binary.ts │ ├── chalk.ts │ ├── circuitbreaker.ts │ ├── command.ts │ ├── default.ts │ ├── effects.ts │ ├── errorBox.ts │ ├── flag.ts │ ├── from.ts │ ├── helpFormatter.ts │ ├── helpdoc.ts │ ├── index.ts │ ├── multiflag.ts │ ├── multioption.ts │ ├── newparser/ │ │ ├── findOption.ts │ │ ├── parser.ts │ │ └── tokenizer.ts │ ├── oneOf.ts │ ├── option.ts │ ├── positional.ts │ ├── rest.ts │ ├── restPositionals.ts │ ├── runner.ts │ ├── subcommands.ts │ ├── type.ts │ ├── types.ts │ ├── union.ts │ └── utils.ts ├── test/ │ ├── __snapshots__/ │ │ ├── ui.test.ts.snap │ │ └── vercel-formatter.test.ts.snap │ ├── command.test.ts │ ├── createRegisterOptions.ts │ ├── errorBox.test.ts │ ├── flag.test.ts │ ├── multioption.test.ts │ ├── negative-numbers.test.ts │ ├── newparser/ │ │ ├── findOption.test.ts │ │ └── parser.test.ts │ ├── rest-parameters.test.ts │ ├── rest.test.ts │ ├── restPositionals.test.ts │ ├── subcommands.test.ts │ ├── test-types.ts │ ├── tsconfig.json │ ├── type-inference.test.ts │ ├── ui.test.ts │ ├── util.ts │ ├── utils.test.ts │ └── vercel-formatter.test.ts ├── tsconfig.esm.json ├── tsconfig.json ├── tsconfig.noEmit.json └── typedoc.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .changeset/README.md ================================================ # Changesets Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets) We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) ================================================ FILE: .changeset/config.json ================================================ { "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [] } ================================================ FILE: .github/workflows/build.yml ================================================ name: "build" on: push: branches: - "main" pull_request: branches: - "main" jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: ["18.x", "20.x", "22.x", "24.x"] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - uses: pnpm/action-setup@v4 - name: Cache pnpm modules uses: actions/cache@v4 with: path: ~/.pnpm-store key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}- - run: pnpm install - run: pnpm run build - run: pnpm run test - run: pnpm run lint - run: pnpm dlx pkg-pr-new publish if: matrix.node-version == '22.x' ================================================ FILE: .github/workflows/release.yml ================================================ on: push: branches: - main name: "release" concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: release: permissions: id-token: write # Required for OIDC contents: write pull-requests: write name: Release runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 24.x - uses: pnpm/action-setup@v4 - name: Cache pnpm modules uses: actions/cache@v4 with: path: ~/.pnpm-store key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}- - run: pnpm install - name: Create Release Pull Request uses: changesets/action@v1 with: version: "pnpm changeset:version" publish: "pnpm changeset:publish" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ *.log .DS_Store node_modules dist /coverage /public # book /book ================================================ FILE: .kodiak.toml ================================================ version = 1 ================================================ FILE: .node-version ================================================ v24.13.0 ================================================ FILE: CHANGELOG.md ================================================ # cmd-ts ## 0.15.0 ### Minor Changes - 0366d4f: Add pluggable help formatters with `HelpFormatter` interface and `setDefaultHelpFormatter()` API. This allows customizing how CLI help is rendered. Also adds: - `examples` option to commands and subcommands for documenting usage examples - `cmd-ts/batteries/vercelFormatter` - a Vercel-style help formatter with column-aligned output ## 0.14.4 ### Patch Changes - bcd5d02: better multiline error formatting ## 0.14.3 ### Patch Changes - e0afa2f: handle circuit breaker (--help and --version) before parsing arguments ## 0.14.2 ### Patch Changes - 87565b2: Added onMissing callback support to flags, options, and custom types That allows providing dynamic fallback values when command-line arguments are not provided, This enables: - Hiding default values from help output - Interactive prompts: Ask users for input when flags/options are missing - Environment-based defaults: Check environment variables or config files dynamically - Auto-discovery: Automatically find files or resources when not specified - Async support: Handle both synchronous and asynchronous fallback logic The onMissing callback is used as a fallback when defaultValue is not provided, following the precedence order: environment variables → defaultValue → onMissing → type defaults. New APIs: - flag({ onMissing: () => boolean | Promise }) - option({ onMissing: () => T | Promise }) - multioption({ onMissing: () => T[] | Promise }) - Custom Type interface now supports onMissing property ## 0.14.1 ### Patch Changes - 46bf4a7: fix: properly reconstruct original argument strings in rest combinator ## 0.14.0 ### Minor Changes - a1afb05: --help exits with statuscode 0 ## 0.13.0 ### Minor Changes - dfeafc8: add `defaultValue` configuration @ `multioption` ## 0.12.1 ### Patch Changes - 5867a13: Allow dangling forcePositionals ## 0.12.0 ### Minor Changes - 2f651de: Display help when calling subcommands without any arguments ### Patch Changes - e05e433: Allow readonly T[] in oneOf ## 0.11.0 ### Minor Changes - 6cb8d08: upgrade all deps ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Gal Schlezinger 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. ================================================ FILE: README.md ================================================ # `cmd-ts` > 💻 A type-driven command line argument parser, with awesome error reporting 🤤 Not all command line arguments are strings, but for some reason, our CLI parsers force us to use strings everywhere. 🤔 `cmd-ts` is a fully-fledged command line argument parser, influenced by Rust's [`clap`](https://github.com/clap-rs/clap) and [`structopt`](https://github.com/TeXitoi/structopt): 🤩 Awesome autocomplete, awesome safeness 🎭 Decode your own custom types from strings with logic and context-aware error handling 🌲 Nested subcommands, composable API ### Basic usage ```ts import { command, run, string, number, positional, option } from 'cmd-ts'; const cmd = command({ name: 'my-command', description: 'print something to the screen', version: '1.0.0', args: { number: positional({ type: number, displayName: 'num' }), message: option({ long: 'greeting', type: string, }), }, handler: (args) => { args.message; // string args.number; // number console.log(args); }, }); run(cmd, process.argv.slice(2)); ``` #### `command(arguments)` Creates a CLI command. ### Decoding custom types from strings Not all command line arguments are strings. You sometimes want integers, UUIDs, file paths, directories, globs... > **Note:** this section describes the `ReadStream` type, implemented in `./src/example/test-types.ts` Let's say we're about to write a `cat` clone. We want to accept a file to read into stdout. A simple example would be something like: ```ts // my-app.ts import { command, run, positional, string } from 'cmd-ts'; const app = command({ /// name: ..., args: { file: positional({ type: string, displayName: 'file' }), }, handler: ({ file }) => { // read the file to the screen fs.createReadStream(file).pipe(stdout); }, }); // parse arguments run(app, process.argv.slice(2)); ``` That works okay. But we can do better. In which ways? - Error handling is out of the command line argument parser context, and in userland, making things less consistent and pretty. - It shows we lack composability and encapsulation — and we miss a way to distribute shared "command line" behavior. What if we had a way to get a `Stream` out of the parser, instead of a plain string? This is where `cmd-ts` gets its power from, custom type decoding: ```ts // ReadStream.ts import { Type } from 'cmd-ts'; import fs from 'fs'; // Type reads as "A type from `string` to `Stream`" const ReadStream: Type = { async from(str) { if (!fs.existsSync(str)) { // Here is our error handling! throw new Error('File not found'); } return fs.createReadStream(str); }, }; ``` Now we can use (and share) this type and always get a `Stream`, instead of carrying the implementation detail around: ```ts // my-app.ts import { command, run, positional } from 'cmd-ts'; const app = command({ // name: ..., args: { stream: positional({ type: ReadStream, displayName: 'file' }), }, handler: ({ stream }) => stream.pipe(process.stdout), }); // parse arguments run(app, process.argv.slice(2)); ``` Encapsulating runtime behaviour and safe type conversions can help us with awesome user experience: - We can throw an error when the file is not found - We can try to parse the string as a URI and check if the protocol is HTTP, if so - make an HTTP request and return the body stream - We can see if the string is `-`, and when it happens, return `process.stdin` like many Unix applications And the best thing about it — everything is encapsulated to an easily tested type definition, which can be easily shared and reused. Take a look at [io-ts-types](https://github.com/gcanti/io-ts-types), for instance, which has types like DateFromISOString, NumberFromString and more, which is something we can totally do. ## Inspiration This project was previously called `clio-ts`, because it was based on `io-ts`. This is no longer the case, because I want to reduce the dependency count and mental overhead. I might have a function to migrate types between the two. ================================================ FILE: batteries/fs/package.json ================================================ { "types": "../../dist/esm/batteries/fs.d.ts", "main": "../../dist/cjs/batteries/fs.js", "module": "../../dist/esm/batteries/fs.js" } ================================================ FILE: batteries/url/package.json ================================================ { "types": "../../dist/esm/batteries/url.d.ts", "main": "../../dist/cjs/batteries/url.js", "module": "../../dist/esm/batteries/url.js" } ================================================ FILE: batteries/vercel-formatter/package.json ================================================ { "types": "../../dist/esm/batteries/vercel-formatter.d.ts", "main": "../../dist/cjs/batteries/vercel-formatter.js", "module": "../../dist/esm/batteries/vercel-formatter.js" } ================================================ FILE: biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, "files": { "ignoreUnknown": false, "ignore": [] }, "formatter": { "enabled": true, "indentStyle": "tab" }, "organizeImports": { "enabled": true }, "linter": { "enabled": true, "rules": { "recommended": true, "suspicious": { "noExplicitAny": "off" }, "complexity": { "noForEach": "off", "noBannedTypes": "off" } } }, "javascript": { "formatter": { "quoteStyle": "double" } } } ================================================ FILE: book.toml ================================================ [book] authors = ["Gal Schlezinger"] language = "en" multilingual = false src = "docs" title = "cmd-ts" ================================================ FILE: docs/SUMMARY.md ================================================ # Summary - [Introduction](./introduction.md) - [Getting Started](./getting_started.md) - [Included Types](./included_types.md) - [Custom Types](./custom_types.md) - [Battery Packs](./batteries.md) - [File System](./batteries_file_system.md) - [URL](./batteries_url.md) - [Parsers and Combinators](./parsers.md) - [Positional Arguments](./parsers/positionals.md) - [Options](./parsers/options.md) - [Flags](./parsers/flags.md) - [Command](./parsers/command.md) - [Subcommands](./parsers/subcommands.md) - [Binary](./parsers/binary.md) - [Building a Custom Parser](./parsers/custom.md) ================================================ FILE: docs/batteries.md ================================================ # Battery Packs Batteries, from the term "batteries included", are optional imports you can use in your application but aren't needed in every application. They might have dependencies of their own peer dependencies or run only in a specific runtime (browser, Node.js). Here are some battery packs: - [File System](./batteries_file_system.md) - [URL](./batteries_url.md) ================================================ FILE: docs/batteries_file_system.md ================================================ # File System Battery Pack The file system battery pack contains the following types: ### `ExistingPath` ```typescript import { ExistingPath } from 'cmd-ts/batteries/fs'; ``` Resolves into a path that exists. Fails if the path does not exist. If a relative path is provided (`../file`), it will expand by using the current working directory. ### `Directory` ```typescript import { Directory } from 'cmd-ts/batteries/fs'; ``` Resolves into a path of an existing directory. If an existing file was given, it'll use its `dirname`. ### `File` ```typescript import { File } from 'cmd-ts/batteries/fs'; ``` Resolves into an existing file. Fails if the provided path is not a file. ================================================ FILE: docs/batteries_url.md ================================================ # URL Battery Pack The URL battery pack contains the following types: ### `Url` ```typescript import { Url } from 'cmd-ts/batteries/url'; ``` Resolves into a `URL` class. Fails if there is no `host` or `protocol`. ### `HttpUrl` ```typescript import { HttpUrl } from 'cmd-ts/batteries/url'; ``` Resolves into a `URL` class. Fails if the protocol is not `http` or `https` ================================================ FILE: docs/custom_types.md ================================================ # Custom Types Not all command line arguments are strings. You sometimes want integers, UUIDs, file paths, directories, globs... > **Note:** this section describes the `ReadStream` type, implemented in `./example/test-types.ts` Let's say we're about to write a `cat` clone. We want to accept a file to read into stdout. A simple example would be something like: ```ts // my-app.ts import { command, run, positional, string } from 'cmd-ts'; const app = command({ /// name: ..., args: { file: positional({ type: string, displayName: 'file' }), }, handler: ({ file }) => { // read the file to the screen fs.createReadStream(file).pipe(stdout); }, }); // parse arguments run(app, process.argv.slice(2)); ``` That works well! We already get autocomplete from TypeScript and we're making progress towards developer experience. Still, we can do better. In which ways, you might think? - Error handling is non existent, and if we'd implement it in our handler it'll be out of the command line argument parser context, making things less consistent and pretty. - It shows we lack composability and encapsulation — we miss a way to share and distribute "command line" behavior. > 💡 What if we had a way to get a `Stream` out of the parser, instead of a plain string? This is where `cmd-ts` gets its power from, ### Custom Type Decoding Exported from `cmd-ts`, the construct `Type` is a way to declare a type that can be converted from `A` into `B`, in a safe manner. `cmd-ts` uses it to decode the arguments provided. You might've seen the `string` type, which is `Type`, or, the identity: because every string is a string. Constructing our own types let us have all the implementation we need in an isolated and easily composable. So in our app, we need to implement a `Type`, or — a type that reads a `string` and outputs a `Stream`: ```ts // ReadStream.ts import { Type } from 'cmd-ts'; import fs from 'fs'; // Type reads as "A type from `string` to `Stream`" const ReadStream: Type = { async from(str) { if (!fs.existsSync(str)) { // Here is our error handling! throw new Error('File not found'); } return fs.createReadStream(str); }, }; ``` - `from` is the only required key in `Type`. It's an async operation that gets `A` and returns a `B`, or throws an error with some message. - Other than `from`, we can provide more metadata about the type: - `description` to provide a default description for this type - `displayName` is a short way to describe the type in the help - `defaultValue(): B` to allow the type to be optional and have a default value - `onMissing(): B | Promise` to provide a dynamic fallback when the argument is not provided (used as fallback if `defaultValue` is not provided) Using the type we've just created is no different that using `string`: ```ts // my-app.ts import { command, run, positional } from 'cmd-ts'; const app = command({ // name: ..., args: { stream: positional({ type: ReadStream, displayName: 'file' }), }, handler: ({ stream }) => stream.pipe(process.stdout), }); // parse arguments run(app, process.argv.slice(2)); ``` Our `handler` function now takes a `stream` which has a type of `Stream`. This is amazing: we've pushed the logic of encoding a `string` into a `Stream` outside of our implementation, which free us from having lots of guards and checks inside our `handler` function, making it less readable and harder to test. Now, we can add more features to our `ReadStream` type and stop touching our code which expects a `Stream`: - We can throw a detailed error when the file is not found - We can try to parse the string as a URI and check if the protocol is HTTP, if so - make an HTTP request and return the body stream - We can see if the string is `-`, and when it happens, return `process.stdin` like many Unix applications ### Custom Types with `onMissing` Custom types can also provide dynamic defaults using `onMissing`. This is useful when you want the type itself to determine what happens when no argument is provided: ```ts const ConfigFile: Type = { async from(str) { if (!fs.existsSync(str)) { throw new Error(`Config file not found: ${str}`); } return JSON.parse(fs.readFileSync(str, 'utf8')); }, displayName: 'config-file', async onMissing() { // Look for config in standard locations when not provided const candidates = [ './config.json', path.join(os.homedir(), '.myapp', 'config.json'), '/etc/myapp/config.json' ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { console.log(`Using config from: ${candidate}`); return JSON.parse(fs.readFileSync(candidate, 'utf8')); } } // Return default config if none found return { debug: false, verbose: false }; }, }; ``` And the best thing about it — everything is encapsulated to an easily tested type definition, which can be easily shared and reused. Take a look at [io-ts-types](https://github.com/gcanti/io-ts-types), for instance, which has types like DateFromISOString, NumberFromString and more, which is something we can totally do. ================================================ FILE: docs/getting_started.md ================================================ # Getting Started Install the package using npm: ``` npm install --save cmd-ts ``` or if you use Yarn: ``` yarn add cmd-ts ``` ## Using `cmd-ts` All the interesting stuff is exported from the main module. Try writing the following app: ```ts import { command, run, string, positional } from 'cmd-ts'; const app = command({ name: 'my-first-app', args: { someArg: positional({ type: string, displayName: 'some arg' }), }, handler: ({ someArg }) => { console.log({ someArg }); }, }); run(app, process.argv.slice(2)); ``` This app is taking one string positional argument and prints it to the screen. Read more about the different parsers and combinators in [Parsers and Combinators](./parsers.md). > **Note:** `string` is one type that comes included in `cmd-ts`. There are more of these bundled in the [included types guide](./included_types.md). You can define your own types using the [custom types guide](./custom_types.md) ================================================ FILE: docs/included_types.md ================================================ # Included Types ### `string` A simple `string => string` type. Useful for [`option`](./parsers/options.md) and [`positional`](./parsers/positionals.md) arguments ### `boolean` A simple `boolean => boolean` type. Useful for [`flag`](./parsers/flags.md) ### `number` A `string => number` type. Checks that the input is indeed a number or fails with a descriptive error message. ### `optional(type)` Takes a type and makes it nullable by providing a default value of `undefined` ### `array(type)` Takes a type and turns it into an array of type, useful for [`multioption`](./parsers/options.md) and [`multiflag`](./parsers/flags.md). ### `union([types])` Tries to decode the types provided until it succeeds, or throws all the errors combined. There's an optional configuration to this function: - `combineErrors`: function that takes a list of strings (the error messages) and returns a string which is the combined error message. The default value for it is to join with a newline: `xs => xs.join("\n")`. ### `oneOf(["string1", "string2", ...])` Takes a closed set of string values to decode from. An exact enum. ================================================ FILE: docs/introduction.md ================================================ # Introduction `cmd-ts` is a type-driven command line argument parser written in TypeScript. Let's break it down: ### A command line argument parser written in TypeScript Much like `commander` and similar Node.js tools, the goal of `cmd-ts` is to provide your users a superior experience while using your app from the terminal. `cmd-ts` is built with TypeScript and tries to bring soundness and ease of use to CLI apps. It is fully typed and allows custom types as CLI arguments. More on that on the next paragraph. `cmd-ts` API is built with small, composable "parsers" that are easily extensible `cmd-ts` has a wonderful error output, which preserves the parsing context, allowing the users to know what they've mistyped and where, instead of playing a guessing game ### Type-driven command line argument parser `cmd-ts` is essentially an adapter between the user's shell and the code. For some reason, most command line argument parsers only accept strings as arguments, and provide no typechecking that the value makes sense in the context of your app: - Some arguments may be a number; so providing a string should result in an error - Some arguments may be an integer; so providing a float should result in an error - Some arguments may be readable files; so providing a missing path should result in an error These types of concerns are mostly implemented in userland right now. `cmd-ts` has a different way of thinking about it using the `Type` construct, which provides both static (TypeScript) and runtime typechecking. The power of `Type` lets us have a strongly-typed commands that provide us autocomplete for our implementation and confidence in our codebase, while providing an awesome experience for the users, when they provide a wrong argument. More on that on the [Custom Types guide](./custom_types.md) ================================================ FILE: docs/parsers/binary.md ================================================ # `binary` A standard Node executable will receive two additional arguments that are often omitted: - the node executable path - the command path `cmd-ts` provides a small helper that ignores the first two positional arguments that a command receives: ```ts import { binary, command, run } from 'cmd-ts'; const myCommand = command({ /* ... */ }); const binaryCommand = binary(myCommand); run(binaryCommand, process.argv); ``` ================================================ FILE: docs/parsers/command.md ================================================ # `command` This is what we call "a combinator": `command` takes multiple parsers and combine them into one parser that can also take raw user input using its `run` function. ### Config - `name` (required): A name for the command - `version`: A version for the command - `handler` (required): A function that takes all the arguments and do something with it - `args` (required): An object where the keys are the argument names (how they'll be treated in code) and the values are [parsers](../parsers.md) - `aliases`: A list of other names this command can be called with. Useful with [`subcommands`](./subcommands.md) ### Usage ```ts {{#include ../../example/app2.ts}} ``` ================================================ FILE: docs/parsers/custom.md ================================================ # Building a Custom Parser ... wip ... ================================================ FILE: docs/parsers/flags.md ================================================ # Flags A command line flag is an argument or arguments in the following formats: - `--long-key` - `--long-key=true` or `--long-key=false` - `-s` - `-s=true` or `--long-key=false` where `long-key` is "the long form key" and `s` is "a short form key". Flags can also be stacked using their short form. Let's assume we have flags with the short form keys of `a`, `b` and `c`: `-abc` will be parsed the same as `-a -b -c`. There are two ways to parse flags: - [The `flag` parser](#flag) which parses one and only one flag - [The `multiflag` parser](#multiflag) which parser none or multiple flags ## `flag` Parses one and only one flag. Accepts a `Type` from `boolean` to any value to decode the users' intent. In order to make this optional, either the type provided or a `defaultValue` function should be provided. In order to make a certain type optional, you can take a look at [`optional`](../included_types.md#optionaltype) This parser will fail to parse if: - There are zero flags that match the long form key or the short form key - There are more than one flag that match the long form key or the short form key - A value other than `true` or `false` was provided (if it was treated like [an option](./options.md)) - Decoding the user input fails ### Usage ```ts import { command, boolean, flag } from 'cmd-ts'; const myFlag = flag({ type: boolean, long: 'my-flag', short: 'f', }); const cmd = command({ name: 'my flag', args: { myFlag }, }); ``` ### Dynamic Defaults with `onMissing` The `onMissing` callback provides a way to dynamically generate values when a flag is not provided. This is useful for environment-based defaults, configuration file lookups, or user prompts. ```ts import { command, flag } from 'cmd-ts'; const verboseFlag = flag({ long: 'verbose', short: 'v', description: 'Enable verbose output', onMissing: () => { // Check environment variable as fallback return process.env.NODE_ENV === 'development'; }, }); const debugFlag = flag({ long: 'debug', short: 'd', description: 'Enable debug mode', onMissing: async () => { // Async example: check config file or make API call const config = await loadConfig(); return config.debug || false; }, }); const cmd = command({ name: 'my app', args: { verbose: verboseFlag, debug: debugFlag, }, handler: ({ verbose, debug }) => { console.log(`Verbose: ${verbose}, Debug: ${debug}`); }, }); ``` ### Config - `type` (required): A type from `boolean` to any value - `long` (required): The long form key - `short`: The short form key - `description`: A short description regarding the option - `displayName`: A short description regarding the option - `defaultValue`: A function that returns a default value for the option - `defaultValueIsSerializable`: Whether to print the defaultValue as a string in the help docs. - `onMissing`: A function (sync or async) that returns a value when the flag is not provided. Used as fallback if `defaultValue` is not provided. ## `multiflag` Parses multiple or zero flags. Accepts a `Type` from `boolean[]` to any value, letting you do the conversion yourself. > **Note:** using `multiflag` will drop all the contextual errors. Every error on the type conversion will show up as if all of the options were errored. This is a higher level with less granularity. This parser will fail to parse if: - A value other than `true` or `false` was provided (if it was treated like [an option](./options.md)) - Decoding the user input fails ### Config - `type` (required): A type from `boolean[]` to any value - `long` (required): The long form key - `short`: The short form key - `description`: A short description regarding the flag - `displayName`: A short description regarding the flag ================================================ FILE: docs/parsers/options.md ================================================ # Options A command line option is an argument or arguments in the following formats: - `--long-key value` - `--long-key=value` - `-s value` - `-s=value` where `long-key` is "the long form key" and `s` is "a short form key". There are two ways to parse options: - [The `option` parser](#option) which parses one and only one option - [The `multioption` parser](#multioption) which parser none or multiple options ## `option` Parses one and only one option. Accepts a `Type` from `string` to any value to decode the users' intent. In order to make this optional, either the type provided or a `defaultValue` function should be provided. In order to make a certain type optional, you can take a look at [`optional`](../included_types.md#optionaltype) This parser will fail to parse if: - There are zero options that match the long form key or the short form key - There are more than one option that match the long form key or the short form key - No value was provided (if it was treated like [a flag](./flags.md)) - Decoding the user input fails ### Usage ```ts import { command, number, option } from 'cmd-ts'; const myNumber = option({ type: number, long: 'my-number', short: 'n', }); const cmd = command({ name: 'my number', args: { myNumber }, }); ``` ### Dynamic Defaults with `onMissing` The `onMissing` callback provides a way to dynamically generate values when an option is not provided. This is perfect for interactive prompts: ```ts import { command, option, string } from './src'; import { createInterface } from 'readline/promises'; const name = option({ type: string, long: 'name', short: 'n', description: 'Your name for the greeting', onMissing: async () => { const rl = createInterface({ input: process.stdin, output: process.stdout, }); try { const answer = await rl.question("What's your name? "); return answer.trim() || 'Anonymous'; } finally { rl.close(); } }, }); const cmd = command({ name: 'greeting', args: { name }, handler: ({ name }) => { console.log(`Hello, ${name}!`); }, }); ``` ### Config - `type` (required): A type from `string` to any value - `long` (required): The long form key - `short`: The short form key - `description`: A short description regarding the option - `displayName`: A short description regarding the option - `defaultValue`: A function that returns a default value for the option - `defaultValueIsSerializable`: Whether to print the defaultValue as a string in the help docs. - `onMissing`: A function (sync or async) that returns a value when the option is not provided. Used as fallback if `defaultValue` is not provided. ## `multioption` Parses multiple or zero options. Accepts a `Type` from `string[]` to any value, letting you do the conversion yourself. > **Note:** using `multioption` will drop all the contextual errors. Every error on the type conversion will show up as if all of the options were errored. This is a higher level with less granularity. This parser will fail to parse if: - No value was provided (if it was treated like [a flag](./flags.md)) - Decoding the user input fails ### Dynamic Defaults for `multioption` Like single options, `multioption` supports `onMissing` callbacks for dynamic default arrays: ```ts import { command, multioption } from 'cmd-ts'; import type { Type } from 'cmd-ts'; const stringArray: Type = { async from(strings) { return strings; }, displayName: 'string', }; const includes = multioption({ type: stringArray, long: 'include', short: 'i', description: 'Files to include', onMissing: async () => { // Auto-discover files when none specified const files = await glob('src/**/*.ts'); return files; }, }); const cmd = command({ name: 'build', args: { includes }, handler: ({ includes }) => { console.log(`Processing files: ${includes.join(', ')}`); }, }); ``` ### Config - `type` (required): A type from `string[]` to any value - `long` (required): The long form key - `short`: The short form key - `description`: A short description regarding the option - `displayName`: A short description regarding the option - `defaultValue`: A function that returns a default value for the option array in case no options were provided. If not provided, the default value will be an empty array. - `defaultValueIsSerializable`: Whether to print the defaultValue as a string in the help docs. - `onMissing`: A function (sync or async) that returns a value when the option is not provided. Used as fallback if `defaultValue` is not provided. ================================================ FILE: docs/parsers/positionals.md ================================================ # Positional Arguments Read positional arguments. Positional arguments are all the arguments that are not an [option](./options.md) or a [flag](./options.md). So in the following command line invocation for the `my-app` command: ``` my-app greet --greeting Hello Joe ^^^^^ ^^^ - positional arguments ``` ## `positional` Fetch a single positional argument This parser will fail to parse if: - Decoding the user input fails ### Config: - `displayName` (required): a display name for the named argument. This is required so it'll be understandable what the argument is for - `type` (required): a [Type](../included_types.md) from `string` that will help decoding the value provided by the user - `description`: a short text describing what this argument is for ## `restPositionals` Fetch all the rest positionals > **Note:** this will swallaow all the other positionals, so you can't use [`positional`](#positional) to fetch a positional afterwards. This parser will fail to parse if: - Decoding the user input fails ### Config: - `displayName`: a display name for the named argument. - `type` (required): a [Type](../included_types.md) from `string` that will help decoding the value provided by the user. Each argument will go through this. - `description`: a short text describing what these arguments are for ================================================ FILE: docs/parsers/subcommands.md ================================================ # `subcommands` This is yet another combinator, which takes a couple of [`command`](./command.md)s and produce a new command that the first argument will choose between them. ### Config - `name` (required): A name for the container - `version`: The container version - `cmds`: An object where the keys are the names of the subcommands to use, and the values are [`command`](./command.md) instances. You can also provide `subcommands` instances to nest a nested subcommand! ### Usage ```ts import { command, subcommands, run } from 'cmd-ts'; const cmd1 = command({ /* ... */ }); const cmd2 = command({ /* ... */ }); const subcmd1 = subcommands({ name: 'my subcmd1', cmds: { cmd1, cmd2 }, }); const nestingSubcommands = subcommands({ name: 'nesting subcommands', cmds: { subcmd1 }, }); run(nestingSubcommands, process.argv.slice(2)); ``` ================================================ FILE: docs/parsers.md ================================================ # Parsers and Combinators `cmd-ts` can help you build a full command line application, with nested commands, options, arguments, and whatever you want. One of the secret sauces baked into `cmd-ts` is the ability to compose parsers. ## Argument Parser An argument parser is a simple struct with a `parse` function and an optional `register` function. `cmd-ts` is shipped with a couple of parsers and combinators to help you build your dream command-line app. [`subcommands`](./parsers/subcommands.md) are built using nested [`command`](./parsers/command.md)s. Every [`command`](./parsers/command.md) is built with [`flag`](./parsers/flags.md), [`option`](./parsers/options.md) and [`positional`](./parsers/positionals.md) arguments. Here is a short parser description: - [`positional` and `restPositionals`](./parsers/positionals.md) to read arguments by position - [`option` and `multioption`](./parsers/options.md) to read binary `--key value` arguments - [`flag` and `multiflag`](./parsers/flags.md) to read unary `--key` arguments - [`command`](./parsers/command.md) to compose multiple arguments into a command line app - [`subcommands`](./parsers/subcommands.md) to compose multiple command line apps into one command line app - [`binary`](./parsers/binary.md) to make a command line app a UNIX-executable-ready command ================================================ FILE: example/app.ts ================================================ #!/usr/bin/env YARN_SILENT=1 yarn ts-node /* istanbul ignore file */ import { binary, boolean, command, extendType, flag, number, option, optional, positional, restPositionals, run, string, subcommands, union, } from "../src"; import { Integer, ReadStream } from "./test-types"; const complex = command({ version: "6.6.6-alpha", args: { pos1: positional({ displayName: "pos1", type: Integer, }), pos2: positional(), named1: option({ type: Integer, short: "n", long: "number", }), intOrString: option({ type: union([Integer, string]), long: "int-or-string", }), floatOrString: option({ type: union([number, string]), long: "float-or-string", }), optionalOption: option({ type: optional(string), long: "optional-option", }), optionWithoutType: option({ long: "no-type-option", }), optionWithDefault: option({ long: "optional-with-default", env: "SOME_ENV_VAR", type: { ...string, defaultValue: () => "Hello", defaultValueIsSerializable: true, }, }), bool: flag({ type: boolean, long: "boolean", }), boolWithoutType: flag({ long: "bool-without-type" }), rest: restPositionals(), }, name: "printer", description: "Just prints the arguments", examples: [ { description: "Print with required args", command: "complex 42 hello -n 10 --int-or-string 5 --float-or-string 3.14", }, { description: "Using a string for int-or-string", command: "complex 1 world -n 1 --int-or-string foo --float-or-string bar", }, ], handler: (args) => { /** @export complex -> intOrString */ const x = args.intOrString; /** @export complex -> floatOrString */ const y = args.floatOrString; /** @export complex -> pos2 */ const pos2 = args.pos2; /** @export complex -> boolWithoutType */ const boolWithoutType = args.boolWithoutType; /** @export complex -> optionWithoutType */ const optionWithoutType = args.optionWithoutType; /** @export complex -> rest */ const restPos = args.rest; console.log( "I got", args, x, y, pos2, optionWithoutType, boolWithoutType, restPos, ); }, }); const withStream = command({ args: { stream: positional({ displayName: "stream", type: ReadStream, }), restStreams: restPositionals({ displayName: "stream", type: ReadStream, }), }, description: "A simple `cat` clone", name: "cat", aliases: ["read"], handler: (result) => { /** @export cat -> stream */ const stream = result.stream; /** @export cat -> restStreams */ const restStreams = result.restStreams; [stream, ...restStreams].forEach((s) => s.pipe(process.stdout)); }, }); const composed = subcommands({ name: "another-command", cmds: { cat: withStream, }, description: "a nested subcommand", }); const Name = extendType(string, { async from(s) { if (s.length === 0) { throw new Error("name cannot be empty"); } if (s === "Bon Jovi") { throw new Error(`Woah, we're half way there\nWoah! living on a prayer!`); } if (s.charAt(0).toUpperCase() !== s.charAt(0)) { throw new Error("name must be capitalized"); } return s; }, displayName: "name", }); const withSubcommands = subcommands({ cmds: { complex, cat: withStream, greet: command({ name: "greet", description: "greet a person", args: { times: option({ type: { ...Integer, defaultValue: () => 1 }, long: "times", }), name: positional({ displayName: "name", type: Name, }), noExclaim: flag({ type: boolean, long: "no-exclaim", }), greeting: option({ long: "greeting", type: string, description: "the greeting to say", env: "GREETING_NAME", }), }, examples: [ { description: "Greet someone", command: "greet --greeting Hello World", }, { description: "Greet multiple times", command: "greet --greeting Hi --times 3 Gal", }, ], handler: (result) => { const args = result; /** @export greet -> greeting */ const greeting = args.greeting; /** @export greet -> noExclaim */ const noExclaim = args.noExclaim; /** @export greet -> name */ const name = args.name; const exclaim = noExclaim ? "" : "!"; console.log(`${greeting}, ${name}${exclaim}`); }, }), composed, }, name: "subcmds", description: "An awesome subcommand app!", version: "1.0.0", examples: [ { description: "Show help for a command", command: "subcmds greet --help" }, { description: "Run the cat command", command: "subcmds cat ./file.txt" }, ], }); const cli = binary(withSubcommands); async function main() { await run(cli, process.argv); } main(); ================================================ FILE: example/app2.ts ================================================ #!/usr/bin/env YARN_SILENT=1 yarn ts-node import { type Type, boolean, command, extendType, flag, option, run, string, } from "../src"; const PrNumber = extendType(string, { async from(branchName) { const prNumber = branchName === "master" ? "10" : undefined; if (!prNumber) { throw new Error(`There is no PR associated with branch '${branchName}'`); } return prNumber; }, defaultValue: () => "Hello", }); const Repo: Type = { ...string, defaultValue: () => { throw new Error("Can't infer repo from git"); }, description: "repository uri", displayName: "uri", }; const app = command({ name: "build", args: { user: option({ type: string, env: "APP_USER", long: "user", short: "u", }), password: option({ type: string, env: "APP_PASS", long: "password", short: "p", }), repo: option({ type: Repo, long: "repo", short: "r", }), prNumber: option({ type: PrNumber, short: "b", long: "pr-number", env: "APP_BRANCH", }), dev: flag({ type: boolean, long: "dev", short: "D", }), }, handler: ({ repo, user, password, prNumber, dev }) => { console.log({ repo, user, password, prNumber, dev }); }, }); run(app, process.argv.slice(2)); ================================================ FILE: example/app3.ts ================================================ import { inspect } from "node:util"; import { command, number, option, optional, positional, run, string, subcommands, } from "../src"; const sub1 = command({ name: "sub1", args: { name: option({ type: string, long: "name" }), }, handler: ({ name }) => { console.log({ name }); }, }); const sub2 = command({ name: "sub2", args: { age: positional({ type: optional(number) }), name: positional({ type: { ...string, defaultValue: () => "anonymous", defaultValueIsSerializable: true, }, }), }, handler({ name, age }) { console.log(inspect({ name, age })); }, }); const nested = subcommands({ name: "subcmds", cmds: { sub1, sub2, }, }); run(nested, process.argv.slice(2)); ================================================ FILE: example/app4.ts ================================================ #!/usr/bin/env YARN_SILENT=1 yarn ts-node import { command, extendType, option, run, string } from "../src"; const AsyncType = extendType(string, { async from(str) { return str; }, onMissing: () => Promise.resolve("default value"), description: "A type with onMissing callback", }); const app = command({ name: "async-test", args: { asyncArg: option({ type: AsyncType, long: "async-arg", short: "a", }), asyncArg2: option({ long: "async-arg-2", type: AsyncType, defaultValue: () => "Hi", defaultValueIsSerializable: true, }), arg3: option({ long: "async-arg-3", type: string, onMissing: () => "Hello from opt", }), }, handler: ({ asyncArg, asyncArg2, arg3 }) => { console.log(`Result: ${asyncArg}, ${asyncArg2}, ${arg3}`); }, }); run(app, process.argv.slice(2)); ================================================ FILE: example/app5.ts ================================================ #!/usr/bin/env YARN_SILENT=1 yarn ts-node import { command, extendType, option, run, string } from "../src"; const AsyncFailureType = extendType(string, { async from(str) { return str; }, onMissing: () => Promise.reject(new Error("Async onMissing failed")), description: "A type with onMissing callback that fails", }); const app = command({ name: "async-test-failure", args: { failArg: option({ type: AsyncFailureType, long: "fail-arg", short: "f", }), }, handler: ({ failArg }) => { console.log(`Result: ${failArg}`); }, }); run(app, process.argv.slice(2)); ================================================ FILE: example/app6.ts ================================================ #!/usr/bin/env YARN_SILENT=1 yarn ts-node import { command, flag, run } from "../src"; const app = command({ name: "flag-onmissing-demo", args: { verbose: flag({ long: "verbose", short: "v", description: "Enable verbose output", onMissing: () => { console.log("🤔 Verbose flag not provided, checking environment..."); return process.env.NODE_ENV === "development"; }, }), debug: flag({ long: "debug", short: "d", description: "Enable debug mode", onMissing: async () => { console.log("🔍 Debug flag missing, simulating config check..."); await new Promise((resolve) => setTimeout(resolve, 100)); return Math.random() > 0.5; // Simulate config-based decision }, }), force: flag({ long: "force", short: "f", description: "Force operation without confirmation", onMissing: () => { console.log("⚠️ Force flag not set, would normally prompt user..."); // In real scenario: return prompt("Force operation? (y/n)") === "y" return false; // Safe default for demo }, }), }, handler: ({ verbose, debug, force }) => { console.log("\n📋 Results:"); console.log(` Verbose: ${verbose ? "✅" : "❌"}`); console.log(` Debug: ${debug ? "✅" : "❌"}`); console.log(` Force: ${force ? "✅" : "❌"}`); }, }); run(app, process.argv.slice(2)); ================================================ FILE: example/app7.ts ================================================ #!/usr/bin/env YARN_SILENT=1 yarn ts-node import { command, multioption, run } from "../src"; import type { Type } from "../src/type"; // Create a simple string array type for multioption const stringArray: Type = { async from(strings) { return strings; }, displayName: "string", }; const app = command({ name: "multioption-onmissing-demo", args: { includes: multioption({ long: "include", short: "i", type: stringArray, description: "Files to include in processing", onMissing: async () => { console.log("📁 No includes specified, discovering files..."); await new Promise((resolve) => setTimeout(resolve, 100)); // Simulate filesystem discovery return ["src/**/*.ts", "lib/**/*.js"]; }, }), targets: multioption({ long: "target", short: "t", type: stringArray, description: "Build targets", onMissing: () => { console.log("🎯 No targets specified, using environment defaults..."); return process.env.NODE_ENV === "production" ? ["es2020", "node16"] : ["esnext", "node18"]; }, }), features: multioption({ long: "feature", short: "f", type: stringArray, description: "Features to enable", onMissing: async () => { console.log("🚀 No features specified, checking available features..."); await new Promise((resolve) => setTimeout(resolve, 150)); const available = ["auth", "db", "api", "ui"]; // Simulate interactive selection or config-based defaults return available.slice(0, 2); // Return first 2 as default }, }), }, handler: ({ includes, targets, features }) => { console.log("\n📋 Configuration:"); console.log(` Includes: [${includes.join(", ")}]`); console.log(` Targets: [${targets.join(", ")}]`); console.log(` Features: [${features.join(", ")}]`); }, }); run(app, process.argv.slice(2)); ================================================ FILE: example/negative-numbers.ts ================================================ import { binary, command, number, option, run } from "../src"; export function createCmd() { const cmd = command({ name: "test", args: { number: option({ long: "number", type: number, }), }, async handler(args) { console.log({ args }); }, }); return cmd; } if (require.main === module) { const cmd = createCmd(); run(binary(cmd), process.argv); } ================================================ FILE: example/rest-example.ts ================================================ import { binary, command, positional, rest, run } from "../src"; const cmd = command({ args: { scriptName: positional(), everythingElse: rest(), }, name: "hi", handler({ scriptName, everythingElse }) { console.log(JSON.stringify({ scriptName, everythingElse })); }, }); run(binary(cmd), process.argv); ================================================ FILE: example/test-types.ts ================================================ /* istanbul ignore file */ import type { Stream } from "node:stream"; import { URL } from "node:url"; import { createReadStream, pathExists, stat } from "fs-extra"; import fetch from "node-fetch"; import { type Type, extendType, number } from "../src"; export const Integer: Type = extendType(number, { async from(n) { if (Math.round(n) !== n) { throw new Error("This is a floating-point number"); } return n; }, }); function stdin() { return (global as any).mockStdin || process.stdin; } export const ReadStream: Type = { description: "A file path or a URL to make a GET request to", displayName: "file", async from(str) { if (str.startsWith("http://") || str.startsWith("https://")) { const parsedUrl = new URL(str); if (parsedUrl.protocol?.startsWith("http")) { const response = await fetch(str); const statusGroup = Math.floor(response.status / 100); if (statusGroup !== 2) { throw new Error( `Got status ${response.statusText} ${response.status} reading URL`, ); } return response.body; } } if (str === "-") { return stdin(); } if (!(await pathExists(str))) { throw new Error(`Can't find file in path ${str}`); } const fileStat = await stat(str); if (!fileStat.isFile()) { throw new Error("Path is not a file."); } return createReadStream(str); }, }; export function readStreamToString(s: Stream): Promise { return new Promise((resolve, reject) => { let str = ""; s.on("data", (x) => { str += x.toString(); }); s.on("error", (e) => reject(e)); s.on("end", () => resolve(str)); }); } export const CommaSeparatedString: Type = { description: "comma seperated string", async from(s) { return s.split(/, ?/); }, }; ================================================ FILE: example/tsconfig.json ================================================ { "extends": "../tsconfig.noEmit.json", "include": ["."] } ================================================ FILE: example/vercel-formatter.ts ================================================ import { vercelFormatter } from "../batteries/vercel-formatter"; import { setDefaultHelpFormatter } from "../src"; setDefaultHelpFormatter(vercelFormatter); import("./app"); ================================================ FILE: jest.config.js ================================================ module.exports = { testEnvironment: "node", transform: { "^.+\\.(t|j)sx?$": ["@swc-node/jest"], }, }; ================================================ FILE: package.json ================================================ { "name": "cmd-ts", "version": "0.15.0", "homepage": "https://cmd-ts.now.sh", "license": "MIT", "author": "Gal Schlezinger", "main": "dist/cjs/index.js", "typings": "dist/cjs/index.d.ts", "module": "dist/esm/index.js", "repository": { "type": "git", "url": "https://github.com/Schniz/cmd-ts" }, "files": ["dist", "batteries"], "scripts": { "build": "tsc && tsc --project ./tsconfig.esm.json", "lint": "biome check src test example", "now-build": "mdbook build --dest-dir=public", "start": "pnpm build --watch", "prepublishOnly": "rm -rf dist && pnpm build && pnpm test", "test": "vitest", "ts-node": "tsx", "changeset:version": "changeset version && pnpm install --no-frozen-lockfile", "changeset:publish": "pnpm run build && changeset publish" }, "husky": { "hooks": { "pre-commit": "pnpm lint" } }, "sideEffects": false, "prettier": { "printWidth": 80, "semi": true, "singleQuote": true, "trailingComma": "es5" }, "dependencies": { "chalk": "^5.4.1", "debug": "^4.4.1", "didyoumean": "^1.2.2", "strip-ansi": "^7.1.0" }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@changesets/cli": "2.29.4", "@types/debug": "4.1.12", "@types/didyoumean": "1.2.3", "@types/fs-extra": "11.0.4", "@types/node-fetch": "2.6.12", "@types/request": "2.48.12", "cargo-mdbook": "0.4.4", "docs-ts": "0.8.0", "execa": "9.6.0", "fs-extra": "11.3.0", "husky": "9.1.7", "infer-types": "0.0.2", "node-fetch": "3.3.2", "request": "2.88.2", "tempy": "3.1.0", "tsx": "^4.19.4", "typedoc": "0.28.5", "typescript": "5.8.3", "vitest": "3.2.4" }, "packageManager": "pnpm@10.11.1" } ================================================ FILE: renovate.json ================================================ { "labels": ["PR: Dependency Update"], "packageRules": [ { "packagePatterns": ["*"], "minor": { "groupName": "all non-major dependencies", "groupSlug": "all-minor-patch" } } ], "extends": ["config:base"] } ================================================ FILE: scripts/print-all-states.js ================================================ #!/usr/bin/env node const chalk = require("chalk"); const allSnapshots = require("../test/__snapshots__/ui.test.ts.snap"); for (const [snapName, snapshot] of Object.entries(allSnapshots)) { const snapNameWithoutNumber = snapName.match(/^(.+) \d+$/)[1]; console.log(chalk.bold.underline(snapNameWithoutNumber)); console.log( snapshot .trim() .slice(1, -1) .split("\n") .map((x) => ` ${x}`) .join("\n"), ); console.log(); console.log(); } ================================================ FILE: scripts/ts-node ================================================ #!/bin/bash DIRECTORY="$(dirname "$0")" SWC_NODE_PROJECT=$DIRECTORY/tsconfig.json node -r esm -r @swc-node/register "$@" ================================================ FILE: scripts/tsconfig.json ================================================ { "extends": "../tsconfig.noEmit.json", "include": [".."], "exclude": ["../dist"] } ================================================ FILE: src/Result.ts ================================================ /** * A successful value */ export type Ok = { _tag: "ok"; value: R }; /** * A failed value */ export type Err = { _tag: "error"; error: L }; /** * A safe result type: imagine a language with no exceptions — the way to handle * errors would be to use something like a tagged union type. * * Why would we want that? I want to explicitly handle exceptions in this library * and having this construct really helps. It's also pretty easy to implement. */ export type Result = Err | Ok; export function ok(value: O): Ok { return { _tag: "ok", value }; } export function err(error: E): Err { return { _tag: "error", error }; } /** * Checks whether a value is an `Ok`. * Handy with TypeScript guards */ export function isOk(result: Result): result is Ok { return result._tag === "ok"; } /** * Checks whether a value is an `Err`. * Handy with TypeScript guards */ export function isErr(either: Result): either is Err { return either._tag === "error"; } /** * Convert a `Promise` into a `Promise>`, * therefore catching the errors and being able to handle them explicitly */ export async function safeAsync( promise: Promise, ): Promise> { try { const value = await promise; return ok(value); } catch (e: any) { return err(e); } } ================================================ FILE: src/argparser.ts ================================================ import type { Result } from "./Result"; import type { AstNode } from "./newparser/parser"; export type Nodes = AstNode[]; export type ParsingError = { /** Relevant nodes that should be shown with the message provided */ nodes: AstNode[]; /** Why did the parsing failed? */ message: string; }; export type FailedParse = { errors: ParsingError[]; /** The content that was parsed so far */ partialValue?: unknown; }; export type ParseContext = { /** The nodes we parsed */ nodes: Nodes; /** * A set of nodes that were already visited. Helpful when writing a parser, * and wanting to skip all the nodes we've already used */ visitedNodes: Set; /** The command path breadcrumbs, to print when asking for help */ hotPath?: string[]; }; export type ParsingResult = Result; /** * A weird thing about command line interfaces is that they are not consistent without some context. * Consider the following argument list: `my-app --server start` * * Should we parse it as `[positional my-app] [option --server start]` * or should we parse it as `[positional my-app] [flag --server] [positional start]`? * * The answer is — it depends. A good command line utility has the context to know which key is a flag * and which is an option that can take a value. We aim to be a good command line utility library, so * we need to have the ability to provide this context. * * This is the small object that has this context. */ export type RegisterOptions = { forceFlagLongNames: Set; forceFlagShortNames: Set; forceOptionLongNames: Set; forceOptionShortNames: Set; }; export type Register = { /** * Inform the parser with context before parsing. * Right now, only used to force flags in the parser. */ register(opts: RegisterOptions): void; }; export type ArgParser = Partial & { /** * Parse from AST nodes into the value provided in [[Into]]. * * @param context The parsing context */ parse(context: ParseContext): Promise>; }; export type ParsingInto> = AP extends ArgParser< infer R > ? R : never; ================================================ FILE: src/batteries/fs.ts ================================================ import fs from "node:fs"; import path from "node:path"; import { extendType, string } from ".."; /** * Resolves an existing path. Produces an error when path does not exist. * When provided a relative path, extends it using the current working directory. */ export const ExistingPath = extendType(string, { displayName: "path", description: "An existing path", async from(str) { const resolved = path.resolve(str); if (!fs.existsSync(resolved)) { throw new Error("Path doesn't exist"); } return resolved; }, }); /** * Resolves to a directory if given one, and to a file's directory if file was given. * Fails when the directory or the given file does not exist. */ export const Directory = extendType(ExistingPath, { async from(resolved) { const stat = fs.statSync(resolved); if (stat.isDirectory()) { return resolved; } return path.dirname(resolved); }, displayName: "dir", description: "A path to a directory or a file within a directory", }); /** * Resolves to a path to an existing file */ export const File = extendType(ExistingPath, { async from(resolved) { const stat = fs.statSync(resolved); if (stat.isFile()) { return resolved; } throw new Error("Provided path is not a file"); }, displayName: "file", description: "A file in the file system", }); ================================================ FILE: src/batteries/url.ts ================================================ import { URL } from "node:url"; import { extendType, string } from ".."; /** * Decodes a string into the `URL` type */ export const Url = extendType(string, { displayName: "url", description: "A valid URL", async from(str): Promise { const url = new URL(str); if (!url.protocol || !url.host) { throw new Error("Malformed URL"); } if (!["http:", "https:"].includes(url.protocol as string)) { throw new Error("Only allowed http and https URLs"); } return url; }, }); /** * Decodes an http/https only URL */ export const HttpUrl = extendType(Url, { async from(url): Promise { if (!["http:", "https:"].includes(url.protocol as string)) { throw new Error("Only allowed http and https URLs"); } return url; }, }); ================================================ FILE: src/batteries/vercel-formatter.ts ================================================ import chalk from "chalk"; import type { ParseContext } from "../argparser"; import type { CommandHelpData, HelpFormatter, SubcommandsHelpData, } from "../helpFormatter"; /** * Configuration for the Vercel-style help formatter */ export type VercelFormatterConfig = { /** * The CLI name to display in the header (e.g., "Vercel Sandbox CLI") */ cliName?: string; /** * Logo/symbol to display before the command name (e.g., "▲") */ logo?: string; }; /** * Extract argument hints from help topics. * Returns a string like "[cmd]" or "[src] [dst]" based on positional arguments. */ function getArgHint(helpTopics: { category: string; usage: string }[]): string { return helpTopics .filter((t) => t.category === "arguments") .map((t) => t.usage) .join(" "); } /** * Format command name with aliases in "alias | name" style. * Prefers shorter alias first for display. */ function formatCommandName(name: string, aliases?: string[]): string { if (!aliases?.length) { return name; } // Sort aliases by length to prefer shorter ones first const sortedAliases = [...aliases].sort((a, b) => a.length - b.length); return `${sortedAliases[0]} | ${name}`; } /** * Create a Vercel-style help formatter. * * This formatter produces output similar to the Vercel CLI with: * - A header with CLI name and version * - Logo symbol before the command name * - Column-aligned commands with aliases shown as "short | long" * - Argument hints derived from positional arguments * - Examples section with highlighted commands * * @example * ```ts * import { setDefaultHelpFormatter } from "cmd-ts"; * import { createVercelFormatter } from "cmd-ts/batteries/vercelFormatter"; * * setDefaultHelpFormatter(createVercelFormatter({ * cliName: "Vercel Sandbox CLI", * logo: "▲", * })); * ``` */ export function createVercelFormatter( config: VercelFormatterConfig = {}, ): HelpFormatter { const { cliName, logo = "▲" } = config; return { formatCommand(data: CommandHelpData, _context: ParseContext): string { const lines: string[] = []; const displayName = cliName ?? data.name; let header = displayName; if (data.version) { header += ` ${data.version}`; } lines.push(chalk.grey(header)); // Command usage line lines.push(""); const path = data.path.length > 0 ? data.path.join(" ") : data.name; lines.push(`${logo} ${chalk.bold(path)} [options]`); // Description if (data.description) { lines.push(""); lines.push(chalk.dim(data.description)); } // Group help topics by category const byCategory = new Map(); for (const topic of data.helpTopics) { const existing = byCategory.get(topic.category) ?? []; existing.push(topic); byCategory.set(topic.category, existing); } // Render each category for (const [category, topics] of byCategory) { lines.push(""); lines.push(chalk.dim(`${capitalize(category)}:`)); lines.push(""); // Calculate max width for alignment const maxUsageWidth = Math.max(...topics.map((t) => t.usage.length)); for (const topic of topics) { const defaults = topic.defaults.length > 0 ? chalk.dim(` [${topic.defaults.join(", ")}]`) : ""; lines.push( ` ${topic.usage.padEnd(maxUsageWidth + 2)}${chalk.dim(topic.description)}${defaults}`, ); } } // Examples if (data.examples && data.examples.length > 0) { lines.push(""); lines.push(chalk.dim("Examples:")); for (const example of data.examples) { lines.push(""); lines.push(`${chalk.gray("–")} ${example.description}`); lines.push(""); lines.push(chalk.cyan(` $ ${example.command}`)); } } lines.push(""); return lines.join("\n"); }, formatSubcommands( data: SubcommandsHelpData, _context: ParseContext, ): string { const lines: string[] = []; const path = data.path.length > 0 ? data.path.join(" ") : data.name; const displayName = cliName ?? path; // Header if (data.version) { lines.push(chalk.grey(`${displayName} ${data.version}`)); } else { lines.push(chalk.grey(displayName)); } // Command usage line lines.push(""); lines.push(`${logo} ${chalk.bold(path)} [options] `); // Help hint lines.push(""); lines.push( chalk.dim(`For command help, run \`${path} --help\``), ); // Commands section lines.push(""); lines.push(chalk.dim("Commands:")); lines.push(""); // Calculate column widths const commandNames = data.commands.map((cmd) => formatCommandName(cmd.name, cmd.aliases), ); const maxNameWidth = Math.max(...commandNames.map((n) => n.length)); const argHints = data.commands.map((cmd) => getArgHint(cmd.helpTopics)); const maxArgWidth = Math.max(...argHints.map((a) => a.length), 0); // Render commands for (let i = 0; i < data.commands.length; i++) { const cmd = data.commands[i]; const displayCommandName = commandNames[i]; const argHint = argHints[i]; lines.push( ` ${displayCommandName.padEnd(maxNameWidth + 2)}${chalk.dim(argHint.padEnd(maxArgWidth + 2))}${chalk.dim(cmd.description ?? "")}`, ); } // Examples if (data.examples && data.examples.length > 0) { lines.push(""); lines.push(chalk.dim("Examples:")); for (const example of data.examples) { lines.push(""); lines.push(`${chalk.gray("–")} ${example.description}`); lines.push(""); lines.push(chalk.cyan(` $ ${example.command}`)); } } lines.push(""); return lines.join("\n"); }, }; } function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } /** * A pre-configured Vercel-style formatter with default settings. * Uses "▲" as the logo and derives the CLI name from the command name. */ export const vercelFormatter = createVercelFormatter(); ================================================ FILE: src/binary.ts ================================================ import type { ParseContext } from "./argparser"; import type { Named } from "./helpdoc"; import type { Runner } from "./runner"; /** * A small helper to easily use `process.argv` without dropping context * * @param cmd a command line parser */ export function binary & Named>( cmd: Command, ): Command { return { ...cmd, run(context: ParseContext) { const name = cmd.name || context.nodes[1].raw; context.hotPath?.push(name); context.nodes.splice(0, 1); context.nodes[0].raw = name; context.visitedNodes.add(context.nodes[0]); return cmd.run(context); }, }; } ================================================ FILE: src/chalk.ts ================================================ import chalk, { type ChalkInstance } from "chalk"; let mode: "chalk" | "tags" | "disabled" = "chalk"; export function setMode(newMode: typeof mode) { mode = newMode; } type ColorizerFunction = (...strings: string[]) => string; type AllColorOptions = Extract< (typeof allColors)[keyof typeof allColors], string >; type Colored = { [key in AllColorOptions]: ColoredFunction; }; type ColoredFunction = ColorizerFunction & Colored; type Strategy = ( levels: AllColorOptions[], str: string, ) => ReturnType; function withLevels( levels: AllColorOptions[], strategy: Strategy, ): ColoredFunction { const fn: ColorizerFunction = (str) => strategy(levels, str); Object.assign(fn, generateColoredBody(fn, levels, strategy)); return fn as any; } const allColors = [ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray", "grey", "blackBright", "redBright", "greenBright", "yellowBright", "blueBright", "magentaBright", "cyanBright", "whiteBright", "italic", "bold", "underline", ] as const; function generateColoredBody( wrapper: T, levels: AllColorOptions[], strategy: Strategy, ): T & Colored { const c = allColors.reduce( (acc, curr) => { Object.defineProperty(acc, curr, { get() { const x = withLevels([...levels, curr], strategy); return x; }, }); return acc; }, wrapper as T & Colored, ); return c; } const chalked = generateColoredBody({}, [], (levels, str) => { const color = levels.reduce((c, curr) => c[curr], chalk); return color(str); }); const tagged = generateColoredBody({}, [], (levels, str) => { const [start, end] = levels.reduce( (acc, curr) => { acc[0] += `<${curr}>`; acc[1] = `${acc[1]}`; return acc; }, ["", ""], ); return `${start}${str}${end}`; }); const disabled = generateColoredBody({}, [], (_levels, str) => str); export function colored(): Colored { if (mode === "chalk") return chalked; if (mode === "disabled") return disabled; return tagged; } setMode("tags"); console.log(colored().red.bold("hello")); setMode("chalk"); console.log(colored().red.bold("hello")); ================================================ FILE: src/circuitbreaker.ts ================================================ import * as Result from "./Result"; import type { ArgParser, ParseContext, Register } from "./argparser"; import { Exit } from "./effects"; import { flag } from "./flag"; import type { PrintHelp, ProvidesHelp, Versioned } from "./helpdoc"; import { boolean } from "./types"; type CircuitBreaker = "help" | "version"; export const helpFlag = flag({ long: "help", short: "h", type: boolean, description: "show help", }); export const versionFlag = flag({ long: "version", short: "v", type: boolean, description: "print the version", }); export function handleCircuitBreaker( context: ParseContext, value: PrintHelp & Partial, breaker: Result.Result, ): void { if (Result.isErr(breaker)) { return; } if (breaker.value === "help") { const message = value.printHelp(context); throw new Exit({ exitCode: 0, message, into: "stdout" }); } if (breaker.value === "version") { const message = value.version || "0.0.0"; throw new Exit({ exitCode: 0, message, into: "stdout" }); } } /** * Helper flags that are being used in `command` and `subcommands`: * `--help, -h` to show help * `--version, -v` to show the current version * * It is called circuitbreaker because if you have `--help` or `--version` * anywhere in your argument list, you'll see the version and the help for the closest command */ export function createCircuitBreaker( withVersion: boolean, ): ArgParser & ProvidesHelp & Register { return { register(opts) { helpFlag.register(opts); if (withVersion) { versionFlag.register(opts); } }, helpTopics() { const helpTopics = helpFlag.helpTopics(); if (withVersion) { helpTopics.push(...versionFlag.helpTopics()); } return helpTopics; }, async parse(context) { const help = await helpFlag.parse(context); const version = withVersion ? await versionFlag.parse(context) : undefined; if (Result.isErr(help) || (version && Result.isErr(version))) { const helpErrors = Result.isErr(help) ? help.error.errors : []; const versionErrors = version && Result.isErr(version) ? version.error.errors : []; return Result.err({ errors: [...helpErrors, ...versionErrors] }); } if (help.value) { return Result.ok("help"); } if (version?.value) { return Result.ok("version"); } return Result.err({ errors: [ { nodes: [], message: "Neither help nor version", }, ], }); }, }; } ================================================ FILE: src/command.ts ================================================ import * as Result from "./Result"; import type { ArgParser, ParseContext, ParsingError, ParsingInto, ParsingResult, } from "./argparser"; import { createCircuitBreaker, handleCircuitBreaker } from "./circuitbreaker"; import { type CommandHelpData, type Example, getHelpFormatter, } from "./helpFormatter"; import type { Aliased, Descriptive, Named, PrintHelp, ProvidesHelp, Versioned, } from "./helpdoc"; import type { AstNode } from "./newparser/parser"; import type { Runner } from "./runner"; import { entries, flatMap } from "./utils"; type ArgTypes = Record & Partial>; type HandlerFunc = (args: Output) => any; type CommandConfig< Arguments extends ArgTypes, Handler extends HandlerFunc, > = { args: Arguments; version?: string; name: string; description?: string; handler: Handler; aliases?: string[]; /** Examples to show in help output */ examples?: Example[]; }; type Output = { [key in keyof Args]: ParsingInto; }; /** * A command line utility. * * A combination of multiple flags, options and arguments * with a common name and a handler that expects them as input. */ export function command< Arguments extends ArgTypes, Handler extends HandlerFunc, >( config: CommandConfig, ): ArgParser> & PrintHelp & ProvidesHelp & Named & Runner, ReturnType> & Partial { const argEntries = entries(config.args); const circuitbreaker = createCircuitBreaker(!!config.version); return { name: config.name, aliases: config.aliases, handler: config.handler, description: config.description, version: config.version, helpTopics() { return flatMap( Object.values(config.args).concat([circuitbreaker]), (x) => x.helpTopics?.() ?? [], ); }, printHelp(context) { const data: CommandHelpData = { name: config.name, path: context.hotPath ?? [config.name], version: config.version, description: config.description, aliases: config.aliases, helpTopics: this.helpTopics(), examples: config.examples, }; return getHelpFormatter().formatCommand(data, context); }, register(opts) { for (const [, arg] of argEntries) { arg.register?.(opts); } }, async parse( context: ParseContext, ): Promise>> { if (context.hotPath?.length === 0) { context.hotPath.push(config.name); } const resultObject = {} as Output; const errors: ParsingError[] = []; for (const [argName, arg] of argEntries) { const result = await arg.parse(context); if (Result.isErr(result)) { errors.push(...result.error.errors); } else { resultObject[argName] = result.value; } } const unknownArguments: AstNode[] = []; for (const node of context.nodes) { if (context.visitedNodes.has(node)) { continue; } if (node.type === "forcePositional") { } else if (node.type === "shortOptions") { for (const option of node.options) { if (context.visitedNodes.has(option)) { continue; } unknownArguments.push(option); } } else { unknownArguments.push(node); } } if (unknownArguments.length > 0) { errors.push({ message: "Unknown arguments", nodes: unknownArguments, }); } if (errors.length > 0) { return Result.err({ errors: errors, partialValue: resultObject, }); } return Result.ok(resultObject); }, async run(context) { const breaker = await circuitbreaker.parse(context); handleCircuitBreaker(context, this, breaker); const parsed = await this.parse(context); if (Result.isErr(parsed)) { return Result.err(parsed.error); } return Result.ok(await this.handler(parsed.value)); }, }; } ================================================ FILE: src/default.ts ================================================ export type Default = { /** * A default value to be provided when a value is missing. * i.e., `string | null` should probably return `null`. * This should be synchronous for fast help generation. */ defaultValue(): T; defaultValueIsSerializable?: boolean; }; export type OnMissing = { /** * A callback that will be executed when the value is missing from user input. * This could be used for interactive prompts, reading from config files, * API calls, or any dynamic fallback. Only executed during actual parsing, * never during help generation. */ onMissing(): T | Promise; }; ================================================ FILE: src/effects.ts ================================================ /** * "Effects" are custom exceptions that can do stuff. * The concept comes from React, where they throw a `Promise` to provide the ability to write * async code with synchronous syntax. * * These effects _should stay an implementation detail_ and not leak out of the library. * * @packageDocumentation */ import chalk from "chalk"; /** * An effect to exit the program with a message * * **Why this is an effect?** * * Using `process.exit` in a program is both a problem: * * in tests, because it needs to be mocked somehow * * in browser usage, because it does not have `process` at all * * Also, using `console.log` is something we'd rather avoid and return strings, and if returning strings * would be harmful for performance we might ask for a stream to write to: * Printing to stdout and stderr means that we don't control the values and it ties us to only use `cmd-ts` * with a command line, and again, to mock `stdout` and `stderr` it if we want to test it. */ export class Exit { constructor( public readonly config: { exitCode: number; message: string; into: "stdout" | "stderr"; }, ) {} run(): never { const output = this.output(); output(this.config.message); process.exit(this.config.exitCode); } dryRun(): string { const { into, message, exitCode } = this.config; const coloredExit = chalk.dim( `process exited with status ${exitCode} (${into})`, ); return `${message}\n\n${coloredExit}`; } private output() { if (this.config.into === "stderr") { return console.error; } return console.log; } } ================================================ FILE: src/errorBox.ts ================================================ import chalk from "chalk"; import stripAnsi from "strip-ansi"; import type { ParsingError } from "./argparser"; import type { AstNode } from "./newparser/parser"; import { enumerate, padNoAnsi } from "./utils"; type HighlightResult = { colorized: string; errorIndex: number }; /** * Get the input as highlighted keywords to show to the user * with the error that was generated from parsing the input. * * @param nodes AST nodes * @param error A parsing error */ function highlight( nodes: AstNode[], error: ParsingError, ): HighlightResult | undefined { const strings: string[] = []; let errorIndex: undefined | number = undefined; function foundError() { if (errorIndex !== undefined) return; errorIndex = stripAnsi(strings.join(" ")).length; } if (error.nodes.length === 0) return; nodes.forEach((node) => { if (error.nodes.includes(node)) { foundError(); return strings.push(chalk.red(node.raw)); } if (node.type === "shortOptions") { let failed = false; let s = ""; for (const option of node.options) { if (error.nodes.includes(option)) { s += chalk.red(option.raw); failed = true; } else { s += chalk.dim(option.raw); } } const prefix = failed ? chalk.red("-") : chalk.dim("-"); if (failed) { foundError(); } return strings.push(prefix + s); } return strings.push(chalk.dim(node.raw)); }); return { colorized: strings.join(" "), errorIndex: errorIndex ?? 0 }; } /** * An error UI * * @param breadcrumbs The command breadcrumbs to print with the error */ export function errorBox( nodes: AstNode[], errors: ParsingError[], breadcrumbs: string[], ): string { const withHighlight: { message: string; highlighted?: HighlightResult }[] = []; const errorMessages: string[] = []; for (const error of errors) { const highlighted = highlight(nodes, error); withHighlight.push({ message: error.message, highlighted }); } let number = 1; const maxNumberWidth = String(withHighlight.length).length; errorMessages.push( `${chalk.red.bold("error: ")}found ${chalk.yellow(withHighlight.length)} error${withHighlight.length > 1 ? "s" : ""}`, ); errorMessages.push(""); withHighlight .filter((x) => x.highlighted) .forEach((x) => { if (!x.highlighted) { throw new Error("WELP"); } const pad = "".padStart(x.highlighted.errorIndex); errorMessages.push(` ${x.highlighted.colorized}`); for (const [index, line] of enumerate(x.message.split("\n"))) { const prefix = index === 0 ? chalk.bold("^") : " "; const msg = chalk.red(` ${pad} ${prefix} ${line}`); errorMessages.push(msg); } errorMessages.push(""); number++; }); const withNoHighlight = withHighlight.filter((x) => !x.highlighted); if (number > 1) { if (withNoHighlight.length === 1) { errorMessages.push("Along with the following error:"); } else if (withNoHighlight.length > 1) { errorMessages.push("Along with the following errors:"); } } withNoHighlight.forEach(({ message }) => { const num = chalk.red.bold( `${padNoAnsi(number.toString(), maxNumberWidth, "start")}.`, ); const lines = message.split("\n"); errorMessages.push(` ${num} ${chalk.red(lines[0] ?? "")}`); for (const line of lines.slice(1)) { errorMessages.push( ` ${"".padStart(maxNumberWidth + 1)} ${chalk.red(line)}`, ); } number++; }); const helpCmd = chalk.yellow(`${breadcrumbs.join(" ")} --help`); errorMessages.push(""); errorMessages.push( `${chalk.red.bold("hint: ")}for more information, try '${helpCmd}'`, ); return errorMessages.join("\n"); } ================================================ FILE: src/flag.ts ================================================ import chalk from "chalk"; import * as Result from "./Result"; import type { ArgParser, ParseContext, ParsingResult, Register, } from "./argparser"; import type { Default, OnMissing } from "./default"; import type { Descriptive, EnvDoc, LongDoc, ProvidesHelp, ShortDoc, } from "./helpdoc"; import { findOption } from "./newparser/findOption"; import { type HasType, type OutputOf, type Type, extendType } from "./type"; import { boolean as booleanIdentity } from "./types"; import type { AllOrNothing } from "./utils"; type FlagConfig> = LongDoc & HasType & Partial>> & AllOrNothing>>; /** * A decoder from `string` to `boolean` * works for `true` and `false` only. */ export const boolean: Type = { async from(str) { if (str === "true") return true; if (str === "false") return false; throw new Error( `expected value to be either "true" or "false". got: "${str}"`, ); }, displayName: "true/false", defaultValue: () => false, }; export function fullFlag>( config: FlagConfig, ): ArgParser> & ProvidesHelp & Register & Partial { const decoder = extendType(boolean, config.type); return { description: config.description ?? config.type.description, helpTopics() { let usage = `--${config.long}`; if (config.short) { usage += `, -${config.short}`; } const defaults: string[] = []; if (config.env) { const env = process.env[config.env] === undefined ? "" : `=${chalk.italic(process.env[config.env])}`; defaults.push(`env: ${config.env}${env}`); } if (config.defaultValue) { try { const defaultValue = config.defaultValue(); if (config.defaultValueIsSerializable) { defaults.push(`default: ${chalk.italic(defaultValue)}`); } else { defaults.push("optional"); } } catch (e) {} } else if (config.type.defaultValue) { try { const defaultValue = config.type.defaultValue(); if (config.type.defaultValueIsSerializable) { defaults.push(`default: ${chalk.italic(defaultValue)}`); } else { defaults.push("optional"); } } catch (e) {} } else if (config.onMissing || config.type.onMissing) { defaults.push("optional"); } return [ { category: "flags", usage, defaults, description: config.description ?? config.type.description ?? "self explanatory", }, ]; }, register(opts) { opts.forceFlagLongNames.add(config.long); if (config.short) { opts.forceFlagShortNames.add(config.short); } }, async parse({ nodes, visitedNodes, }: ParseContext): Promise>> { const options = findOption(nodes, { longNames: [config.long], shortNames: config.short ? [config.short] : [], }).filter((x) => !visitedNodes.has(x)); options.forEach((opt) => visitedNodes.add(opt)); if (options.length > 1) { return Result.err({ errors: [ { nodes: options, message: `Expected 1 occurence, got ${options.length}`, }, ], }); } const valueFromEnv = config.env ? process.env[config.env] : undefined; const onMissingFn = config.onMissing || config.type.onMissing; let rawValue: string; let envPrefix = ""; if (options.length === 0 && valueFromEnv !== undefined) { rawValue = valueFromEnv; envPrefix = `env[${chalk.italic(config.env)}]: `; } else if (options.length === 0 && config.defaultValue) { try { const defaultValue = config.defaultValue(); return Result.ok(defaultValue); } catch (e: any) { const message = `Default value not found for '--${config.long}': ${e.message}`; return Result.err({ errors: [{ message, nodes: [] }], }); } } else if (options.length === 0 && onMissingFn) { try { const missingValue = await onMissingFn(); return Result.ok(missingValue); } catch (e: any) { const message = `Failed to get missing value for '--${config.long}': ${e.message}`; return Result.err({ errors: [{ message, nodes: [] }], }); } } else if (options.length === 0 && config.type.defaultValue) { try { const defaultValue = config.type.defaultValue(); return Result.ok(defaultValue); } catch (e: any) { const message = `Default value not found for '--${config.long}': ${e.message}`; return Result.err({ errors: [{ message, nodes: [] }], }); } } else if (options.length === 1) { rawValue = options[0].value?.node.raw ?? "true"; } else { return Result.err({ errors: [ { nodes: [], message: `No value provided for --${config.long}` }, ], }); } const decoded = await Result.safeAsync(decoder.from(rawValue)); if (Result.isErr(decoded)) { return Result.err({ errors: [ { nodes: options, message: envPrefix + decoded.error.message, }, ], }); } return decoded; }, }; } type BooleanType = Type; /** * Decodes an argument which is in the form of a key and a boolean value, and allows parsing the following ways: * * - `--long` where `long` is the provided `long` * - `-s=value` where `s` is the provided `short` * Shorthand forms can be combined: * - `-abcd` will call all flags for the short forms of `a`, `b`, `c` and `d`. * @param config flag configurations */ export function flag>( config: FlagConfig, ): ArgParser> & ProvidesHelp & Register & Partial; export function flag( config: LongDoc & Partial< HasType & ShortDoc & Descriptive & EnvDoc & OnMissing> > & AllOrNothing>>, ): ArgParser> & ProvidesHelp & Register & Partial; export function flag( config: LongDoc & Partial & ShortDoc & Descriptive & EnvDoc> & AllOrNothing>>, ): ArgParser> & ProvidesHelp & Register & Partial { return fullFlag({ type: booleanIdentity, ...config, }); } ================================================ FILE: src/from.ts ================================================ export type FromFn = (input: A) => Promise; /** A safe conversion from type A to type B */ export type From = { /** * Convert `input` safely and asynchronously into an output. */ from: FromFn; }; /** The output of a conversion type or function */ export type OutputOf | FromFn> = F extends From ? Output : F extends FromFn ? Output : never; /** The input of a conversion type or function */ export type InputOf | FromFn> = F extends From ? Input : F extends FromFn ? Input : never; /** * A type "conversion" from any type to itself */ export function identity(): From { return { async from(a) { return a; }, }; } ================================================ FILE: src/helpFormatter.ts ================================================ import chalk from "chalk"; import type { ParseContext } from "./argparser"; import type { HelpTopic } from "./helpdoc"; import { entries, groupBy, padNoAnsi } from "./utils"; /** * An example to show in help output */ export type Example = { /** Description of what this example does */ description: string; /** The command to run */ command: string; }; /** * Structured data for command help */ export type CommandHelpData = { /** The command name */ name: string; /** Breadcrumb path to this command (e.g., ["sandbox", "create"]) */ path: string[]; /** Version string */ version?: string; /** Description of the command */ description?: string; /** Aliases for the command */ aliases?: string[]; /** Help topics for flags, options, arguments */ helpTopics: HelpTopic[]; /** Examples to show in help output */ examples?: Example[]; }; /** * Structured data for subcommands help */ export type SubcommandsHelpData = { /** The name of the subcommands group */ name: string; /** Breadcrumb path (e.g., ["sandbox"]) */ path: string[]; /** Version string */ version?: string; /** Description of the subcommands group */ description?: string; /** Available subcommands */ commands: Array<{ name: string; description?: string; aliases?: string[]; /** Help topics from the command (to derive argument hints) */ helpTopics: HelpTopic[]; }>; /** Examples to show in help output */ examples?: Example[]; }; /** * Interface for custom help formatters. * Implement this to create your own help output style. */ export interface HelpFormatter { /** Format help output for a single command */ formatCommand(data: CommandHelpData, context: ParseContext): string; /** Format help output for a subcommands group */ formatSubcommands(data: SubcommandsHelpData, context: ParseContext): string; } // Global formatter storage let currentFormatter: HelpFormatter | null = null; /** * Set a custom help formatter to be used for all commands. * * @example * ```ts * import { setDefaultHelpFormatter } from "cmd-ts"; * * setDefaultHelpFormatter({ * formatCommand(data, context) { * return `My CLI ${data.version}\n${data.description}`; * }, * formatSubcommands(data, context) { * return `Commands: ${data.commands.map(c => c.name).join(", ")}`; * }, * }); * ``` */ export function setDefaultHelpFormatter(formatter: HelpFormatter): void { currentFormatter = formatter; } /** * Reset the help formatter to the default. * Useful for testing. */ export function resetHelpFormatter(): void { currentFormatter = null; } /** * Get the current help formatter (custom or default). * @internal */ export function getHelpFormatter(): HelpFormatter { return currentFormatter ?? defaultHelpFormatter; } /** * The default help formatter that matches cmd-ts's original output style. */ export const defaultHelpFormatter: HelpFormatter = { formatCommand(data: CommandHelpData, _context: ParseContext): string { const lines: string[] = []; let name = data.path.length > 0 ? data.path.join(" ") : data.name; name = chalk.bold(name); if (data.version) { name += ` ${chalk.dim(data.version)}`; } lines.push(name); if (data.description) { lines.push(chalk.dim("> ") + data.description); } const usageBreakdown = groupBy(data.helpTopics, (x) => x.category); for (const [category, helpTopics] of entries(usageBreakdown)) { lines.push(""); lines.push(`${category.toUpperCase()}:`); const widestUsage = helpTopics.reduce((len, curr) => { return Math.max(len, curr.usage.length); }, 0); for (const helpTopic of helpTopics) { let line = ""; line += ` ${padNoAnsi(helpTopic.usage, widestUsage, "end")}`; line += " - "; line += helpTopic.description; for (const defaultValue of helpTopic.defaults) { line += chalk.dim(` [${defaultValue}]`); } lines.push(line); } } if (data.examples && data.examples.length > 0) { lines.push(""); lines.push("EXAMPLES:"); for (const example of data.examples) { lines.push(""); lines.push(` ${example.description}`); lines.push(chalk.dim(` $ ${example.command}`)); } } return lines.join("\n"); }, formatSubcommands(data: SubcommandsHelpData, _context: ParseContext): string { const lines: string[] = []; const argsSoFar = data.path.length > 0 ? data.path.join(" ") : data.name; lines.push(chalk.bold(argsSoFar + chalk.italic(" "))); if (data.description) { lines.push(chalk.dim("> ") + data.description); } lines.push(""); lines.push(`where ${chalk.italic("")} can be one of:`); lines.push(""); for (const cmd of data.commands) { let description = cmd.description ?? ""; description = description && ` - ${description} `; if (cmd.aliases?.length) { const aliasTxt = cmd.aliases.length === 1 ? "alias" : "aliases"; const aliases = cmd.aliases.join(", "); description += chalk.dim(`[${aliasTxt}: ${aliases}]`); } const row = chalk.dim("- ") + cmd.name + description; lines.push(row.trim()); } const helpCommand = chalk.yellow(`${argsSoFar} --help`); lines.push(""); lines.push(chalk.dim(`For more help, try running \`${helpCommand}\``)); if (data.examples && data.examples.length > 0) { lines.push(""); lines.push("EXAMPLES:"); for (const example of data.examples) { lines.push(""); lines.push(` ${example.description}`); lines.push(chalk.dim(` $ ${example.command}`)); } } return lines.join("\n"); }, }; ================================================ FILE: src/helpdoc.ts ================================================ import type { ParseContext } from "./argparser"; export type Descriptive = { /** A long description that will be shown on help */ description: string; }; export type Versioned = { /** The item's version */ version: string; }; export type Named = { /** The item's name */ name: string; }; export type Displayed = { /** A short display name that summarizes it */ displayName: string; }; export type HelpTopic = { /** * A category to show in `PrintHelp` */ category: string; /** * How to use this? */ usage: string; /** * A short description of what it does */ description: string; /** * Defaults to show the user */ defaults: string[]; }; export type ProvidesHelp = { helpTopics(): HelpTopic[]; }; export type PrintHelp = { /** * Print help for the current item and the current parsing context. */ printHelp(context: ParseContext): string; }; export type Aliased = { /** More ways to call this item */ aliases: string[]; }; export type ShortDoc = { /** * One letter to support the shorthand format of `-s` * where `s` is the value provided */ short: string; }; export type LongDoc = { /** * A name to support the long format of `--long` * where `long` is the value provided */ long: string; }; export type EnvDoc = { /** * An environment variable name * * i.e. `env: 'MY_ARGUMENT'` would allow a default value coming from * the `MY_ARGUMENT` environment variable. */ env: string; }; ================================================ FILE: src/index.ts ================================================ /** * The index module: the entrance to the world of cmd-ts 😎 * * @packageDocumentation */ export { subcommands } from "./subcommands"; export { type Type, extendType } from "./type"; export * from "./types"; export { binary } from "./binary"; export { command } from "./command"; export { flag } from "./flag"; export { setDefaultHelpFormatter, resetHelpFormatter, defaultHelpFormatter, type HelpFormatter, type CommandHelpData, type SubcommandsHelpData, type Example, } from "./helpFormatter"; export { option } from "./option"; export { positional } from "./positional"; export { dryRun, runSafely, run, parse, type Runner } from "./runner"; export { restPositionals } from "./restPositionals"; export { multiflag } from "./multiflag"; export { multioption } from "./multioption"; export { union } from "./union"; export { oneOf } from "./oneOf"; export { rest } from "./rest"; ================================================ FILE: src/multiflag.ts ================================================ import * as Result from "./Result"; import type { ArgParser, ParseContext, ParsingError, ParsingResult, } from "./argparser"; import { boolean } from "./flag"; import type { From, OutputOf } from "./from"; import type { Descriptive, LongDoc, ProvidesHelp, ShortDoc } from "./helpdoc"; import { findOption } from "./newparser/findOption"; import type { HasType } from "./type"; type MultiFlagConfig> = HasType & LongDoc & Partial; /** * Like `option`, but can accept multiple options, and expects a decoder from a list of strings. * An error will highlight all option occurences. */ export function multiflag>( config: MultiFlagConfig, ): ArgParser> & ProvidesHelp { return { helpTopics() { let usage = `--${config.long}`; if (config.short) { usage += `, -${config.short}`; } return [ { category: "flags", usage, defaults: [], description: config.description ?? "self explanatory", }, ]; }, register(opts) { opts.forceFlagLongNames.add(config.long); if (config.short) { opts.forceFlagShortNames.add(config.short); } }, async parse({ nodes, visitedNodes, }: ParseContext): Promise>> { const options = findOption(nodes, { longNames: [config.long], shortNames: config.short ? [config.short] : [], }).filter((x) => !visitedNodes.has(x)); for (const option of options) { visitedNodes.add(option); } const optionValues: boolean[] = []; const errors: ParsingError[] = []; for (const option of options) { const decoded = await Result.safeAsync( boolean.from(option.value?.node.raw ?? "true"), ); if (Result.isErr(decoded)) { errors.push({ nodes: [option], message: decoded.error.message }); } else { optionValues.push(decoded.value); } } if (errors.length > 0) { return Result.err({ errors, }); } const multiDecoded = await Result.safeAsync( config.type.from(optionValues), ); if (Result.isErr(multiDecoded)) { return Result.err({ errors: [ { nodes: options, message: multiDecoded.error.message, }, ], }); } return multiDecoded; }, }; } ================================================ FILE: src/multioption.ts ================================================ import chalk from "chalk"; import * as Result from "./Result"; import type { ArgParser, ParseContext, ParsingError, ParsingResult, Register, } from "./argparser"; import type { Default, OnMissing } from "./default"; import type { OutputOf } from "./from"; import type { Descriptive, LongDoc, ProvidesHelp, ShortDoc } from "./helpdoc"; import { findOption } from "./newparser/findOption"; import type { AstNode } from "./newparser/parser"; import type { HasType, Type } from "./type"; type MultiOptionConfig> = HasType & LongDoc & Partial< ShortDoc & Descriptive & Default> & OnMissing> >; /** * Like `option`, but can accept multiple options, and expects a decoder from a list of strings. * An error will highlight all option occurences. */ export function multioption>( config: MultiOptionConfig, ): ArgParser> & ProvidesHelp & Register { return { helpTopics() { const displayName = config.type.displayName ?? "value"; let usage = `--${config.long} <${displayName}>`; if (config.short) { usage += `, -${config.short}=<${displayName}>`; } const defaults: string[] = []; if (config.defaultValue) { try { const defaultValue = config.defaultValue(); if (config.defaultValueIsSerializable) { defaults.push(`default: ${chalk.italic(defaultValue)}`); } else { defaults.push("[...optional]"); } } catch (e) {} } else if (config.type.defaultValue) { try { const defaultValue = config.type.defaultValue(); if (config.type.defaultValueIsSerializable) { defaults.push(`default: ${chalk.italic(defaultValue)}`); } else { defaults.push("[...optional]"); } } catch (e) {} } else if (config.onMissing || config.type.onMissing) { defaults.push("[...optional]"); } return [ { category: "options", usage, defaults, description: config.description ?? "self explanatory", }, ]; }, register(opts) { opts.forceOptionLongNames.add(config.long); if (config.short) { opts.forceOptionShortNames.add(config.short); } }, async parse({ nodes, visitedNodes, }: ParseContext): Promise>> { const options = findOption(nodes, { longNames: [config.long], shortNames: config.short ? [config.short] : [], }).filter((x) => !visitedNodes.has(x)); const defaultValueFn = config.defaultValue || config.type.defaultValue; const onMissingFn = config.onMissing || config.type.onMissing; if (options.length === 0 && defaultValueFn) { try { const defaultValue = defaultValueFn(); return Result.ok(defaultValue); } catch (e: any) { const message = `Failed to resolve default value for '--${config.long}': ${e.message}`; return Result.err({ errors: [ { nodes: [], message, }, ], }); } } else if (options.length === 0 && onMissingFn) { try { const missingValue = await onMissingFn(); return Result.ok(missingValue); } catch (e: any) { const message = `Failed to get missing value for '--${config.long}': ${e.message}`; return Result.err({ errors: [ { nodes: [], message, }, ], }); } } for (const option of options) { visitedNodes.add(option); } const optionValues: string[] = []; const errors: ParsingError[] = []; const flagNodes: AstNode[] = []; for (const option of options) { const providedValue = option.value?.node.raw; if (providedValue === undefined) { flagNodes.push(option); continue; } optionValues.push(providedValue); } if (flagNodes.length > 0) { errors.push({ nodes: flagNodes, message: "Expected to get a value, found a flag", }); } if (errors.length > 0) { return Result.err({ errors }); } const multiDecoded = await Result.safeAsync( config.type.from(optionValues), ); if (Result.isErr(multiDecoded)) { return Result.err({ errors: [{ nodes: options, message: multiDecoded.error.message }], }); } return multiDecoded; }, }; } ================================================ FILE: src/newparser/findOption.ts ================================================ import type { AstNode, LongOption, ShortOption } from "./parser"; type Option = LongOption | ShortOption; /** * A utility function to find an option in the AST * * @param nodes AST node list * @param opts Long and short names to look up */ export function findOption( nodes: AstNode[], opts: { longNames: string[]; shortNames: string[]; }, ): Option[] { const result: Option[] = []; for (const node of nodes) { if (node.type === "longOption" && opts.longNames.includes(node.key)) { result.push(node); continue; } if (node.type === "shortOptions" && opts.shortNames.length) { for (const option of node.options) { if (opts.shortNames.includes(option.key)) { result.push(option); } } } } return result; } ================================================ FILE: src/newparser/parser.ts ================================================ import createDebugger from "debug"; import type { RegisterOptions } from "../argparser"; import type { Token } from "./tokenizer"; const debug = createDebugger("cmd-ts:parser"); export type AstNode = | Value | LongOption | ShortOption | ShortOptions | PositionalArgument | ForcePositional; type BaseAstNode = { type: Type; index: number; raw: string; }; export interface LongOption extends BaseAstNode<"longOption"> { key: string; value?: OptionValue; } interface Delimiter extends BaseAstNode<"delimiter"> {} interface Value extends BaseAstNode<"value"> {} interface OptionValue extends BaseAstNode<"optionValue"> { delimiter: Delimiter; node: Value; } export interface ShortOptions extends BaseAstNode<"shortOptions"> { options: ShortOption[]; } export interface ShortOption extends BaseAstNode<"shortOption"> { key: string; value?: OptionValue; } export interface PositionalArgument extends BaseAstNode<"positionalArgument"> {} interface ForcePositional extends BaseAstNode<"forcePositional"> { type: "forcePositional"; } /** * Create an AST from a token list * * @param tokens A token list, coming from `tokenizer.ts` * @param forceFlag Keys to force as flag. {@see ForceFlag} to read more about it. */ export function parse(tokens: Token[], forceFlag: RegisterOptions): AstNode[] { if (debug.enabled) { const registered = { shortFlags: [...forceFlag.forceFlagShortNames], longFlags: [...forceFlag.forceFlagLongNames], shortOptions: [...forceFlag.forceOptionShortNames], longOptions: [...forceFlag.forceOptionLongNames], }; debug("Registered:", JSON.stringify(registered)); } const nodes: AstNode[] = []; let index = 0; let forcedPositional = false; function getToken(): Token | undefined { return tokens[index++]; } function peekToken(): Token | undefined { return tokens[index]; } while (index < tokens.length) { const currentToken = getToken(); if (!currentToken) break; if (currentToken.type === "argumentDivider") { continue; } if (forcedPositional) { let str = currentToken.raw; let nextToken = getToken(); while (nextToken && nextToken?.type !== "argumentDivider") { str += nextToken.raw; nextToken = getToken(); } nodes.push({ type: "positionalArgument", index: currentToken.index, raw: str, }); continue; } if (currentToken.type === "char") { let str = currentToken.raw; let nextToken = getToken(); while (nextToken && nextToken?.type !== "argumentDivider") { str += nextToken.raw; nextToken = getToken(); } nodes.push({ type: "positionalArgument", index: currentToken.index, raw: str, }); continue; } if (currentToken.type === "longPrefix") { let nextToken = getToken(); if (nextToken?.type === "argumentDivider" || !nextToken) { nodes.push({ type: "forcePositional", index: currentToken.index, raw: "--", }); forcedPositional = true; continue; } let key = ""; while ( nextToken && nextToken?.raw !== "=" && nextToken?.type !== "argumentDivider" ) { key += nextToken.raw; nextToken = getToken(); } const parsedValue = parseOptionValue({ key, delimiterToken: nextToken, forceFlag: forceFlag.forceFlagLongNames, getToken, peekToken, forceOption: forceFlag.forceOptionLongNames, }); let raw = `--${key}`; if (parsedValue) { raw += parsedValue.raw; } nodes.push({ type: "longOption", key, index: currentToken.index, raw, value: parsedValue, }); continue; } if (currentToken.type === "shortPrefix") { const keys: Token[] = []; let nextToken = getToken(); if (nextToken?.type === "argumentDivider" || !nextToken) { nodes.push({ type: "positionalArgument", index: currentToken.index, raw: "-", }); continue; } while ( nextToken && nextToken?.type !== "argumentDivider" && nextToken?.raw !== "=" ) { keys.push(nextToken); nextToken = getToken(); } // biome-ignore lint/style/noNonNullAssertion: migration const lastKey = keys.pop()!; const parsedValue = parseOptionValue({ key: lastKey.raw, delimiterToken: nextToken, forceFlag: forceFlag.forceFlagShortNames, forceOption: forceFlag.forceOptionShortNames, getToken, peekToken, }); const options: ShortOption[] = []; for (const key of keys) { options.push({ type: "shortOption", index: key.index, raw: key.raw, key: key.raw, }); } let lastKeyRaw = lastKey.raw; if (parsedValue) { lastKeyRaw += parsedValue.raw; } options.push({ type: "shortOption", index: lastKey.index, raw: lastKeyRaw, value: parsedValue, key: lastKey.raw, }); let optionsRaw = `-${keys.map((x) => x.raw).join("")}${lastKey.raw}`; if (parsedValue) { optionsRaw += parsedValue.raw; } const shortOptions: ShortOptions = { type: "shortOptions", index: currentToken.index, raw: optionsRaw, options, }; nodes.push(shortOptions); continue; } index++; } if (debug.enabled) { const objectNodes = nodes.map((node) => ({ [node.type]: node.raw })); debug("Parsed items:", JSON.stringify(objectNodes)); } return nodes; } function parseOptionValue(opts: { delimiterToken?: Token; getToken(): Token | undefined; peekToken(): Token | undefined; key: string; forceFlag: Set; forceOption: Set; }): OptionValue | undefined { const { getToken, delimiterToken, forceFlag, key, forceOption } = opts; const shouldReadKeyAsOption = forceOption.has(key); const shouldReadKeyAsFlag = !shouldReadKeyAsOption && (forceFlag.has(key) || opts.peekToken()?.type !== "char"); if (!delimiterToken || (delimiterToken.raw !== "=" && shouldReadKeyAsFlag)) { return; } const delimiter = delimiterToken.raw === "=" ? "=" : " "; const delimiterIndex = delimiterToken.index; let nextToken = getToken(); if (!nextToken) { return; } let value = ""; const valueIndex = nextToken.index; while (nextToken && nextToken?.type !== "argumentDivider") { value += nextToken.raw; nextToken = getToken(); } return { type: "optionValue", index: delimiterToken.index, delimiter: { type: "delimiter", raw: delimiter, index: delimiterIndex }, node: { type: "value", raw: value, index: valueIndex }, raw: `${delimiter}${value}`, }; } ================================================ FILE: src/newparser/tokenizer.ts ================================================ import { enumerate } from "../utils"; export type Token = | { index: number; type: "argumentDivider"; raw: " " } | { index: number; type: "shortPrefix"; raw: "-"; } | { index: number; type: "longPrefix"; raw: "--"; } | { index: number; type: "char"; raw: string; }; /** * Tokenize a list of arguments * * @param strings arguments, based on `process.argv` */ export function tokenize(strings: string[]): Token[] { const tokens: Token[] = []; let overallIndex = 0; const push = (token: Token) => { tokens.push(token); overallIndex += token.raw.length; }; for (const [stringIndex, string] of enumerate(strings)) { const chars = [...string]; for (let i = 0; i < chars.length; i++) { if (chars[i] === "-" && chars[i + 1] === "-") { push({ type: "longPrefix", raw: "--", index: overallIndex }); i++; } else if (chars[i] === "-") { push({ type: "shortPrefix", raw: "-", index: overallIndex }); } else { push({ type: "char", raw: chars[i], index: overallIndex }); } } if (stringIndex + 1 !== strings.length) { push({ type: "argumentDivider", raw: " ", index: overallIndex }); } } return tokens; } ================================================ FILE: src/oneOf.ts ================================================ import { inspect } from "node:util"; import type { Type } from "./type"; /** * A union of literals. When you want to take an exact enum value. */ export function oneOf( literals: readonly T[], ): Type { const examples = literals.map((x) => inspect(x)).join(", "); return { async from(str) { const value = literals.find((x) => x === str); if (!value) { throw new Error(`Invalid value '${str}'. Expected one of: ${examples}`); } return value; }, description: `One of ${examples}`, }; } ================================================ FILE: src/option.ts ================================================ import chalk from "chalk"; import * as Result from "./Result"; import type { ArgParser, ParseContext, ParsingError, ParsingResult, } from "./argparser"; import type { Default, OnMissing } from "./default"; import type { OutputOf } from "./from"; import type { Descriptive, EnvDoc, LongDoc, ProvidesHelp, ShortDoc, } from "./helpdoc"; import { findOption } from "./newparser/findOption"; import type { HasType, Type } from "./type"; import { string } from "./types"; import type { AllOrNothing } from "./utils"; type OptionConfig> = LongDoc & HasType & Partial>> & AllOrNothing>>; function fullOption>( config: OptionConfig, ): ArgParser> & ProvidesHelp & Partial { return { description: config.description ?? config.type.description, helpTopics() { const displayName = config.type.displayName ?? "value"; let usage = `--${config.long}`; if (config.short) { usage += `, -${config.short}`; } usage += ` <${displayName}>`; const defaults: string[] = []; if (config.env) { const env = process.env[config.env] === undefined ? "" : `=${chalk.italic(process.env[config.env])}`; defaults.push(`env: ${config.env}${env}`); } if (config.defaultValue) { try { const defaultValue = config.defaultValue(); if (config.defaultValueIsSerializable) { defaults.push(`default: ${chalk.italic(defaultValue)}`); } else { defaults.push("optional"); } } catch (e) {} } else if (config.type.defaultValue) { try { const defaultValue = config.type.defaultValue(); if (config.type.defaultValueIsSerializable) { defaults.push(`default: ${chalk.italic(defaultValue)}`); } else { defaults.push("optional"); } } catch (e) {} } else if (config.onMissing || config.type.onMissing) { defaults.push("optional"); } return [ { category: "options", usage, defaults, description: config.description ?? config.type.description ?? "self explanatory", }, ]; }, register(opts) { opts.forceOptionLongNames.add(config.long); if (config.short) { opts.forceOptionShortNames.add(config.short); } }, async parse({ nodes, visitedNodes, }: ParseContext): Promise>> { const options = findOption(nodes, { longNames: [config.long], shortNames: config.short ? [config.short] : [], }).filter((x) => !visitedNodes.has(x)); options.forEach((opt) => visitedNodes.add(opt)); if (options.length > 1) { const error: ParsingError = { message: `Too many times provided. Expected 1, got: ${options.length}`, nodes: options, }; return Result.err({ errors: [error] }); } const valueFromEnv = config.env ? process.env[config.env] : undefined; const defaultValueFn = config.defaultValue || config.type.defaultValue; const onMissingFn = config.onMissing || config.type.onMissing; const option = options[0]; let rawValue: string; let envPrefix = ""; if (option?.value) { rawValue = option.value.node.raw; } else if (valueFromEnv !== undefined) { rawValue = valueFromEnv; envPrefix = `env[${chalk.italic(config.env)}]: `; } else if (defaultValueFn) { try { const defaultValue = defaultValueFn(); return Result.ok(defaultValue); } catch (e: any) { const message = `Default value not found for '--${config.long}': ${e.message}`; return Result.err({ errors: [ { nodes: [], message, }, ], }); } } else if (onMissingFn) { try { const missingValue = await onMissingFn(); return Result.ok(missingValue); } catch (e: any) { const message = `Failed to get missing value for '--${config.long}': ${e.message}`; return Result.err({ errors: [ { nodes: [], message, }, ], }); } } else { // If we reach here, no default or prompt value was available const raw = option?.type === "shortOption" ? `-${option?.key}` : `--${config.long}`; return Result.err({ errors: [ { nodes: options, message: `No value provided for ${raw}`, }, ], }); } const decoded = await Result.safeAsync(config.type.from(rawValue)); if (Result.isErr(decoded)) { return Result.err({ errors: [ { nodes: options, message: envPrefix + decoded.error.message }, ], }); } return Result.ok(decoded.value); }, }; } type StringType = Type; /** * Decodes an argument which is in the form of a key and a value, and allows parsing the following ways: * * - `--long=value` where `long` is the provided `long` * - `--long value` where `long` is the provided `long` * - `-s=value` where `s` is the provided `short` * - `-s value` where `s` is the provided `short` * @param config flag configurations */ export function option>( config: LongDoc & HasType & Partial>> & AllOrNothing>>, ): ArgParser> & ProvidesHelp & Partial; export function option( config: LongDoc & Partial< HasType & Descriptive & EnvDoc & ShortDoc & OnMissing> > & AllOrNothing>>, ): ArgParser> & ProvidesHelp & Partial; export function option( config: LongDoc & Partial> & Partial, ): ArgParser> & ProvidesHelp & Partial { return fullOption({ type: string, ...config, }); } ================================================ FILE: src/positional.ts ================================================ import chalk from "chalk"; import * as Result from "./Result"; import type { ArgParser, ParseContext, ParsingResult } from "./argparser"; import type { Default } from "./default"; import type { OutputOf } from "./from"; import type { Descriptive, Displayed, ProvidesHelp } from "./helpdoc"; import type { PositionalArgument } from "./newparser/parser"; import type { HasType, Type } from "./type"; import { string } from "./types"; import type { AllOrNothing } from "./utils"; type PositionalConfig> = HasType & Partial & AllOrNothing>>; type PositionalParser> = ArgParser< OutputOf > & ProvidesHelp & Partial; function fullPositional>( config: PositionalConfig, ): PositionalParser { const displayName = config.displayName ?? config.type.displayName ?? "arg"; return { description: config.description ?? config.type.description, helpTopics() { const defaults: string[] = []; const defaultValueFn = config.defaultValue ?? config.type.defaultValue; if (defaultValueFn) { try { const defaultValue = defaultValueFn(); if ( config.defaultValueIsSerializable ?? config.type.defaultValueIsSerializable ) { defaults.push(`default: ${chalk.italic(defaultValue)}`); } else { defaults.push("optional"); } } catch (e) {} } const usage = defaults.length > 0 ? `[${displayName}]` : `<${displayName}>`; return [ { category: "arguments", usage, description: config.description ?? config.type.description ?? "self explanatory", defaults, }, ]; }, register(_opts) {}, async parse({ nodes, visitedNodes, }: ParseContext): Promise>> { const positionals = nodes.filter( (node): node is PositionalArgument => node.type === "positionalArgument" && !visitedNodes.has(node), ); const defaultValueFn = config.defaultValue ?? config.type.defaultValue; const positional = positionals[0]; if (!positional) { if (defaultValueFn) { return Result.ok(defaultValueFn()); } return Result.err({ errors: [ { nodes: [], message: `No value provided for ${displayName}`, }, ], }); } visitedNodes.add(positional); const decoded = await Result.safeAsync(config.type.from(positional.raw)); if (Result.isErr(decoded)) { return Result.err({ errors: [ { nodes: [positional], message: decoded.error.message, }, ], }); } return Result.ok(decoded.value); }, }; } type StringType = Type; /** * A positional command line argument. * * Decodes one argument that is not a flag or an option: * In `hello --key value world` we have 2 positional arguments — `hello` and `world`. * * @param config positional argument config */ export function positional>( config: HasType & Partial, ): PositionalParser; export function positional( config?: Partial & Displayed & Descriptive>, ): PositionalParser; export function positional( config?: Partial> & Partial, ): PositionalParser { return fullPositional({ type: string, ...config, }); } ================================================ FILE: src/rest.ts ================================================ import * as Result from "./Result"; import type { ArgParser } from "./argparser"; import type { Descriptive, Displayed, ProvidesHelp } from "./helpdoc"; import type { AstNode } from "./newparser/parser"; export function rest( config?: Partial, ): ArgParser & ProvidesHelp { return { helpTopics() { const displayName = config?.displayName ?? "arg"; return [ { usage: `[...${displayName}]`, category: "arguments", defaults: [], description: config?.description ?? "catches the rest of the values", }, ]; }, register() {}, async parse(context) { const visitedNodeIndices = [...context.visitedNodes] .map((x) => context.nodes.indexOf(x)) .filter((x) => x > -1); const strings: string[] = []; const maxIndex = Math.max(-1, ...visitedNodeIndices); const restItems = context.nodes.slice(maxIndex + 1); for (const node of restItems) { switch (node.type) { case "positionalArgument": { strings.push(node.raw); context.visitedNodes.add(node); break; } case "longOption": { strings.push(...getOriginal(node)); context.visitedNodes.add(node); break; } case "shortOption": { strings.push(...getOriginal(node)); context.visitedNodes.add(node); break; } case "forcePositional": { strings.push(node.raw); context.visitedNodes.add(node); break; } case "shortOptions": { const last = node.options.at(-1); context.visitedNodes.add(node); strings.push(...getOriginal({ ...node, value: last?.value })); break; } } } return Result.ok(strings); }, }; } function getOriginal(node: { index: number; raw: string; value?: Extract["value"]; }): string[] { if (!node.value) { return [node.raw]; } if (node.value.delimiter.raw === " ") { return [ node.raw.slice(0, node.value.index - node.index), node.value.node.raw, ]; } return [node.raw]; } ================================================ FILE: src/restPositionals.ts ================================================ import * as Result from "./Result"; import type { ArgParser, ParseContext, ParsingError, ParsingResult, } from "./argparser"; import type { OutputOf } from "./from"; import type { Descriptive, Displayed, ProvidesHelp } from "./helpdoc"; import type { PositionalArgument } from "./newparser/parser"; import type { HasType, Type } from "./type"; import { string } from "./types"; type RestPositionalsConfig> = HasType & Partial; /** * Read all the positionals and decode them using the type provided. * Works best when it is the last item on the `command` construct, to be * used like the `...rest` operator in JS and TypeScript. */ function fullRestPositionals>( config: RestPositionalsConfig, ): ArgParser[]> & ProvidesHelp { return { helpTopics() { const displayName = config.displayName ?? config.type.displayName ?? "arg"; return [ { usage: `[...${displayName}]`, category: "arguments", defaults: [], description: config.description ?? config.type.description ?? "", }, ]; }, register(_opts) {}, async parse({ nodes, visitedNodes, }: ParseContext): Promise[]>> { const positionals = nodes.filter( (node): node is PositionalArgument => node.type === "positionalArgument" && !visitedNodes.has(node), ); const results: OutputOf[] = []; const errors: ParsingError[] = []; for (const positional of positionals) { visitedNodes.add(positional); const decoded = await Result.safeAsync( config.type.from(positional.raw), ); if (Result.isOk(decoded)) { results.push(decoded.value); } else { errors.push({ nodes: [positional], message: decoded.error.message, }); } } if (errors.length > 0) { return Result.err({ errors, }); } return Result.ok(results); }, }; } type StringType = Type; type RestPositionalsParser> = ArgParser< OutputOf[] > & ProvidesHelp; /** * Read all the positionals and decode them using the type provided. * Works best when it is the last item on the `command` construct, to be * used like the `...rest` operator in JS and TypeScript. * * @param config rest positionals argument config */ export function restPositionals>( config: HasType & Partial, ): RestPositionalsParser; export function restPositionals( config?: Partial & Displayed & Descriptive>, ): RestPositionalsParser; export function restPositionals( config?: Partial> & Partial, ): RestPositionalsParser { return fullRestPositionals({ type: string, ...config, }); } ================================================ FILE: src/runner.ts ================================================ import { type Result, err, isErr, ok } from "./Result"; import type { ArgParser, ParseContext, ParsingResult, Register, } from "./argparser"; import { Exit } from "./effects"; import { errorBox } from "./errorBox"; import type { PrintHelp, Versioned } from "./helpdoc"; import { type AstNode, parse as doParse } from "./newparser/parser"; import { tokenize } from "./newparser/tokenizer"; export type Handling = { handler: (values: Values) => Result }; export type Runner = PrintHelp & Partial & Register & Handling & ArgParser & { run(context: ParseContext): Promise>; }; export type Into> = R extends Runner ? X : never; export async function run>( ap: R, strings: string[], ): Promise> { const result = await runSafely(ap, strings); if (isErr(result)) { return result.error.run(); } return result.value; } /** * Runs a command but does not apply any effect */ export async function runSafely>( ap: R, strings: string[], ): Promise>> { const hotPath: string[] = []; const nodes = parseCommon(ap, strings); try { const result = await ap.run({ nodes, visitedNodes: new Set(), hotPath }); if (isErr(result)) { throw new Exit({ message: errorBox(nodes, result.error.errors, hotPath), exitCode: 1, into: "stderr", }); } return ok(result.value); } catch (e) { if (e instanceof Exit) { return err(e); } throw e; } } /** * Run a command but don't quit. Returns an `Result` instead. */ export async function dryRun>( ap: R, strings: string[], ): Promise>> { const result = await runSafely(ap, strings); if (isErr(result)) { return err(result.error.dryRun()); } return result; } /** * Parse the command as if to run it, but only return the parse result and don't run the command. */ export function parse>( ap: R, strings: string[], ): Promise> { const hotPath: string[] = []; const nodes = parseCommon(ap, strings); return ap.parse({ nodes, visitedNodes: new Set(), hotPath }); } function parseCommon>( ap: R, strings: string[], ): AstNode[] { const longFlagKeys = new Set(); const shortFlagKeys = new Set(); const longOptionKeys = new Set(); const shortOptionKeys = new Set(); const registerContext = { forceFlagShortNames: shortFlagKeys, forceFlagLongNames: longFlagKeys, forceOptionShortNames: shortOptionKeys, forceOptionLongNames: longOptionKeys, }; ap.register(registerContext); const tokens = tokenize(strings); return doParse(tokens, registerContext); } ================================================ FILE: src/subcommands.ts ================================================ import chalk from "chalk"; import didYouMean from "didyoumean"; import * as Result from "./Result"; // import { Runner, Into } from './runner'; import type { ArgParser, ParseContext, ParsingInto, ParsingResult, } from "./argparser"; import { createCircuitBreaker, handleCircuitBreaker } from "./circuitbreaker"; import type { From } from "./from"; import { type Example, type SubcommandsHelpData, getHelpFormatter, } from "./helpFormatter"; import type { Aliased, Descriptive, Named, ProvidesHelp, Versioned, } from "./helpdoc"; import { positional } from "./positional"; import type { Runner } from "./runner"; type Output< Commands extends Record & Runner>, > = { [key in keyof Commands]: { command: key; args: ParsingInto }; }[keyof Commands]; type RunnerOutput< Commands extends Record & ArgParser>, > = { [key in keyof Commands]: { command: key; value: Commands[key] extends Runner ? X : never; }; }[keyof Commands]; /** * Combine multiple `command`s into one */ export function subcommands< Commands extends Record< string, ArgParser & Runner & Partial >, >(config: { name: string; version?: string; cmds: Commands; description?: string; /** Examples to show in help output */ examples?: Example[]; }): ArgParser> & Named & Partial & Runner, RunnerOutput> { const circuitbreaker = createCircuitBreaker(!!config.version); const type: From = { async from(str) { const commands = Object.entries(config.cmds).map(([name, cmd]) => { return { cmdName: name as keyof Commands, names: [name, ...(cmd.aliases ?? [])], }; }); const cmd = commands.find((x) => x.names.includes(str)); if (cmd) { return cmd.cmdName; } let errorMessage = "Not a valid subcommand name"; const closeOptions = didYouMean( str, flatMap(commands, (x) => x.names), ); if (closeOptions) { const option = Array.isArray(closeOptions) ? closeOptions[0] : closeOptions; errorMessage += `\nDid you mean ${chalk.italic(option)}?`; } throw new Error(errorMessage); }, }; const subcommand = positional({ displayName: "subcommand", description: `one of ${Object.keys(config.cmds).join(", ")}`, type, }); function normalizeContext(context: ParseContext) { if (context.hotPath?.length === 0) { context.hotPath.push(config.name); } // Called without any arguments? We default to subcommand help. if (!context.nodes.some((n) => !context.visitedNodes.has(n))) { context.nodes.push({ type: "longOption", index: 0, key: "help", raw: "--help", }); } } return { version: config.version, description: config.description, name: config.name, handler: (value) => { const cmd = config.cmds[value.command]; return cmd.handler(value.args); }, register(opts) { for (const cmd of Object.values(config.cmds)) { cmd.register(opts); } circuitbreaker.register(opts); }, printHelp(context) { const data: SubcommandsHelpData = { name: config.name, path: context.hotPath ?? [config.name], version: config.version, description: config.description, commands: Object.entries(config.cmds).map(([name, cmd]) => ({ name, description: cmd.description, aliases: cmd.aliases, helpTopics: cmd.helpTopics?.() ?? [], })), examples: config.examples, }; return getHelpFormatter().formatSubcommands(data, context); }, async parse( context: ParseContext, ): Promise>> { normalizeContext(context); const parsed = await subcommand.parse(context); if (Result.isErr(parsed)) { return Result.err({ errors: parsed.error.errors, partialValue: {}, }); } context.hotPath?.push(parsed.value as string); const cmd = config.cmds[parsed.value]; const parsedCommand = await cmd.parse(context); if (Result.isErr(parsedCommand)) { return Result.err({ errors: parsedCommand.error.errors, partialValue: { command: parsed.value, args: parsedCommand.error.partialValue, }, }); } return Result.ok({ args: parsedCommand.value, command: parsed.value, }); }, async run(context): Promise>> { normalizeContext(context); const parsedSubcommand = await subcommand.parse(context); if (Result.isErr(parsedSubcommand)) { const breaker = await circuitbreaker.parse(context); handleCircuitBreaker(context, this, breaker); return Result.err({ ...parsedSubcommand.error, partialValue: {} }); } context.hotPath?.push(parsedSubcommand.value as string); const cmd = config.cmds[parsedSubcommand.value]; const commandRun = await cmd.run(context); if (Result.isOk(commandRun)) { return Result.ok({ command: parsedSubcommand.value, value: commandRun.value, }); } return Result.err({ ...commandRun.error, partialValue: { command: parsedSubcommand.value, value: commandRun.error.partialValue, }, }); }, }; } function flatMap(array: T[], f: (t: T) => R[]): R[] { const rs: R[] = []; for (const item of array) { rs.push(...f(item)); } return rs; } ================================================ FILE: src/type.ts ================================================ import type { Default, OnMissing } from "./default"; import type { From, FromFn, InputOf, OutputOf } from "./from"; import type { Descriptive, Displayed } from "./helpdoc"; export { identity, type OutputOf, type InputOf } from "./from"; export type Type = From & Partial & OnMissing>; /** * Get the type definitions or an empty object from a type or a decoding function */ export function typeDef | FromFn>( from: T, ): T extends FromFn ? {} : Omit { if (typeof from === "function") { return {} as any; } return from as any; } /** * Get the decoding function from a type or a function */ export function fromFn(t: FromFn | From): FromFn { if (typeof t === "function") { return t; } return t.from; } /** * Extend a type: take a type and use it as a base for another type. Much like using the spread operator: * ``` * const newType = { ...oldType } * ``` * but composes the `from` arguments * * @param base A base type from `InputA` to `OutputA` * @param nextTypeOrDecodingFunction Either an entire `Type` or just a decoding function from `OutputA` to any type */ export function extendType< BaseType extends Type, NextType extends | Type, any> | FromFn, any>, >( base: BaseType, nextTypeOrDecodingFunction: NextType, ): Omit & (NextType extends FromFn ? unknown : Omit) & From, OutputOf> { const { defaultValue: _defaultValue, onMissing: _onMissing, from: _from, ...t1WithoutDefault } = base; const t2Object = typeDef(nextTypeOrDecodingFunction); const t2From = fromFn(nextTypeOrDecodingFunction); return { ...t1WithoutDefault, ...t2Object, async from(a) { const f1Result = await base.from(a); return await t2From(f1Result); }, }; } /** Contains a type definition inside */ export type HasType> = { /** The value decoding strategy for this item */ type: T; }; ================================================ FILE: src/types.ts ================================================ import { type InputOf, type OutputOf, type Type, identity } from "./type"; /** * A number type to be used with `option` * * Throws an error when the provided string is not a number */ export const number: Type = { async from(str) { const decoded = Number.parseFloat(str); if (Number.isNaN(decoded)) { throw new Error("Not a number"); } return decoded; }, displayName: "number", description: "a number", }; /** * A string type to be used with `option`. */ export const string: Type = { ...identity(), description: "a string", displayName: "str", }; /** * A boolean type to be used with `flag`. */ export const boolean: Type = { ...identity(), description: "a boolean", displayName: "true/false", defaultValue() { return false; }, }; /** * Makes any type optional, by defaulting to `undefined`. */ export function optional>( t: T, ): Type, OutputOf | undefined> { return { ...t, defaultValue(): OutputOf | undefined { return undefined; }, }; } /** * Transforms any type into an array, useful for `multioption` and `multiflag`. */ export function array>( t: T, ): Type[], OutputOf[]> { return { ...t, async from(inputs: InputOf[]): Promise[]> { return Promise.all(inputs.map((input) => t.from(input))); }, }; } ================================================ FILE: src/union.ts ================================================ import * as Result from "./Result"; import type { From, FromFn, InputOf, OutputOf } from "./from"; import { type Type, fromFn, typeDef } from "./type"; type Any = FromFn | From; /** * Take one of the types. Merge the metadata from left to right. * If nothing matches, prints all the errors. */ export function union>>( ts: [T1, ...T2s[]], { combineErrors = (errors) => errors.join("\n"), }: { /** * Combine all the errors produced by the types. * Defaults to joining them with a newline. */ combineErrors?(errors: string[]): string; } = {}, ): Type, OutputOf> { const merged = Object.assign({}, ...ts.map((x) => typeDef(x))); return { ...merged, async from(input) { const errors: string[] = []; for (const t of ts) { const decoded = await Result.safeAsync(fromFn(t)(input)); if (Result.isOk(decoded)) { return decoded.value; } errors.push(decoded.error.message); } throw new Error(combineErrors(errors)); }, }; } ================================================ FILE: src/utils.ts ================================================ import stripAnsi from "strip-ansi"; /** * @ignore */ export function padNoAnsi( str: string, length: number, place: "end" | "start", ): string { const noAnsiStr = stripAnsi(str); if (length < noAnsiStr.length) return str; const pad = Array(length - noAnsiStr.length + 1).join(" "); if (place === "end") { return str + pad; } return pad + str; } /** * Group an array by a function that returns the key * * @ignore */ export function groupBy( objs: A[], f: (a: A) => B, ): Record { const result = {} as Record; for (const obj of objs) { const key = f(obj); result[key] = result[key] ?? []; result[key].push(obj); } return result; } /** * A better typed version of `Object.entries` * * @ignore */ export function entries>( obj: Obj, ): { [key in keyof Obj]: [key, Obj[key]] }[keyof Obj][] { return Object.entries(obj); } /** * Enumerate over a list, to get a pair of [index, value] * * @ignore */ export function* enumerate(arr: T[]): Generator<[number, T]> { for (let i = 0; i < arr.length; i++) { yield [i, arr[i]]; } } /** * Array#flatMap polyfill * * @ignore */ export function flatMap(xs: A[], fn: (a: A) => B[]): B[] { const results: B[] = []; for (const x of xs) { results.push(...fn(x)); } return results; } /** * Flatten an array * * @ignore */ export function flatten(xs: A[][]): A[] { const results: A[] = []; for (const x of xs) { results.push(...x); } return results; } /** * Either the provided `T` or an empty object */ export type AllOrNothing = T | { [key in keyof T]?: never }; ================================================ FILE: test/__snapshots__/ui.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`allows positional arguments > expects the correct order 1`] = ` "error: found 1 error sub2 hello  ^ Not a number hint: for more information, try 'subcmds sub2 --help'" `; exports[`allows positional arguments > help shows them 1`] = ` "subcmds sub2 ARGUMENTS: [number] - a number [optional] [str] - a string [default: anonymous] FLAGS: --help, -h - show help [optional]" `; exports[`asynchronous type conversion works for failures 1`] = ` "error: found 1 error subcmds composed cat https://mock.httpstatus.io/404  ^ Got status Not Found 404 reading URL hint: for more information, try 'subcmds composed cat --help'" `; exports[`asynchronous type conversion works for success 1`] = `"200 OK"`; exports[`composes errors 1`] = ` "error: found 3 errors subcmds greet --times=not-a-number not-capitalized  ^ Not a number subcmds greet --times=not-a-number not-capitalized  ^ name must be capitalized Along with the following error: 3. No value provided for --greeting hint: for more information, try 'subcmds greet --help'" `; exports[`displays nested subcommand help if no arguments passed 1`] = ` "subcmds composed  > a nested subcommand where  can be one of: - cat - A simple \`cat\` clone [alias: read] For more help, try running \`subcmds composed --help\`" `; exports[`displays subcommand help if no arguments passed 1`] = ` "subcmds  > An awesome subcommand app! where  can be one of: - complex - Just prints the arguments - cat - A simple \`cat\` clone [alias: read] - greet - greet a person - composed - a nested subcommand For more help, try running \`subcmds --help\` EXAMPLES: Show help for a command  $ subcmds greet --help Run the cat command  $ subcmds cat ./file.txt" `; exports[`failures in defaultValue 1`] = ` "error: found 3 errors 1. No value provided for --user 2. No value provided for --password 3. Default value not found for '--repo': Can't infer repo from git hint: for more information, try 'build --help'" `; exports[`help for complex command 1`] = ` "subcmds complex 6.6.6-alpha > Just prints the arguments ARGUMENTS: - a number - a string [...str] - a string OPTIONS: --number, -n - a number --int-or-string - a string --float-or-string - a string --optional-option - a string [optional] --no-type-option - a string --optional-with-default - a string [env: SOME_ENV_VAR] [default: Hello] FLAGS: --boolean - a boolean [optional] --bool-without-type - a boolean [optional] --help, -h - show help [optional] --version, -v - print the version [optional] EXAMPLES: Print with required args  $ complex 42 hello -n 10 --int-or-string 5 --float-or-string 3.14 Using a string for int-or-string  $ complex 1 world -n 1 --int-or-string foo --float-or-string bar" `; exports[`help for composed subcommand 1`] = ` "subcmds composed cat > A simple \`cat\` clone ARGUMENTS: - A file path or a URL to make a GET request to [...stream] - A file path or a URL to make a GET request to FLAGS: --help, -h - show help [optional]" `; exports[`help for composed subcommands 1`] = ` "subcmds composed  > a nested subcommand where  can be one of: - cat - A simple \`cat\` clone [alias: read] For more help, try running \`subcmds composed --help\`" `; exports[`help for subcommands 1`] = ` "subcmds  > An awesome subcommand app! where  can be one of: - complex - Just prints the arguments - cat - A simple \`cat\` clone [alias: read] - greet - greet a person - composed - a nested subcommand For more help, try running \`subcmds --help\` EXAMPLES: Show help for a command  $ subcmds greet --help Run the cat command  $ subcmds cat ./file.txt" `; exports[`help shows onMissing option 1`] = ` "async-test OPTIONS: --async-arg, -a - A type with onMissing callback [optional] --async-arg-2 - A type with onMissing callback [default: Hi] --async-arg-3 - a string [optional] FLAGS: --help, -h - show help [optional]" `; exports[`invalid subcommand 1`] = ` "error: found 1 error subcmds subcommand-that-doesnt-exist  ^ Not a valid subcommand name hint: for more information, try 'subcmds --help'" `; exports[`multiline error 1`] = ` "error: found 2 errors subcmds greet Bon Jovi  ^ Woah, we're half way there  Woah! living on a prayer! Along with the following error: 2. No value provided for --greeting hint: for more information, try 'subcmds greet --help'" `; exports[`onMissing failure 1`] = ` "error: found 1 error 1. Failed to get missing value for '--fail-arg': Async onMissing failed hint: for more information, try 'async-test-failure --help'" `; exports[`subcommands show their version 1`] = `"1.0.0"`; exports[`subcommands with process.argv.slice(2) 1`] = ` "subcmds  where  can be one of: - sub1 - sub2 For more help, try running \`subcmds --help\`" `; exports[`suggests a subcommand on typo 1`] = ` "error: found 1 error subcmds greek  ^ Not a valid subcommand name  Did you mean greet? hint: for more information, try 'subcmds --help'" `; exports[`too many arguments 1`] = ` "error: found 1 error subcmds --this=will-be-an-error cat package.json --and-also-this  ^ Unknown arguments hint: for more information, try 'subcmds cat --help'" `; ================================================ FILE: test/__snapshots__/vercel-formatter.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`vercelFormatter > formats command help 1`] = ` "create 1.0.0 ▲ create [options] Create a new sandbox Flags: --connect, -c Connect to the sandbox after creating [optional] --help, -h show help [optional] --version, -v print the version [optional] Arguments: Name for the sandbox Examples: – Create a sandbox named 'dev' $ create dev – Create and connect $ create dev --connect " `; exports[`vercelFormatter > formats subcommands help with version and logo 1`] = ` "Vercel Sandbox CLI 2.3.0 ▲ sandbox [options] For command help, run \`sandbox --help\` Commands: create Create a new sandbox ls | list List sandboxes for the current project run Create and run a command in a sandbox rm | stop Stop one or more running sandboxes cp | copy Copy files between local and remote Examples: – Create a sandbox and start a shell $ sandbox create --connect – Run a command in a new sandbox $ sandbox run -- node -e "console.log('hello')" " `; ================================================ FILE: test/command.test.ts ================================================ import { expect, test } from "vitest"; import * as Result from "../src/Result"; import { command } from "../src/command"; import { flag } from "../src/flag"; import { parse } from "../src/newparser/parser"; import { tokenize } from "../src/newparser/tokenizer"; import { option } from "../src/option"; import { restPositionals } from "../src/restPositionals"; import { createRegisterOptions } from "./createRegisterOptions"; import { boolean, number, string } from "./test-types"; const cmd = command({ name: "My command", args: { positionals: restPositionals({ type: string }), option: option({ type: number, long: "option" }), secondOption: option({ type: string, long: "second-option", }), flag: flag({ type: boolean, long: "flag" }), }, handler: (_) => {}, }); test("merges options, positionals and flags", async () => { const argv = "first --option=666 second --second-option works-too --flag third".split( " ", ); const tokens = tokenize(argv); const registerOptions = createRegisterOptions(); cmd.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = await cmd.parse({ nodes, visitedNodes: new Set() }); const expected: typeof result = Result.ok({ positionals: ["first", "second", "third"], option: 666, secondOption: "works-too", flag: true, }); expect(result).toEqual(expected); }); test("fails if an argument fail to parse", async () => { const argv = "first --option=hello second --second-option works-too --flag=fails-too third".split( " ", ); const tokens = tokenize(argv); const registerOptions = createRegisterOptions(); cmd.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = cmd.parse({ nodes, visitedNodes: new Set(), }); await expect(result).resolves.toEqual( Result.err({ errors: [ { nodes: nodes.filter((x) => x.raw.startsWith("--option")), message: "Not a number", }, { nodes: nodes.filter((x) => x.raw.startsWith("--flag")), message: `expected value to be either "true" or "false". got: "fails-too"`, }, ], partialValue: { positionals: ["first", "second", "third"], secondOption: "works-too", }, }), ); }); test("fails if providing unknown arguments", async () => { const cmd = command({ name: "my command", args: { positionals: restPositionals({ type: string }), }, handler: (_) => {}, }); const argv = "okay --option=failing alright --another=fail".split(" "); const tokens = tokenize(argv); const registerOptions = createRegisterOptions(); cmd.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = await cmd.parse({ nodes, visitedNodes: new Set(), }); expect(result).toEqual( Result.err({ errors: [ { message: "Unknown arguments", nodes: nodes.filter( (node) => node.raw.startsWith("--option") || node.raw.startsWith("--another"), ), }, ], partialValue: { positionals: ["okay", "alright"], }, }), ); }); test("should fail run when an async handler fails", async () => { const error = new Error("oops"); const cmd = command({ name: "my command", args: {}, handler: async (_) => { throw error; }, }); await expect( cmd.run({ nodes: [], visitedNodes: new Set(), }), ).rejects.toEqual(error); }); test("succeeds when rest is quoted", async () => { // since spliting this by space doesn't give us the expected result, I just built the array myself // const argv = `--option=666 --second-option works-too positional -- "--restPositionals --trailing-comma all {{scripts,src}/**/*.{js,ts},{scripts,src}/*.{js,ts},*.{js,ts}}"`; const tokens = tokenize([ "--option=666", "--second-option", "works-too", "positional", "--", "--restPositionals --trailing-comma all {{scripts,src}/**/*.{js,ts},{scripts,src}/*.{js,ts},*.{js,ts}}", ]); const registerOptions = createRegisterOptions(); cmd.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = cmd.parse({ nodes, visitedNodes: new Set(), }); await expect(result).resolves.toEqual( Result.ok({ positionals: [ "positional", "--restPositionals --trailing-comma all {{scripts,src}/**/*.{js,ts},{scripts,src}/*.{js,ts},*.{js,ts}}", ], option: 666, secondOption: "works-too", flag: false, }), ); }); ================================================ FILE: test/createRegisterOptions.ts ================================================ import type { RegisterOptions } from "../src/argparser"; export function createRegisterOptions(): RegisterOptions { return { forceFlagLongNames: new Set(), forceFlagShortNames: new Set(), forceOptionLongNames: new Set(), forceOptionShortNames: new Set(), }; } ================================================ FILE: test/errorBox.test.ts ================================================ import chalk from "chalk"; import { expect, test } from "vitest"; import * as Result from "../src/Result"; import { errorBox } from "../src/errorBox"; import { parse } from "../src/newparser/parser"; import { tokenize } from "../src/newparser/tokenizer"; import { option } from "../src/option"; import { createRegisterOptions } from "./createRegisterOptions"; import { number } from "./test-types"; test("works for multiple nodes", async () => { const argv = "hello world --some arg --flag --some another --flag --this-is=option -abcde=f -abcde"; const tokens = tokenize(argv.split(" ")); const tree = parse(tokens, createRegisterOptions()); const opt = option({ type: number, long: "some", }); const result = await opt.parse({ nodes: tree, visitedNodes: new Set(), }); if (Result.isOk(result)) { throw new Error("should fail..."); } const errors = errorBox(tree, result.error.errors, []); expect(errors).toMatch("Too many times provided"); }); test("works for a short flag", async () => { const argv = "hello world -fn not_a_number hey"; const tokens = tokenize(argv.split(" ")); const tree = parse(tokens, createRegisterOptions()); const opt = option({ type: number, long: "some", short: "n", }); const result = await opt.parse({ nodes: tree, visitedNodes: new Set(), }); if (Result.isOk(result)) { throw new Error("should fail..."); } const errors = errorBox(tree, result.error.errors, []); expect(errors).toMatch(chalk.red("n not_a_number")); }); test("works for a single node", async () => { const argv = "hello world --flag --some not_a_number --flag --this-is=option -abcde=f -abcde"; const tokens = tokenize(argv.split(" ")); const tree = parse(tokens, createRegisterOptions()); const opt = option({ type: number, long: "some", }); const result = await opt.parse({ nodes: tree, visitedNodes: new Set(), }); if (Result.isOk(result)) { throw new Error("should fail..."); } const errors = errorBox(tree, result.error.errors, []); expect(errors).toMatch("Not a number"); }); test("works when no nodes", async () => { const argv = "hello world --flag --flag --this-is=option -abcde=f -abcde"; const tokens = tokenize(argv.split(" ")); const tree = parse(tokens, createRegisterOptions()); const opt = option({ type: number, long: "some", }); const result = await opt.parse({ nodes: tree, visitedNodes: new Set(), }); if (Result.isOk(result)) { throw new Error("should fail..."); } const errors = errorBox(tree, result.error.errors, []); expect(errors).toMatch("No value provided for --some"); }); test("multiline non-highlight errors are indented", () => { const errors = errorBox( [], [ { nodes: [], message: "Failed to get missing value for '--token'\n" + "hint: one line.\n" + "╰▶ second line", }, ], [], ); expect(errors).toMatchInlineSnapshot( ` "error: found 1 error 1. Failed to get missing value for '--token' hint: one line. ╰▶ second line hint: for more information, try ' --help'" `, ); }); ================================================ FILE: test/flag.test.ts ================================================ import { expect, test } from "vitest"; import * as Result from "../src/Result"; import { flag } from "../src/flag"; import { parse } from "../src/newparser/parser"; import { tokenize } from "../src/newparser/tokenizer"; import { boolean } from "../src/types"; import { createRegisterOptions } from "./createRegisterOptions"; test("fails on incompatible value", async () => { const argv = "--hello=world"; const tokens = tokenize(argv.split(" ")); const argparser = flag({ type: boolean, long: "hello", description: "description", }); const registerOptions = createRegisterOptions(); argparser.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = argparser.parse({ nodes, visitedNodes: new Set(), }); await expect(result).resolves.toEqual( Result.err({ errors: [ { nodes: nodes, message: 'expected value to be either "true" or "false". got: "world"', }, ], }), ); }); test("defaults to false", async () => { const argv = ""; const tokens = tokenize(argv.split(" ")); const argparser = flag({ type: boolean, long: "hello", description: "description", }); const registerOptions = createRegisterOptions(); argparser.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = argparser.parse({ nodes, visitedNodes: new Set(), }); await expect(result).resolves.toEqual(Result.ok(false)); }); test("allows short arguments", async () => { const argv = "-abc"; const tokens = tokenize(argv.split(" ")); const argparser = flag({ type: boolean, long: "hello", short: "b", description: "description", }); const registerOptions = createRegisterOptions(); argparser.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = argparser.parse({ nodes, visitedNodes: new Set(), }); await expect(result).resolves.toEqual(Result.ok(true)); }); ================================================ FILE: test/multioption.test.ts ================================================ import { expect, test } from "vitest"; import * as Result from "../src/Result"; import { multioption } from "../src/multioption"; import { parse } from "../src/newparser/parser"; import { tokenize } from "../src/newparser/tokenizer"; import { array, string } from "../src/types"; import { createRegisterOptions } from "./createRegisterOptions"; test("applies default value when no option is provided", async () => { const argv = ""; const tokens = tokenize(argv.split(" ")); const argparser = multioption({ type: array(string), long: "hello", defaultValue: () => ["world!"], description: "description", }); const registerOptions = createRegisterOptions(); argparser.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = argparser.parse({ nodes, visitedNodes: new Set(), }); await expect(result).resolves.toEqual(Result.ok(["world!"])); }); test("does not apply default value when option is provided", async () => { const argv = "--hello=moshe"; const tokens = tokenize(argv.split(" ")); const argparser = multioption({ type: array(string), long: "hello", defaultValue: () => ["world!"], description: "description", }); const registerOptions = createRegisterOptions(); argparser.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = argparser.parse({ nodes, visitedNodes: new Set(), }); await expect(result).resolves.toEqual(Result.ok(["moshe"])); }); test("does not apply default value when options are provided", async () => { const argv = "--hello=moshe --hello=haim"; const tokens = tokenize(argv.split(" ")); const argparser = multioption({ type: array(string), long: "hello", defaultValue: () => ["world!"], description: "description", }); const registerOptions = createRegisterOptions(); argparser.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = argparser.parse({ nodes, visitedNodes: new Set(), }); await expect(result).resolves.toEqual(Result.ok(["moshe", "haim"])); }); test("fails when no option is provided and applying default value fails", async () => { const argv = ""; const tokens = tokenize(argv.split(" ")); const argparser = multioption({ type: array(string), long: "hello", defaultValue: () => { throw new Error("its too hot outside, stay inside sweetheart!"); }, description: "description", }); const registerOptions = createRegisterOptions(); argparser.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = argparser.parse({ nodes, visitedNodes: new Set(), }); await expect(result).resolves.toEqual( Result.err({ errors: [ { nodes: [], message: `Failed to resolve default value for '--hello': its too hot outside, stay inside sweetheart!`, }, ], }), ); }); test("fallsback to `[]` when no options and no defaultValue are provided", async () => { const argv = ""; const tokens = tokenize(argv.split(" ")); const argparser = multioption({ type: array(string), long: "hello", description: "description", }); const registerOptions = createRegisterOptions(); argparser.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = argparser.parse({ nodes, visitedNodes: new Set(), }); await expect(result).resolves.toEqual(Result.ok([])); }); ================================================ FILE: test/negative-numbers.test.ts ================================================ import { expect, test } from "vitest"; import { createCmd } from "../example/negative-numbers"; import { runSafely } from "../src"; test("negative numbers", async () => { const cmd = createCmd(); const result = new Promise((resolve) => { cmd.handler = async ({ number }) => { resolve(number); }; }); const runResult = await runSafely(cmd, ["--number", "-10"]); if (runResult._tag === "error") { throw runResult.error; } expect(await result).toEqual(-10); }); ================================================ FILE: test/newparser/findOption.test.ts ================================================ import { expect, test } from "vitest"; import { findOption } from "../../src/newparser/findOption"; import { parse } from "../../src/newparser/parser"; import { tokenize } from "../../src/newparser/tokenizer"; import { createRegisterOptions } from "../createRegisterOptions"; test("finds options", () => { const argv = "hello world --some arg --flag --this-is=option -abcde=f -abcde"; const tokens = tokenize(argv.split(" ")); const nodes = parse(tokens, createRegisterOptions()); const options = findOption(nodes, { longNames: ["some"], shortNames: ["c"] }); const raw = options.map((x) => ({ key: x.key, value: x.value?.node.raw })); expect(raw).toEqual([ { key: "some", value: "arg" }, { key: "c" }, { key: "c" }, ]); }); ================================================ FILE: test/newparser/parser.test.ts ================================================ import { expect, test } from "vitest"; import { type AstNode, parse } from "../../src/newparser/parser"; import { tokenize } from "../../src/newparser/tokenizer"; import { createRegisterOptions } from "../createRegisterOptions"; test("dash in the middle of a word", () => { const tokens = tokenize(["hello", "world", "you-know", "my", "friend"]); const tree = parse(tokens, createRegisterOptions()); expect(tree).toMatchInlineSnapshot(` [ { "index": 0, "raw": "hello", "type": "positionalArgument", }, { "index": 6, "raw": "world", "type": "positionalArgument", }, { "index": 12, "raw": "you-know", "type": "positionalArgument", }, { "index": 21, "raw": "my", "type": "positionalArgument", }, { "index": 24, "raw": "friend", "type": "positionalArgument", }, ] `); }); test("parses forcePositional if it is the last token", () => { const argv = "scripts/ts-node src/example/app.ts cat /tmp/a --".split(" "); const tokens = tokenize(argv); const tree = parse(tokens, createRegisterOptions()); expect(tree.map((x) => x.type)).toContain("forcePositional"); }); test("welp", () => { const argv = "scripts/ts-node src/example/app.ts cat /tmp/a --help".split( " ", ); const tokens = tokenize(argv); const tree = parse(tokens, createRegisterOptions()); expect(tree).toMatchInlineSnapshot(` [ { "index": 0, "raw": "scripts/ts-node", "type": "positionalArgument", }, { "index": 16, "raw": "src/example/app.ts", "type": "positionalArgument", }, { "index": 35, "raw": "cat", "type": "positionalArgument", }, { "index": 39, "raw": "/tmp/a", "type": "positionalArgument", }, { "index": 46, "key": "help", "raw": "--help", "type": "longOption", "value": undefined, }, ] `); }); ================================================ FILE: test/rest-parameters.test.ts ================================================ import path from "node:path"; import { expect, it } from "vitest"; import { app } from "./util"; const runAppRestExample = app( path.join(__dirname, "../example/rest-example.ts"), ); it("should be able to use rest parameters after the positional", async () => { const result = await runAppRestExample([ "pos", "more", "--rest", "parameters", ]); expect(JSON.parse(result.stdout)).toEqual({ scriptName: "pos", everythingElse: ["more", "--rest", "parameters"], }); }); it("should fail if the positional is not provided", async () => { const result = await runAppRestExample(["--rest", "parameters"]); expect(result.exitCode).toBe(1); expect(result.stderr).toContain("No value provided for str"); }); ================================================ FILE: test/rest.test.ts ================================================ import { expect, test } from "vitest"; import { rest } from "../src"; import * as Result from "../src/Result"; import { type AstNode, parse } from "../src/newparser/parser"; import { tokenize } from "../src/newparser/tokenizer"; import { createRegisterOptions } from "./createRegisterOptions"; // test("fails on specific positional", async () => { // const argv = "10 20 --mamma mia hello 40"; // const tokens = tokenize(argv.split(" ")); // const nodes = parse(tokens, createRegisterOptions()); // const argparser = rwrestPositionals({ // type: number, // }); // // const result = argparser.parse({ nodes, visitedNodes: new Set() }); // // await expect(result).resolves.toEqual( // Result.err({ // errors: [ // { // nodes: nodes.filter((x) => x.raw === "hello"), // message: "Not a number", // }, // ], // }), // ); // }); test("succeeds", async () => { const argv = "10 20 --mamma mia hello --hey=ho 40"; const tokens = tokenize(argv.split(" ")); const nodes = parse(tokens, createRegisterOptions()); const argparser = rest(); const visitedNodes = new Set(); const result = argparser.parse({ nodes, visitedNodes }); await expect(result).resolves.toEqual( Result.ok(["10", "20", "--mamma", "mia", "hello", "--hey=ho", "40"]), ); }); ================================================ FILE: test/restPositionals.test.ts ================================================ import { expect, test } from "vitest"; import * as Result from "../src/Result"; import { type AstNode, parse } from "../src/newparser/parser"; import { tokenize } from "../src/newparser/tokenizer"; import { restPositionals } from "../src/restPositionals"; import { createRegisterOptions } from "./createRegisterOptions"; import { number } from "./test-types"; test("fails on specific positional", async () => { const argv = "10 20 --mamma mia hello 40"; const tokens = tokenize(argv.split(" ")); const nodes = parse(tokens, createRegisterOptions()); const argparser = restPositionals({ type: number, }); const result = argparser.parse({ nodes, visitedNodes: new Set() }); await expect(result).resolves.toEqual( Result.err({ errors: [ { nodes: nodes.filter((x) => x.raw === "hello"), message: "Not a number", }, ], }), ); }); test("succeeds when all unused positional decode successfuly", async () => { const argv = "10 20 --mamma mia hello 40"; const tokens = tokenize(argv.split(" ")); const nodes = parse(tokens, createRegisterOptions()); const argparser = restPositionals({ type: number, }); const visitedNodes = new Set(); const alreadyUsedNode = nodes.find((x) => x.raw === "hello"); if (!alreadyUsedNode) { throw new Error("Node `hello` not found. please rewrite the find function"); } visitedNodes.add(alreadyUsedNode); const result = argparser.parse({ nodes, visitedNodes }); await expect(result).resolves.toEqual(Result.ok([10, 20, 40])); }); ================================================ FILE: test/subcommands.test.ts ================================================ import { expect, test, vitest } from "vitest"; import * as Result from "../src/Result"; import { command } from "../src/command"; import { flag } from "../src/flag"; import { parse } from "../src/newparser/parser"; import { tokenize } from "../src/newparser/tokenizer"; import { option } from "../src/option"; import { positional } from "../src/positional"; import { subcommands } from "../src/subcommands"; import { createRegisterOptions } from "./createRegisterOptions"; import { boolean, string } from "./test-types"; const logMock = vitest.fn(); const greeter = command({ name: "greeter", args: { name: positional({ type: string, displayName: "name" }), exclaim: flag({ type: boolean, long: "exclaim", short: "e" }), greeting: option({ type: string, long: "greeting", short: "g" }), }, handler: (x) => { logMock("greeter", x); }, }); const howdyPrinter = command({ name: "howdy", args: { name: positional({ type: string, displayName: "name" }), }, handler: (x) => { logMock("howdy", x); }, }); const subcmds = subcommands({ name: "my-cli", cmds: { greeter, howdy: howdyPrinter, }, }); test("chooses one subcommand", async () => { const argv = "greeter Gal -eg Hello".split(" "); const tokens = tokenize(argv); const registerOptions = createRegisterOptions(); subcmds.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = await subcmds.parse({ nodes, visitedNodes: new Set() }); const expected: typeof result = Result.ok({ args: { name: "Gal", exclaim: true, greeting: "Hello", }, command: "greeter", }); expect(result).toEqual(expected); }); test("chooses the other subcommand", async () => { const argv = "howdy joe".split(" "); const tokens = tokenize(argv); const registerOptions = createRegisterOptions(); subcmds.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = await subcmds.parse({ nodes, visitedNodes: new Set() }); const expected: typeof result = Result.ok({ command: "howdy", args: { name: "joe", }, }); expect(result).toEqual(expected); }); test("fails when using unknown subcommand", async () => { const argv = "--hello yes how are you joe".split(" "); const tokens = tokenize(argv); const registerOptions = createRegisterOptions(); subcmds.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = await subcmds.parse({ nodes, visitedNodes: new Set() }); const expected: typeof result = Result.err({ errors: [ { nodes: nodes.filter((x) => x.raw === "how"), message: "Not a valid subcommand name", }, ], partialValue: {}, }); expect(result).toEqual(expected); }); test("fails for a subcommand argument parsing issue", async () => { const argv = "greeter Gal -g Hello --exclaim=hell-no".split(" "); const tokens = tokenize(argv); const registerOptions = createRegisterOptions(); subcmds.register(registerOptions); const nodes = parse(tokens, registerOptions); const result = await subcmds.parse({ nodes, visitedNodes: new Set() }); const expected = Result.err({ errors: [ { nodes: nodes.filter((x) => x.raw.includes("hell-no")), message: `expected value to be either "true" or "false". got: "hell-no"`, }, ], partialValue: { command: "greeter", args: { greeting: "Hello", name: "Gal", }, }, }); expect(result).toEqual(expected); }); ================================================ FILE: test/test-types.ts ================================================ import { identity } from "../src/from"; import type { InputOf, OutputOf } from "../src/from"; import type { Type } from "../src/type"; export const number: Type = { async from(str) { const decoded = Number.parseInt(str, 10); if (Number.isNaN(decoded)) { throw new Error("Not a number"); } return decoded; }, displayName: "number", description: "a number", }; export function single>( t: T, ): Omit & Type[], OutputOf> { return { ...t, from(ts) { if (ts.length === 0) { return { result: "error", message: "No value provided" }; } if (ts.length > 1) { return { result: "error", message: `Too many arguments provided. Expected 1, got: ${ts.length}`, }; } return t.from(ts[0]); }, }; } export const string: Type = { ...identity(), description: "a string", displayName: "str", }; export const boolean: Type = { ...identity(), description: "a boolean", displayName: "true/false", defaultValue() { return false; }, }; export function optional>( t: T, ): Type, OutputOf | undefined> { return { ...t, defaultValue(): OutputOf | undefined { return undefined; }, }; } ================================================ FILE: test/tsconfig.json ================================================ { "extends": "../tsconfig.noEmit.json", "include": ["."] } ================================================ FILE: test/type-inference.test.ts ================================================ import path from "node:path"; import { getTypes } from "infer-types"; import { expect, test } from "vitest"; test("types are inferred correctly", () => { const filepath = path.join(__dirname, "../example/app.ts"); const types = getTypes(filepath); expect(types).toEqual({ "cat -> stream": "Stream", "cat -> restStreams": "Stream[]", "complex -> intOrString": "string | number", "complex -> floatOrString": "string | number", "complex -> pos2": "string", "complex -> optionWithoutType": "string", "complex -> boolWithoutType": "boolean", "complex -> rest": "string[]", "greet -> greeting": "string", "greet -> name": "string", "greet -> noExclaim": "boolean", }); }); ================================================ FILE: test/ui.test.ts ================================================ import path from "node:path"; import { describe, expect, test } from "vitest"; import { app } from "./util"; test("help for subcommands", async () => { const result = await runApp1(["--help"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(0); }); test("invalid subcommand", async () => { const result = await runApp1(["subcommand-that-doesnt-exist"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(1); }); test("help for complex command", async () => { const result = await runApp1(["complex", "--help"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(0); }); test("too many arguments", async () => { const result = await runApp1([ "--this=will-be-an-error", "cat", path.relative(process.cwd(), path.join(__dirname, "../package.json")), "--and-also-this", ]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(1); }); test("suggests a subcommand on typo", async () => { const result = await runApp1(["greek"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(1); }); test("displays subcommand help if no arguments passed", async () => { const result = await runApp1([]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(0); }); test("displays nested subcommand help if no arguments passed", async () => { const result = await runApp1(["composed"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(0); }); test("composes errors", async () => { const result = await runApp1([ "greet", "--times=not-a-number", "not-capitalized", ]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(1); }); test("multiline error", async () => { const result = await runApp1(["greet", "Bon Jovi"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(1); }); test("help for composed subcommands", async () => { const result = await runApp1(["composed", "--help"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(0); }); test("help for composed subcommand", async () => { const result = await runApp1(["composed", "cat", "--help"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(0); }); test("asynchronous type conversion works for failures", async () => { const result = await runApp1([ "composed", "cat", "https://mock.httpstatus.io/404", ]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(1); }); test("asynchronous type conversion works for success", async () => { const result = await runApp1([ "composed", "cat", "https://mock.httpstatus.io/200", ]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(0); }); test("subcommands show their version", async () => { const result = await runApp1(["--version"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(0); }); test("failures in defaultValue", async () => { const result = await runApp2([]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(1); }); test("subcommands with process.argv.slice(2)", async () => { const result = await runApp3(["--help"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(0); }); describe("allows positional arguments", () => { test("help shows them", async () => { const result = await runApp3(["sub2", "--help"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(0); }); test("no positionals => all default", async () => { const result = await runApp3(["sub2"]); expect(result.all).toMatchInlineSnapshot( `"{ name: 'anonymous', age: undefined }"`, ); expect(result.exitCode).toBe(0); }); test("expects the correct order", async () => { // should fail because we get an age first and `hello` is not a number const result = await runApp3(["sub2", "hello"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(1); }); test("can take all the arguments", async () => { // should fail because we get an age first and `hello` is not a number const result = await runApp3(["sub2", "10", "ben"]); expect(result.all).toMatchInlineSnapshot(`"{ name: 'ben', age: 10 }"`); expect(result.exitCode).toBe(0); }); }); test("onMissing resolves successfully", async () => { const result = await runApp4([]); expect(result.all).toMatchInlineSnapshot( `"Result: default value, Hi, Hello from opt"`, ); expect(result.exitCode).toBe(0); }); test("onMissing failure", async () => { const result = await runApp5([]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(1); }); test("help shows onMissing option", async () => { const result = await runApp4(["--help"]); expect(result.all).toMatchSnapshot(); expect(result.exitCode).toBe(0); }); test("flag onMissing resolves successfully", async () => { const result = await runApp6([]); expect(result.exitCode).toBe(0); expect(result.all).toContain("Verbose flag not provided"); expect(result.all).toContain("Debug flag missing"); expect(result.all).toContain("Force flag not set"); }); test("flag help shows onMissing as optional", async () => { const result = await runApp6(["--help"]); expect(result.exitCode).toBe(0); expect(result.all).toContain("[optional]"); }); test("multioption onMissing resolves successfully", async () => { const result = await runApp7([]); expect(result.exitCode).toBe(0); expect(result.all).toContain("No includes specified"); expect(result.all).toContain("No targets specified"); expect(result.all).toContain("No features specified"); }); test("multioption help shows onMissing as optional", async () => { const result = await runApp7(["--help"]); expect(result.exitCode).toBe(0); expect(result.all).toContain("[...optional]"); }); const runApp1 = app(path.join(__dirname, "../example/app.ts")); const runApp2 = app(path.join(__dirname, "../example/app2.ts")); const runApp3 = app(path.join(__dirname, "../example/app3.ts")); const runApp4 = app(path.join(__dirname, "../example/app4.ts")); const runApp5 = app(path.join(__dirname, "../example/app5.ts")); const runApp6 = app(path.join(__dirname, "../example/app6.ts")); const runApp7 = app(path.join(__dirname, "../example/app7.ts")); ================================================ FILE: test/util.ts ================================================ import { type ExecaReturnValue, execa } from "execa"; export function app( scriptPath: string, ): (args: string[]) => Promise { return async (args) => { const result = await execa( require.resolve("tsx/cli"), [scriptPath, ...args], { all: true, reject: false, env: { FORCE_COLOR: "true", }, }, ); return result; }; } ================================================ FILE: test/utils.test.ts ================================================ import chalk from "chalk"; import stripAnsi from "strip-ansi"; import { describe, expect, it } from "vitest"; import { expectTypeOf } from "vitest"; import { type AllOrNothing, padNoAnsi } from "../src/utils"; describe("padNoAnsi", () => { it("pads start", () => { const expected = "hello".padStart(10, " "); const actual = padNoAnsi( [ chalk.red("h"), chalk.cyan("e"), chalk.blue("l"), chalk.green("l"), chalk.red("o"), ].join(""), 10, "start", ); expect(stripAnsi(actual)).toEqual(expected); }); it("pads end", () => { const expected = "hello".padEnd(10, " "); const actual = padNoAnsi( [ chalk.red("h"), chalk.cyan("e"), chalk.blue("l"), chalk.green("l"), chalk.red("o"), ].join(""), 10, "end", ); expect(stripAnsi(actual)).toEqual(expected); }); it("returns the string if it is shorter than the padding", () => { const str = chalk`{red h}{cyan e}{blue l}{green l}{red o}`; const actual = padNoAnsi(str, 2, "end"); expect(actual).toEqual(str); }); }); it("allows to provide all arguments or none", () => { type Person = { name: string; age: number }; expectTypeOf<{ name: "Joe"; age: 100 }>().toExtend>(); expectTypeOf<{ name: "Joe" }>().not.toExtend>(); expectTypeOf<{}>().toExtend>(); }); ================================================ FILE: test/vercel-formatter.test.ts ================================================ import { afterEach, describe, expect, test } from "vitest"; import { command, flag, positional, resetHelpFormatter, run, setDefaultHelpFormatter, subcommands, } from "../src"; import { createVercelFormatter, vercelFormatter, } from "../src/batteries/vercel-formatter"; describe("vercelFormatter", () => { afterEach(() => { resetHelpFormatter(); }); test("formats subcommands help with version and logo", async () => { setDefaultHelpFormatter( createVercelFormatter({ cliName: "Vercel Sandbox CLI", logo: "▲", }), ); const create = command({ name: "create", description: "Create a new sandbox", args: { connect: flag({ long: "connect", short: "c", description: "Connect after creating", }), }, handler: () => {}, }); const list = command({ name: "list", aliases: ["ls"], description: "List sandboxes for the current project", args: {}, handler: () => {}, }); const runCmd = command({ name: "run", description: "Create and run a command in a sandbox", args: { cmd: positional({ displayName: "cmd", description: "Command to run" }), }, handler: () => {}, }); const stop = command({ name: "stop", aliases: ["rm"], description: "Stop one or more running sandboxes", args: { ids: positional({ displayName: "id...", description: "Sandbox IDs" }), }, handler: () => {}, }); const copy = command({ name: "copy", aliases: ["cp"], description: "Copy files between local and remote", args: { src: positional({ displayName: "src", description: "Source path" }), dst: positional({ displayName: "dst", description: "Destination path", }), }, handler: () => {}, }); const app = subcommands({ name: "sandbox", version: "2.3.0", description: "Interfacing with Vercel Sandbox", cmds: { create, list, run: runCmd, stop, copy }, examples: [ { description: "Create a sandbox and start a shell", command: "sandbox create --connect", }, { description: "Run a command in a new sandbox", command: "sandbox run -- node -e \"console.log('hello')\"", }, ], }); let output = ""; const originalLog = console.log; console.log = (msg: string) => { output = msg; }; try { await run(app, ["--help"]); } catch { // run throws Exit } console.log = originalLog; expect(output).toMatchSnapshot(); }); test("formats command help", async () => { setDefaultHelpFormatter(vercelFormatter); const create = command({ name: "create", version: "1.0.0", description: "Create a new sandbox", args: { connect: flag({ long: "connect", short: "c", description: "Connect to the sandbox after creating", }), name: positional({ displayName: "name", description: "Name for the sandbox", }), }, handler: () => {}, examples: [ { description: "Create a sandbox named 'dev'", command: "create dev", }, { description: "Create and connect", command: "create dev --connect", }, ], }); let output = ""; const originalLog = console.log; console.log = (msg: string) => { output = msg; }; try { await run(create, ["--help"]); } catch { // run throws Exit } console.log = originalLog; expect(output).toMatchSnapshot(); }); test("shows aliases as short | long format", async () => { setDefaultHelpFormatter(vercelFormatter); const list = command({ name: "list", aliases: ["ls", "l"], description: "List items", args: {}, handler: () => {}, }); const app = subcommands({ name: "app", cmds: { list }, }); let output = ""; const originalLog = console.log; console.log = (msg: string) => { output = msg; }; try { await run(app, ["--help"]); } catch { // run throws Exit } console.log = originalLog; // Should show "l | list" (shortest alias first) expect(output).toContain("l | list"); }); test("derives argument hints from help topics", async () => { setDefaultHelpFormatter(vercelFormatter); const exec = command({ name: "exec", description: "Execute a command", args: { id: positional({ displayName: "id", description: "Sandbox ID" }), cmd: positional({ displayName: "cmd", description: "Command" }), }, handler: () => {}, }); const app = subcommands({ name: "app", cmds: { exec }, }); let output = ""; const originalLog = console.log; console.log = (msg: string) => { output = msg; }; try { await run(app, ["--help"]); } catch { // run throws Exit } console.log = originalLog; // Should show argument hints derived from positionals expect(output).toContain(""); expect(output).toContain(""); }); }); ================================================ FILE: tsconfig.esm.json ================================================ { "$schema": "http://json.schemastore.org/tsconfig", "extends": "./tsconfig.json", "compilerOptions": { "module": "ESNext", "outDir": "./dist/esm" } } ================================================ FILE: tsconfig.json ================================================ { "include": ["src"], "exclude": ["dist"], "compilerOptions": { "target": "es2018", "module": "commonjs", "lib": ["ES2018"], "downlevelIteration": true, "sourceMap": true, "outDir": "./dist/cjs", "rootDir": "./src", "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "moduleResolution": "node", "jsx": "react", "esModuleInterop": true, "declaration": true, "skipLibCheck": true } } ================================================ FILE: tsconfig.noEmit.json ================================================ { "$schema": "http://json.schemastore.org/tsconfig", "extends": "./tsconfig.json", "compilerOptions": { "rootDir": "./", "noEmit": true } } ================================================ FILE: typedoc.js ================================================ module.exports = { exclude: ["test/**", "src/example/**"], excludeExternals: true, excludeNotExported: true, excludePrivate: true, hideGenerator: true, includes: "./src", out: "public", module: "commonjs", stripInternal: "true", };