In the world of software development, command-line interfaces (CLIs) are incredibly valuable tools. They allow developers to interact with applications and systems directly through text-based commands, automating tasks, managing projects, and more. If you’ve ever used tools like Git, npm, or even just navigated your file system via the terminal, you’ve used a CLI. This tutorial will guide you through creating your own simple CLI using TypeScript, empowering you to build custom tools tailored to your specific needs. We’ll break down the process into manageable steps, explaining the core concepts and providing practical examples along the way. By the end, you’ll have a fundamental understanding of how CLIs work and be able to create your own.
Why Build a CLI?
Before diving into the code, let’s explore why building a CLI is beneficial. CLIs offer several advantages:
- Automation: Automate repetitive tasks, saving time and reducing errors.
- Efficiency: Quickly execute commands without navigating graphical user interfaces.
- Scripting: Create scripts to chain multiple commands together, automating complex workflows.
- Customization: Build tools tailored to your specific development environment and project needs.
- Cross-Platform Compatibility: CLIs often work consistently across different operating systems.
Imagine you frequently need to generate boilerplate code for new projects or deploy your application to a specific server. A custom CLI can streamline these processes, making you more productive.
Setting Up Your TypeScript Project
Let’s start by setting up a basic TypeScript project. We’ll use npm (or yarn, if you prefer) to manage our dependencies and TypeScript to transpile our code. Open your terminal and follow these steps:
- Create a Project Directory: Create a new directory for your project and navigate into it.
mkdir my-cli-app
cd my-cli-app
- Initialize npm: Initialize a new npm project.
npm init -y
- Install TypeScript: Install TypeScript as a development dependency.
npm install --save-dev typescript
- Create a `tsconfig.json` file: This file configures the TypeScript compiler. Create a file named `tsconfig.json` in your project root with the following content:
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
This configuration specifies that our TypeScript code will be compiled to ES2016 JavaScript, using the CommonJS module system. The `outDir` setting tells the compiler to output the compiled JavaScript files into a `dist` directory, and `rootDir` specifies the source directory where your TypeScript files will reside.
- Create the Source Directory: Create a `src` directory to hold your TypeScript files.
mkdir src
With these steps completed, your project structure should look like this:
my-cli-app/
├── node_modules/
├── package.json
├── package-lock.json
├── src/
├── tsconfig.json
└──
Creating the CLI Entry Point
Now, let’s create the entry point for our CLI. This will be the file that gets executed when a user runs our CLI from the command line. Create a file named `index.ts` inside the `src` directory with the following content:
#!/usr/bin/env node
console.log("Hello from my CLI!");
Let’s break down this code:
#!/usr/bin/env node: This is called a shebang (sh-bang) and is crucial. It tells the operating system to execute this file using the Node.js runtime. Without this line, the file might not be recognized as an executable script.console.log("Hello from my CLI!");: This line simply prints a greeting to the console.
Making the CLI Executable
Before we can run our CLI, we need to make it executable and link it to our system. Here’s how:
- Compile the TypeScript code: Run the TypeScript compiler to transpile your `index.ts` file into JavaScript. You can do this by running the following command in your terminal. This command will execute the tsc compiler, which will use the configuration from the `tsconfig.json` file.
npx tsc
This command will create a `dist` directory containing the compiled JavaScript file (e.g., `dist/index.js`).
- Set the executable permissions: Change the file permissions of the compiled JavaScript file to make it executable. Navigate into the `dist` directory and then use the `chmod` command.
cd dist
chmod +x index.js
- Link the CLI: To use your CLI globally, you need to link it to your system. In your `package.json` file, add a `bin` field that specifies the name of your command and the path to the compiled JavaScript file. For example:
{
"name": "my-cli-app",
"version": "1.0.0",
"description": "A simple CLI",
"main": "dist/index.js",
"bin": {
"my-cli": "dist/index.js"
},
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
"author": "Your Name",
"license": "ISC",
"devDependencies": {
"typescript": "^5.0.0"
}
}
Here, we’ve defined a command named `my-cli` that will execute the `dist/index.js` file. Now, run the following command in your project root to link the CLI:
npm link
This command creates a symbolic link, making your CLI accessible from your terminal.
Running Your CLI
Now, open your terminal and type the name of your CLI command (in this case, `my-cli`) and press Enter:
my-cli
You should see “Hello from my CLI!” printed in the console. Congratulations, you’ve created your first CLI!
Adding Arguments and Options
A basic CLI that just prints a greeting is a good starting point, but most CLIs take arguments and options to perform more complex tasks. Arguments are values passed to the CLI, and options (often called flags) modify the behavior of the CLI. Let’s add support for arguments and options to our CLI.
We’ll use a library called `yargs` to handle argument and option parsing. `yargs` simplifies the process of defining and parsing command-line arguments and options.
- Install yargs: Install `yargs` as a project dependency.
npm install yargs
- Import yargs and Modify `index.ts`: Import `yargs` and update your `src/index.ts` file to use it.
#!/usr/bin/env node
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
yargs(hideBin(process.argv))
.command(
'greet [name]',
'Greets the user',
(yargs) => {
return yargs.positional('name', {
describe: 'The name to greet',
type: 'string',
default: 'World',
});
},
(argv) => {
const { name } = argv;
console.log(`Hello, ${name}!`);
}
)
.option('verbose', {
alias: 'v',
type: 'boolean',
description: 'Run with verbose logging',
default: false,
})
.parse()
Let’s break down the changes:
import yargs from 'yargs';: Imports the `yargs` library.import { hideBin } from 'yargs/helpers';: Imports the `hideBin` helper to remove the first two elements of `process.argv`. These are typically the path to Node.js and the path to the script being executed, and they are not relevant for the argument parsing.yargs(hideBin(process.argv)): Initializes `yargs`, passing in the command-line arguments..command(...): Defines a command named “greet” with an argument “name”..positional('name', { ... }): Defines a positional argument “name” with a description, type, and default value.(argv) => { ... }: This is the handler function that runs when the “greet” command is executed. It extracts the “name” argument from the `argv` object and logs a greeting..option('verbose', { ... }): Defines an option (flag) called “verbose” with an alias “v”, type, description, and default value..parse(): Parses the command-line arguments and executes the appropriate command handler.
- Rebuild and Relink: After making changes to your TypeScript code, you need to recompile and relink your CLI.
npx tsc
npm link
Using Arguments and Options
Now, let’s test our CLI with arguments and options. Open your terminal and try the following commands:
my-cli greet John
This command should print “Hello, John!”
my-cli greet --verbose John
This command should print “Hello, John!” (the `verbose` flag doesn’t do anything yet, but we’ll add some functionality later).
my-cli greet
This command should print “Hello, World!” (because “World” is the default value for the `name` argument).
Adding More Functionality
Let’s expand our CLI to demonstrate more advanced features. We’ll add a command to calculate the area of a rectangle. This will involve accepting two arguments (width and height) and providing more output based on the value of the `verbose` option.
- Modify `index.ts`: Update your `src/index.ts` file to include the new command.
#!/usr/bin/env node
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
yargs(hideBin(process.argv))
.command(
'greet [name]',
'Greets the user',
(yargs) => {
return yargs.positional('name', {
describe: 'The name to greet',
type: 'string',
default: 'World',
});
},
(argv) => {
const { name } = argv;
console.log(`Hello, ${name}!`);
}
)
.command(
'area [width] [height]',
'Calculates the area of a rectangle',
(yargs) => {
return yargs
.positional('width', {
describe: 'The width of the rectangle',
type: 'number',
demandOption: true,
})
.positional('height', {
describe: 'The height of the rectangle',
type: 'number',
demandOption: true,
});
},
(argv) => {
const { width, height, verbose } = argv;
const area = width * height;
console.log(`Area: ${area}`);
if (verbose) {
console.log(`Width: ${width}`);
console.log(`Height: ${height}`);
}
}
)
.option('verbose', {
alias: 'v',
type: 'boolean',
description: 'Run with verbose logging',
default: false,
})
.parse()
Key changes include:
- Added a new command:
.command('area [width] [height]', ...). - Defined two positional arguments:
widthandheight, both of typenumberand required (demandOption: true). - Inside the area command handler, we calculate the area and log it.
- The
verboseoption is now used to conditionally log the width and height.
- Rebuild and Relink: Recompile and relink your CLI.
npx tsc
npm link
- Test the new command: Test the new command in your terminal.
my-cli area 5 10
This should output: Area: 50
my-cli area 5 10 -v
This should output:
Area: 50
Width: 5
Height: 10
Handling Errors
Real-world CLIs need to handle errors gracefully. Let’s add some error handling to our CLI. For example, we’ll check if the width and height provided to the `area` command are valid numbers.
- Modify `index.ts`: Update your
src/index.tsfile to include error handling.
#!/usr/bin/env node
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
yargs(hideBin(process.argv))
.command(
'greet [name]',
'Greets the user',
(yargs) => {
return yargs.positional('name', {
describe: 'The name to greet',
type: 'string',
default: 'World',
});
},
(argv) => {
const { name } = argv;
console.log(`Hello, ${name}!`);
}
)
.command(
'area [width] [height]',
'Calculates the area of a rectangle',
(yargs) => {
return yargs
.positional('width', {
describe: 'The width of the rectangle',
type: 'number',
demandOption: true,
})
.positional('height', {
describe: 'The height of the rectangle',
type: 'number',
demandOption: true,
});
},
(argv) => {
const { width, height, verbose } = argv;
if (typeof width !== 'number' || typeof height !== 'number' || isNaN(width) || isNaN(height)) {
console.error('Error: Width and height must be valid numbers.');
process.exit(1);
}
const area = width * height;
console.log(`Area: ${area}`);
if (verbose) {
console.log(`Width: ${width}`);
console.log(`Height: ${height}`);
}
}
)
.option('verbose', {
alias: 'v',
type: 'boolean',
description: 'Run with verbose logging',
default: false,
})
.parse()
Key changes:
- Inside the
areacommand handler, we added a check to ensure thatwidthandheightare numbers and are not NaN (Not a Number). - If the input is invalid, we log an error message to the console using
console.error()and exit the process with a non-zero exit code (process.exit(1)) to indicate an error.
- Rebuild and Relink: Recompile and relink your CLI.
npx tsc
npm link
- Test the error handling: Test the error handling in your terminal.
my-cli area abc 10
This should output: Error: Width and height must be valid numbers. and the process should exit with a non-zero exit code.
Adding Help and Usage Information
A well-designed CLI provides help and usage information to guide users. `yargs` automatically generates a basic help message, but you can customize it further.
- Run the default help: Run your CLI with the
--helpflag:
my-cli --help
You should see a basic help message that includes information about the available commands and options.
- Customize help and usage (optional): You can customize the help message further by providing descriptions for your commands and options within your
yargsconfiguration. The descriptions you’ve already added will be incorporated into the help message. For more advanced customization, you can use the.usage()method to define a custom usage string, and the.example()method to provide examples of how to use your CLI.
Testing Your CLI
Testing is crucial for ensuring your CLI functions correctly. You can write unit tests to verify the behavior of your CLI commands and error handling.
- Install a testing library: For this example, we’ll use Jest, a popular JavaScript testing framework. Install it as a development dependency:
npm install --save-dev jest @types/jest ts-jest
- Configure Jest: Create a
jest.config.jsfile in your project root with the following content:
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['/test'],
collectCoverage: false,
};
- Create a test file: Create a directory named
testand inside it, create a file namedindex.test.ts. Add the following test cases. Note, that this is a basic setup and you may need to adjust the paths to your compiled files.
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const cliPath = 'dist/index.js'; // Adjust the path if necessary
describe('my-cli', () => {
it('should greet the user with the default name', async () => {
const { stdout } = await execAsync(`node ${cliPath} greet`);
expect(stdout).toContain('Hello, World!');
});
it('should greet the user with a specified name', async () => {
const { stdout } = await execAsync(`node ${cliPath} greet John`);
expect(stdout).toContain('Hello, John!');
});
it('should calculate the area of a rectangle', async () => {
const { stdout } = await execAsync(`node ${cliPath} area 5 10`);
expect(stdout).toContain('Area: 50');
});
it('should handle invalid input for area calculation', async () => {
try {
await execAsync(`node ${cliPath} area abc 10`);
} catch (error: any) {
expect(error.stderr).toContain('Error: Width and height must be valid numbers.');
return;
}
throw new Error('Test failed: Should have thrown an error');
});
});
- Add a test script to `package.json`: Add a test script to your
package.jsonfile to run the tests.
{
"name": "my-cli-app",
"version": "1.0.0",
"description": "A simple CLI",
"main": "dist/index.js",
"bin": {
"my-cli": "dist/index.js"
},
"scripts": {
"test": "jest"
},
"author": "Your Name",
"license": "ISC",
"devDependencies": {
"typescript": "^5.0.0",
"jest": "^29.0.0",
"@types/jest": "^29.0.0",
"ts-jest": "^29.0.0"
}
}
- Run the tests: Run the tests using the command.
npm test
This will execute the tests defined in index.test.ts, verifying that your CLI functions as expected.
Key Takeaways and Best Practices
Let’s summarize the key takeaways and best practices for creating CLIs with TypeScript:
- Project Setup: Start with a well-defined project structure and use TypeScript for type safety and code organization.
- Shebang: Always include the shebang (
#!/usr/bin/env node) at the beginning of your entry point file. - Command-Line Argument Parsing: Use a library like
yargsto simplify the process of parsing arguments and options. - Error Handling: Implement robust error handling to gracefully handle invalid input and unexpected situations.
- Help and Usage Information: Provide clear and concise help messages and usage instructions.
- Testing: Write unit tests to ensure that your CLI functions correctly.
- Modularity: Break down your CLI into smaller, reusable modules.
- User Experience: Design your CLI with the user in mind, providing clear feedback and helpful error messages.
FAQ
Here are some frequently asked questions about creating CLIs with TypeScript:
- Can I use other argument parsing libraries besides yargs? Yes, you can. Other popular options include Commander.js and oclif, each with its own set of features and approaches. Choose the library that best fits your project’s needs.
- How do I handle asynchronous operations in my CLI? You can use
async/awaitor Promises to handle asynchronous operations. For example, if your CLI needs to make an API call, you can useasync/awaitto wait for the response. - How do I publish my CLI to npm? You can publish your CLI to npm so others can install and use it. To do so, you’ll need to create an npm account, package your CLI, and follow the npm publishing guidelines. Make sure you set the
binfield in yourpackage.jsoncorrectly. - What are some common mistakes to avoid? Some common mistakes include not handling errors properly, not providing enough help and usage information, and not writing tests. Also, remember to make your CLI executable and link it correctly.
- How do I add colors and formatting to my CLI output? You can use libraries like Chalk or Colors.js to add colors and formatting to your CLI’s output, making it more visually appealing and easier to read.
Building a CLI with TypeScript is a powerful way to automate tasks, improve your workflow, and create custom tools. This tutorial provides a solid foundation for building your own CLIs. Remember to experiment, explore the capabilities of the libraries, and continuously refine your CLI to meet your specific needs. The possibilities are vast, and with practice, you can create CLIs that significantly enhance your productivity.
By following these steps and exploring the resources available, you are well-equipped to create your own effective command-line tools. As you build more complex CLIs, consider exploring advanced topics such as subcommands, interactive prompts, and more sophisticated argument parsing. The ability to create custom CLIs is a valuable skill for any developer, and with TypeScript, you can build reliable and maintainable tools that streamline your development process.
