Create Custom CLI Tool with Node JS
Sep 1, 2024
Creating Command Line Tools (CLI) can be a great way to automate repetitive tasks, enhance productivity and minimize time spent on time consuming tasks. In this post we'll see how we can build a custom CLI Tool in Node js
-
Introduction
A CLI program basically is a program that runs in the terminal, allowing users to interact with it via commands and arguments.
-
Prerequisites
Before we start you need to have Node JS Installed (In your machine or containerized) check the official website.
I run the version
v22.2.0
you can check your version via the command:bashnode --version
Make sure it is equivalent or higher than mine (recommended to avoid unexpected issues).
I will be using TypeScript in the following code. You can ignore the types if you want to stick with JavaScript.
You can install TypeScript globally on you system using the following command:
bashnpm install -g typescript
-
Setting Up The Project
In this project I want to create a code comments and trailing spaces remover. That means it will take a file as input and removes comments or trailing spaces on that file.
First I will create a directory for this project:
bashmkdir code-cleaner && cd code-cleaner
Note: I will use Built in modules. If you are lazy like me skip this section to the coming one where i use some npm modules.
Now Create a new project using NPM:
bashnpm init -y
Install Dependencies
These dependencies are not required if you are using JavaScript:
bashnpm i -D typescript ts-node @types/node
Configure TypeScript
Create a
tsconfig.json
file to configure TypeScript:bashnpx tsc --init
If you run this command a
tsconfig.json
file will appear Add the following code it is missing (or uncomment it if it is commented):json{ "compilerOptions": { "target": "ES2020", "module": "CommonJS", "outDir": "./dist", "strict": true, "esModuleInterop": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules"] }
Add NPM Scripts
Add the following scripts for building the project and for development too:
json"scripts": { "build": "tsc", "start": "node dist/index.js", "dev": "ts-node src/index.ts" }
Create File Structure
I'll create an
index.ts
file (orindex.js
) file inside thesrc
directory as the entry point to the project. Run the following command:bashmkdir src touch src/index.ts
Test if Everything works
Let's add some code to the index.ts file:
typescriptconsole.log("Hello, SyntaxBox!"); // this may get somebody mad :)
Now let's run the
build
,start
,dev
commands.Let's try the first command:
bashnpm run build
If it builds with no errors you will see that there is a new
dist
directory. It has the compiled JavaScript code.I'll keep the rest for you to test them.
Now The File structure Will be something similar to this:
bashcode-cleaner/ ├── src/ │ └── index.ts ├── dist/ ├── package.json ├── tsconfig.json └── README.md
-
Create The CLI Logic
-
Add Shebang and Imports
First let's import the required packages in the
index.ts
file:typescript#!/usr/bin/env node import * as path from "path"; import * as fs from "fs";
The first line basically allows the script to be executed from the command line directly.
Here is an AI generated explanation to it if you are a Nerd:
typescript#!/usr/bin/env node `This line is called a shebang or hashbang. Let's break it down: 1. "#!" - This is the shebang notation. It tells the system that this file should be executed by an interpreter. 2. "/usr/bin/env" - This is a command that locates and executes programs in the user's PATH. It's used here to find the "node" executable. 3. "node" - This specifies that the Node.js interpreter should be used to execute this script. By using this shebang line, you're indicating that this file is a Node.js script that can be executed directly from the command line on Unix-like systems (Linux, macOS, etc.). When you make the file executable (using "chmod +x filename.js") and run it ("./filename.js"), the system will use Node.js to interpret and run the script. This approach is more portable than specifying a direct path to the Node.js executable (like "#!/usr/bin/node") because it will work even if Node.js is installed in different locations on different systems, as long as it's in the user's PATH.`;
Now if we run
build
command and then make compiled file executable via this command (Read the explanation):bashchmod +x ./dist/index.js
And then try to run the command via
bash./dist/index.js
It works (will not output anything because it is empty).
Congrats you created the first CLI Tool.
-
Basic CLI Structure
If we try to log the
process.argv
object inside the project like this:typescriptconsole.log(process.argv);
Then build and run the file:
bashnpm run build ./dist/index.js
We get nothing interesting:
bash./dist/index.js [ '/home/hamid/.nvm/versions/node/v22.2.0/bin/node', '/home/hamid/dev/scripts/nodejs/code-cleaner/dist/index.js', ]
These two paths mean the binary path for Node JS and the path for our JS file.
But if we add some text after the file path like this:
bash./dist/index.js syntaxbox is cool
We get this output
bash[ '/home/hamid/.nvm/versions/node/v22.2.0/bin/node', '/home/hamid/dev/scripts/nodejs/code-cleaner/dist/index.js', 'syntaxbox', 'is', 'cool' ]
Which means we can send arguments via writing them after the file name. Here is an AI generated text explaining the standard way to use arguments:
typescript`Standard Usage of Command-Line Arguments Command-line arguments are a powerful way to interact with your CLI tool. Here's a standard approach to using them: 1. Positional Arguments: These are arguments that are expected to be in a specific position. For example, in the command "./dist/index.js greet John", "greet" could be considered the command, and "John" the argument. 2. Flags: These are typically used to modify the behavior of a command. They are usually prefixed with one or two dashes, such as "-v" for a version flag or "--name" for a named option. 3. Options: Often used in conjunction with flags, options provide additional parameters. For example, "./dist/index.js greet --name John" uses "--name" as a flag with "John" as its value.`;
To make Things easier to work with we can slice the arguments from the two first paths like this:
typescriptconst args = process.argv.slice(2); // Slice out the first two elements
-
Create Arguments Parser Function
First let's define what we need as an
interface
.We need the following flags:
--file
or--f
flag for file name.--spaces
to specify the removal or trailing spaces.--comments
for the comments removal and also for the comments syntax argument after it.--multiStart
for the multi line comments removal and also. For the multi line comments starting syntax argument after it.--multiEnd
for the multi line comments removal and also for the multi line comments ending syntax argument after it.
typescriptinterface CleanOptions { file: string; spaces: boolean; comments?: string; multiStart?: string; multiEnd?: string; }
Now let's Create the parser function which will return
CleanOptions
type:typescriptfunction parseArguments(args: string[]): CleanOptions { const options: CleanOptions = { file: "", spaces: false, }; for (let i = 0; i < args.length; i++) { switch (args[i]) { case "-f": case "--file": options.file = args[++i]; break; case "--spaces": options.spaces = true; break; case "--comments": options.comments = args[++i]; break; case "--multi-start": options.multiStart = args[++i]; break; case "--multi-end": options.multiEnd = args[++i]; break; } } if (!options.file) { console.error( "Error: File path is required. Use -f or --file to specify the file.", ); process.exit(1); } return options; }
-
Create The Business Logic
Let's create the functions that will handle the trailing spaces and comments removal:
typescriptfunction removeTrailingSpaces(line: string): string { return line.trimRight(); }
typescriptfunction removeSingleLineComment( line: string, commentSyntax: string, ): string { return line.split(commentSyntax)[0].trimRight(); }
typescriptfunction handleMultiLineComments( lines: string[], multiStart: string, multiEnd: string, ): string[] { let inMultiLineComment = false; return lines.map((line) => { if (inMultiLineComment) { if (line.includes(multiEnd)) { inMultiLineComment = false; return line.split(multiEnd)[1] || ""; } return ""; } else if (line.includes(multiStart)) { inMultiLineComment = true; const parts = line.split(multiStart); return parts[0].trim(); } return line; }); }
typescriptinterface LineProcessResult { line: string; inMultiLineComment: boolean; } function processLine( line: string, options: CleanOptions, inMultiLineComment: boolean, ): LineProcessResult { if (inMultiLineComment) { return { line: "", inMultiLineComment }; } if (options.spaces) { line = removeTrailingSpaces(line); } if (options.comments) { line = removeSingleLineComment(line, options.comments); } return { line, inMultiLineComment }; }
Also let's create the functions responsible for files read/write:
typescriptfunction readFile(filePath: string): Promise<string> { return new Promise((resolve, reject) => { fs.readFile(filePath, "utf8", (err, data) => { if (err) reject(err); else resolve(data); }); }); }
typescriptfunction writeFile(filePath: string, content: string): Promise<void> { return new Promise((resolve, reject) => { const { name, ext } = path.parse(filePath); const outputPath = `${name}_cleaned${ext}`; fs.writeFile(outputPath, content, "utf8", (err) => { if (err) reject(err); else { console.log(`Cleaned file saved as: ${outputPath}`); resolve(); } }); }); }
Now let's create the function that puts all the pieces of the puzzle together:
typescriptasync function cleanCode(options: CleanOptions): Promise<void> { try { let content = await readFile(options.file); let lines = content.split("\n"); if (options.multiStart && options.multiEnd) { lines = handleMultiLineComments( lines, options.multiStart, options.multiEnd, ); } let inMultiLineComment = false; lines = lines.map((line) => { const result = processLine(line, options, inMultiLineComment); inMultiLineComment = result.inMultiLineComment; return result.line; }); const cleanedContent = lines.join("\n"); await writeFile(options.file, cleanedContent); } catch (error) { console.error( "Error:", error instanceof Error ? error.message : String(error), ); process.exit(1); } }
Now just call the functions or wrap it in a
main
function for easier readability:typescriptasync function main() { const options = parseArguments(args); // you can also past process.argv but you need to change the loop to start from 2 await cleanCode(options); } main();
-
-
Create CLI Tools Using Commander
Commander is a JS library for argument parsing which means it can replace
parseArguments
functionFirst let's install the package:
bashnpm i commander
Then let's use it to parse the arguments like this:
typescriptimport { Command } from "commander"; const program = new Command(); program .requiredOption("-f, --file <file>", "File path to clean") .option("--spaces", "Remove trailing spaces", false) .option("--comments <comments>", "Single line comment syntax") .option("--multi-start <multiStart>", "Start of multi-line comment") .option("--multi-end <multiEnd>", "End of multi-line comment"); program.parse(process.argv);
Also, We need to change the
main
function:typescriptasync function main() { const options: CleanOptions = program.opts(); await cleanCode(options); } main();
Note:
You can do a lot more with this library check this GitHub repo.
Conclusion
Congratulations! You've built a fully functioning CLI tool with Node.js and TypeScript, complete with commands, options, error handling, and a polished user experience. This tool can now be used locally, shared with your team, or published for the world to use.
By following this guide, you've learned not just how to build a CLI tool, but also how to structure a Node.js project, handle command-line arguments, validate input, and enhance user experience. This foundation can be the starting point for more complex and powerful CLI tools.
Happy Hacking!