Building a CLI Tool in JavaScript: Streamlining Tasks With Simple Commands

Updated
featured-image.png

đź’ˇ I included the link to the example repository at the conclusion of this tutorial.

JavaScript is not only great for web development, but it’s also an excellent language for building powerful command-line tools with Node.js.

Why Build a CLI Tool in JavaScript?

Creating a CLI tool in JavaScript can be a game-changer for your productivity because:

âś… Familiarity: As a JavaScript developer, you already know the language, which makes it easy to get started.
âś… Cross-platform: With Node.js, your tool will run on all major platforms, including Windows, macOS, and Linux.
âś… Quick and Simple: Building a CLI tool for common workflows can save you time by streamlining everyday tasks like Git operations, file management, or project setup.

In software development, we often find ourselves repeating tasks. These tasks might range from managing Git operations to system configurations or deployment processes. Wouldn’t it be great if there was a way to streamline these tasks, making them more efficient and less error-prone? This is where CLI tools come into play.

In this article, you will learn how to build a simple CLI tool in JavaScript that can help simplify and streamline your development workflows, particularly when it comes to automating basic but repetitive tasks like Git operations.

Creating a Simple CLI Tool in JavaScript

Let’s dive into building a basic CLI tool that performs Git operations. For this, we’ll use Commander.js, Inquirer.js, and Simple Git.

Step 1: Setting Up the Project

Start by initializing a new Node.js project:

mkdir git-wizard
cd git-wizard
npm init -y

Then, install the necessary dependencies:

npm install commander inquirer chalk simple-git

Here is what we are installing:

âś… commander: Helps you define and manage your CLI commands and arguments.
âś… inquirer: Provides an interactive way to ask users for input during the execution of the tool.
âś… chalk: Adds color to terminal output, making your CLI tool more user-friendly.
âś… simple-git: Allows you to interact with Git repositories programmatically.

Step 2: Setting Up Basic CLI Commands

In this step, we are going to create the core functionality for git-wizard. We’ll define essential commands to streamline common Git tasks, such as initializing a repository, creating a new feature branch, committing with a message, and pushing your changes. These commands will be accessible via the terminal, making it easy for users to automate and simplify their Git workflows.

Create a new file at src/index.js and add the following code:

#!/usr/bin/env node
import { program } from 'commander';
import init from './commands/init.js';
import newFeature from './commands/new-feature.js';
import commit from './commands/commit.js';
import push from './commands/push.js';

program.name('git-wizard').description('🛠️ A CLI tool to automate Git workflows').version('1.0.0');

program.command('init').description('🧹 Initialize a new Git repository and set remote URL').action(init);
program.command('new-feature').description('🌱 Create and switch to a new feature branch').action(newFeature);
program.command('commit').description('✍️ Create a conventional commit using interactive prompts').action(commit);
program.command('push').description('🚀 Push the current branch and open a pull request URL').action(push);

program.parse(process.argv);

We are using the commander library to define the CLI commands for our tool. The first line #!/usr/bin/env node ensures the script runs as an executable from the command line.

We import the necessary functions (init, newFeature, commit, push) from their respective command files located in the commands directory.

We set up the CLI using program.name('git-wizard') to define the name of our tool and program.description('...') to provide a short description. We also specify the version with .version('1.0.0').

Each command is defined using program.command(). For instance:

  • The init command initializes a Git repository and sets the remote URL, handled by the init function.
  • The new-feature command creates and switches to a new feature branch, managed by the newFeature function.
  • The commit command lets the user create a conventional commit through interactive prompts, executed by the commit function.
  • The push command pushes the current branch and generates a pull request URL, controlled by the push function.

Finally, program.parse(process.argv) processes the command-line arguments, triggering the appropriate command based on the user’s input.

Why ESM Instead of CommonJS?

We’re using ES Modules (ESM) in this project for a few key reasons:

  • Modern Syntax: ESM is the standard for writing modular JavaScript in both browser and Node environments.
  • Compatibility: Some modern libraries, like the latest version of chalk, are now ESM-only and cannot be imported using require(). The alternative is to use v4.
  • Future-proofing: Node.js has fully supported ESM since v14+.

If you’re using ESM in Node.js, make sure your package.json includes:

"type": "module"

This tells Node to treat .js files as ES modules instead of CommonJS.

Step 3: Building the commit Command

In this step, we’ll implement the commit command in our CLI tool. This command helps reduce manual effort for one of the most repetitive tasks: creating clear and consistent Git commit messages.

Start by creating the file: src/commands/commit.js

Paste in the following code:

import inquirer from 'inquirer';
import simpleGit from 'simple-git';
import chalk from 'chalk';

const git = simpleGit();

export default async function commit() {
  try {
    const answers = await inquirer.prompt([
      {
        type: 'list',
        name: 'type',
        message: 'Type:',
        choices: ['feat', 'fix', 'chore', 'docs', 'style', 'refactor', 'test'],
      },
      {
        type: 'input',
        name: 'scope',
        message: 'Scope (optional):',
      },
      {
        type: 'input',
        name: 'message',
        message: 'Message:',
        validate: input => input ? true : 'Commit message cannot be empty',
      },
    ]);

    const { type, scope, message } = answers;

    const commitMsg = scope.trim() 
      ? `${type}(${scope.trim()}): ${message.trim()}`
      : `${type}: ${message.trim()}`;

    await git.add('.');
    await git.commit(commitMsg);

    console.log(chalk.green(`âś… Committed: ${commitMsg}`));
  } catch (error) {
    console.error(chalk.red('Error during commit:'), error);
  }
}

We are using inquirer to prompt the user for information needed to construct a conventional commit message. The user selects a commit type (like feat, fix, or docs), optionally enters a scope, and must provide a commit message.

We destructure the user’s input and build the commit message. If the user provides a scope, the message will be in the format type(scope): message; otherwise, it’s just type: message.

We use simpleGit to stage all changes with git.add('.') and then commit them with the generated message using git.commit().

We use chalk to show a green success message with the final commit text. If something goes wrong, the error is caught and shown in red for clarity.

This command enforces conventional commit standards without requiring you to memorize the format. It’s also great for teams who want consistent history across pull requests, changelogs, or releases.

Here’s an example output:

? Type: (Use arrow keys)
❯ feat
  fix
  chore
  docs
  ...
? Scope (optional): auth
? Message: add JWT-based login
âś… Committed: feat(auth): add JWT-based login

In the next step, we’ll build the init command to bootstrap a Git repository and set a remote URL. Let’s keep going.

Step 4: Setting Up the init Command

Let’s automate the typical steps you take when starting a new Git project:

  • Initialize the repo
  • Add a remote
  • Create a .gitignore file

With the init command, we’ll guide the user through this process interactively.

Create a file called src/commands/init.js, and add this code:

import simpleGit from 'simple-git';
import chalk from 'chalk';
import path from 'path';
import inquirer from 'inquirer';
import fs from 'fs';

const git = simpleGit();

export default async function init() {
  try {
    // Initialize Git repository
    await git.init();
    console.log(chalk.green('âś… Initialized repo'));

    // Check if remote "origin" already exists
    const remotes = await git.getRemotes(true);
    const originRemote = remotes.find(remote => remote.name === 'origin');

    if (!originRemote) {
      // Get the project folder name for default remote URL
      const projectName = path.basename(process.cwd());

      // Prompt for GitHub username
      const { username } = await inquirer.prompt([
        {
          type: 'input',
          name: 'username',
          message: 'Enter your GitHub username:',
          validate: input => input ? true : 'Username cannot be empty',
        },
      ]);

      const defaultRemoteUrl = `git@github.com:${username}/${projectName}.git`;

      // Prompt user for the remote URL
      const { remoteUrl } = await inquirer.prompt([
        {
          type: 'input',
          name: 'remoteUrl',
          message: 'Enter remote repository URL:',
          default: defaultRemoteUrl,
        },
      ]);

      await git.addRemote('origin', remoteUrl);
      console.log(chalk.green(`âś… Added origin: ${remoteUrl}`));
    } else {
      console.log(chalk.yellow('ℹ️ Remote "origin" already exists.'));
    }

    // Check if a .gitignore file already exists
    const gitignorePath = path.join(process.cwd(), '.gitignore');
    if (!fs.existsSync(gitignorePath)) {
      // Prompt to create a default .gitignore file
      const { createGitignore } = await inquirer.prompt([
        {
          type: 'confirm',
          name: 'createGitignore',
          message: 'Create a default .gitignore file?',
          default: true,
        },
      ]);

      if (createGitignore) {
        const gitignoreContent = `node_modules
.DS_Store
.env
`;
        fs.writeFileSync(gitignorePath, gitignoreContent);
        console.log(chalk.green('âś… .gitignore created.'));
      }
    } else {
      console.log(chalk.yellow('ℹ️  .gitignore already exists.'));
    }
  } catch (error) {
    console.error(chalk.red('Error during init:'), error);
  }
}

We are using simpleGit to initialize a new Git repository with git.init().

We check if a remote named origin already exists. If not, we prompt the user for their GitHub username and use it to suggest a default remote URL like git@github.com:username/project-folder.git.

We are using inquirer to let the user confirm or change this remote URL, and then we add it with git.addRemote.

We check if a .gitignore file exists. If it doesn’t, we prompt the user to create one, and if they agree, we write a basic .gitignore with common entries like node_modules and .env.

We are using chalk for colored console output, and any errors are caught and printed in red for clarity.

Here’s how it looks when running git-wizard init:

âś… Initialized repo
? Enter your GitHub username: johndoe
? Enter remote repository URL: git@github.com:johndoe/my-project.git
âś… Added origin: git@github.com:johndoe/my-project.git
? Create a default .gitignore file? Yes
âś… .gitignore created.

If the remote or .gitignore already exists:

ℹ️ Remote "origin" already exists.
ℹ️ .gitignore already exists.

Step 5: Creating a New Feature Branch

In src/commands/new-feature.js, we’ll create a command that helps users generate a new branch for feature development using a naming convention.

import inquirer from 'inquirer';
import simpleGit from 'simple-git';
import chalk from 'chalk';

const git = simpleGit();

export default async function newFeature() {
  try {
    const { featureName } = await inquirer.prompt([
      {
        type: 'input',
        name: 'featureName',
        message: 'Feature name:',
        validate: input => input ? true : 'Feature name cannot be empty',
      },
    ]);

    // Construct branch name
    const branchName = `feature/${featureName.trim()}`;
    
    // Create and checkout the new branch
    await git.checkoutLocalBranch(branchName);
    console.log(chalk.green(`âś… Switched to ${branchName}`));
  } catch (error) {
    console.error(chalk.red('Error during new-feature:'), error);
  }
}

We are using inquirer again to interactively ask the user for a feature name.

After getting the input, we generate the branch name by prepending feature/ to the user’s input. This is a common Git convention to keep branches organized by type (e.g., feature/login-page).

We are using simpleGit to both create and switch to the new branch using checkoutLocalBranch(), making it a one-step process.

Lastly, we show a green confirmation message using chalk, or display a red error if anything goes wrong.

Step 6: Pushing the Current Branch and Generating a Pull Request URL

In src/commands/push.js, we’ll create a command to push the current branch to the remote origin and then generate a link to open a pull request on GitHub.

import simpleGit from 'simple-git';
import chalk from 'chalk';

const git = simpleGit();

export default async function push() {
  try {
    // Get the current branch name
    const status = await git.status();
    const branch = status.current;

    // Push current branch to remote 'origin'
    await git.push('origin', branch);
    console.log(chalk.green(`âś… Pushed to origin/${branch}`));

    // Get remote URL from Git configuration
    const remotes = await git.getRemotes(true);
    const origin = remotes.find(r => r.name === 'origin');
    if (!origin) {
      console.error(chalk.red('Error: No remote named "origin" found.'));
      return;
    }

    // Extract the fetch URL (e.g., git@github.com:username/repo.git)
    const remoteUrl = origin.refs.fetch;
    // Parse the username and repository name from the URL using regex
    const match = remoteUrl.match(/git@github\.com:(.+?)\/(.+?)\.git/);
    if (!match) {
      console.error(chalk.red('Error: Remote URL is not in the expected format.'));
      return;
    }

    const username = match[1];
    const repo = match[2];
    // Construct a pull request URL (placeholder)
    const prUrl = `https://github.com/${username}/${repo}/compare/${branch}?expand=1`;

    console.log(chalk.blue(`đź”— Open PR: ${prUrl}`));
  } catch (error) {
    console.error(chalk.red('Error during push:'), error);
  }
}

We are using simpleGit to get the current branch with git.status(). This ensures that we push the correct branch to the remote repository.

The branch is pushed to the origin remote using git.push('origin', branch), and we notify the user with a green success message using chalk.

Next, we fetch the remotes with git.getRemotes() and extract the remote URL for origin. We check if the URL format matches the expected pattern (for GitHub), and if it does, we parse out the username and repository name using a regular expression.

Finally, we construct a pull request URL in the format https://github.com/username/repo/compare/branch, which the user can open in their browser to create a pull request. The URL is printed in blue for clarity.

Now that all the commands are working, let’s wrap things up by adjusting the project metadata.

Step 7: Update the Project Metadata

In this step, we’ll configure the essential information inside package.json, including the CLI entry points.

Update your package.json with the following settings:

{
  "name": "@your-username/git-wizard",
  "version": "1.0.0",
  "description": "A CLI tool to automate Git workflows",
  "main": "src/index.js",
  "keywords": [
    "git",
    "workflows",
    "automation"
  ],
  "author": "fajarwz",
  "license": "ISC",
  "type": "module",
  "bin": {
    "git-wizard": "./src/index.js",
    "gw": "./src/index.js"
  },
  "dependencies": {
    //...
  }
}

We’re setting the project’s name, version, and a short description to make it easy to understand what the tool does. The main field points to our entry file, which is src/index.js.

The keywords array helps improve the discoverability of the package if published on npm. The author field is set to your name or handle, and we’re using the “ISC” license for simplicity.

We set the type to module so we can use modern JavaScript syntax like import and export.

Finally, the bin field is where we define the command-line entry points. By mapping both git-wizard and gw to src/index.js, users will be able to run the CLI using either command in their terminal.

With our package.json set up, we now have a clear structure for our CLI tool, from metadata to command-line entry points. This setup lays the foundation for a smooth development experience and a convenient way for users to interact with the tool.

Publishing to npm

Once your CLI tool is ready, you can publish it to npm so others (and future you) can install and use it globally.

Step 1: Sign in or create an npm account

If you don’t have an npm account yet, create one with:

npm adduser

Or log in with:

npm login

Step 2: Name your package

Make sure your name in package.json is unique across npm. You can check availability by searching on npmjs.com.

If you want to scope it to your username or organization, use:

"name": "@your-username/git-wizard"

Step 3: Mark it as executable

We already added it, but just make sure to add a Shebang at the very top of your src/index.js file so it can be executed from the command line:

#!/usr/bin/env node

Step 4: Publish

If it’s your first time publishing:

npm publish --access public

For scoped packages (@your-username/package-name), you must use --access public.

From then on, you can publish new versions with:

npm version patch    # or minor/major
npm publish

Step 5: Install and use globally

Once published, you (or others) can install it globally with:

npm install -g @your-username/git-wizard

Now you can run it anywhere using git-wizard or gw.

Conclusion

Building a CLI tool in JavaScript can dramatically improve your development workflow, especially when working with repetitive tasks. By using commander.js, inquirer.js, and simple-git, you can create a tool that streamlines tasks like initializing Git repositories, committing changes, and pushing to remote repositories with just a few simple commands.

One of the biggest advantages of using JavaScript (via Node.js) for CLI development is accessibility. If you’re already comfortable with JavaScript for web development, there’s no need to learn another language just to build automation tools. The Node.js ecosystem is rich, well-documented, and packed with powerful libraries that make scripting fast and intuitive.

Instead of juggling multiple terminal commands every time, a CLI tool helps reduce context switching and ensures your workflow stays fast, consistent, and error-free.

So if you often repeat the same steps across projects, try building your own CLI tool. It’s a surprisingly simple way to boost your productivity and bring a bit of joy back into your development flow.

đź’» The repository for this example can be found at fajarwz/git-wizard.

Fajarwz's photo Fajar Windhu Zulfikar

I'm a full-stack web developer who loves to share my software engineering journey and build software solutions to help businesses succeed.

Email me
Ads
  • Full-Stack Laravel: Forum Web App (Complete Guide 2024)
  • Flexible and powerful review system for Laravel, let any model review and be reviewed.

Share

Subscribe

Sign up for my email newsletter and never miss a beat in the world of web development. Stay up-to-date on the latest trends, techniques, and tools. Don't miss out on valuable insights. Subscribe now!

Comments

comments powered by Disqus