Building an End-to-End CLI with Bun, Clack, and Chalk

Neil BrandNeil Brand
14 min read

Command-line tools are essential for developer productivity. But what if you could build your own CLI—one that's fast, modern, and user-friendly, using the latest JavaScript tools? In this comprehensive guide, I'll walk you through creating a fully interactive CLI with Bun, Clack, and Chalk. I'll cover everything from project setup to global installation, offering practical insights and tips along the way. Let's dive in!

Why Bun, Clack, and Chalk?

Choosing the right tools is crucial for any development project. Here's why this trio stands out for building command-line interfaces:

Bun: This new JavaScript runtime offers exceptional speed and an all-in-one solution, functioning as a runtime, package manager, and more. Its performance benefits can significantly accelerate your development workflow, making it an ideal choice for modern CLI applications.

Clack: Designed to enhance user experience, Clack provides beautiful and intuitive prompts, making your CLI feel like a modern application. It simplifies the process of creating interactive command-line interfaces, ensuring a smooth and engaging experience for your users.

Chalk: Adding color to your terminal output can significantly improve readability and user engagement, and Chalk makes this process straightforward. Beyond basic coloring, Chalk offers a rich API for styling text, allowing you to create visually appealing and informative CLI outputs.

Prerequisites

Before we begin, please ensure you have Bun installed. If you don't, you can install it by running the following command in your terminal:

On Unix/Linux/macOS:

curl -fsSL https://bun.sh/install | bash

On Windows:

powershell -c "irm bun.sh/install.ps1 | iex"

This command will download and execute the Bun installation script, setting up the runtime on your system.

Project Setup (Getting Started with Bun)

Let's initiate our project with Bun. Open your terminal, cd into a directory for your project, and execute the following command:

bun init

This command sets up a new Bun project, providing you with a package.json file and an index.ts file, ready for development. The package.json file will manage your project's metadata and dependencies, while index.ts will serve as the main entry point for your CLI application.

Next, add the necessary dependencies for our CLI, @clack/prompts and chalk, by running:

bun add @clack/prompts chalk

This command will efficiently install these packages and update your package.json file accordingly.

Building Your CLI: Interactive, Colorful, and Engaging

Now, let's open index.ts and start building our interactive CLI. Below is a comprehensive example with explanations to guide you through the process. This code demonstrates how to use Clack for various interactive prompts and Chalk for colorful output, creating a dynamic and user-friendly command-line experience.

#!/usr/bin/env bun

import { text, outro, cancel, select, multiselect, confirm, spinner, isCancel } from '@clack/prompts';
import { setTimeout } from 'node:timers/promises';
import color from 'chalk';

async function main() {
  console.log(color.magenta('Welcome to the Bun, Clack, and Chalk CLI!'));

  const projectName = await text({
    message: 'What is your project name?',
    placeholder: 'my-cli-app',
    defaultValue: 'my-cli-app',
    validate: (value) => {
      if (value.length === 0) return `Project name is required!`;
    },
  });
  if (isCancel(projectName)) return cancelAndExit();

  const framework = await select({
    message: 'Select a framework.',
    options: [
      { label: 'React', value: 'react' },
      { label: 'Vue', value: 'vue' },
      { label: 'Angular', value: 'angular' },
    ],
  });
  if (isCancel(framework)) return cancelAndExit();

  const features = await multiselect({
    message: 'Select additional features.',
    options: [
      { label: 'TypeScript', value: 'typescript' },
      { label: 'ESLint', value: 'eslint' },
      { label: 'Prettier', value: 'prettier' },
    ],
  });
  if (isCancel(features)) return cancelAndExit();

  const confirmInstall = await confirm({
    message: `Ready to install ${projectName} with ${framework} and ${features.join(', ')}?`,
  });
  if (isCancel(confirmInstall)) return cancelAndExit();

  if (confirmInstall) {
    const s = spinner();
    s.start('Installing...');
    await setTimeout(3000); // Simulate installation time
    s.stop('Installation complete!');
  }

  let message = `Successfully created ${color.green(projectName)} with ${color.cyan(framework)}`;
  if (features.length > 0) {
    message += ` and features: ${color.yellow(features.join(', '))}`;
  }
  outro(message);
}

const cancelAndExit = () => {
  cancel('Operation cancelled.');
  process.exit(0);
};

main().catch(console.error);

Here's a breakdown of what this code accomplishes:

  • Welcome Message: I start by displaying a welcome message in magenta, adding a touch of color to the user's terminal. This initial greeting sets a friendly tone for the CLI interaction.

  • Interactive Prompts: The CLI then prompts the user for a project name, framework, and additional features. Each prompt includes validation and support for cancellation, ensuring a robust user experience. This interactive approach allows users to customize their project setup directly from the command line.

  • Progress Feedback: To simulate a real-world scenario, I include a spinner that indicates an ongoing installation process, providing visual feedback to the user. This is crucial for long-running operations, as it prevents the user from thinking the application has frozen.

  • Success Message: Finally, the CLI concludes with a clear and colorful success message, summarizing the created project. This positive reinforcement enhances the user's overall experience.

Exploring Clack's Capabilities with More Examples

Let's dive deeper into Clack's features with some focused examples. Clack offers a variety of prompt types that can be combined to create sophisticated and user-friendly command-line interfaces. Understanding these examples will help you leverage Clack's full potential.

Text Input with Validation

This example demonstrates how to use text input with custom validation rules. This is useful for ensuring that user input meets specific criteria, such as a valid email format or a minimum length.

const email = await text({
  message: "Enter your email address:",
  placeholder: "user@example.com",
  validate: (value) => {
    if (!value.includes('@')) return "Please enter a valid email address";
    if (value.length < 5) return "Email is too short";
  },
});
if (isCancel(email)) return cancelAndExit();

Single Select with Groups

Clack's select prompt allows users to choose a single option from a list. You can also organize options into groups for better readability, as shown in this example:

const template = await select({
  message: "Choose a template:",
  options: [
    { value: 'react', label: 'React', hint: 'with TypeScript' },
    { value: 'vue', label: 'Vue.js', hint: 'v3 composition API' },
    { value: 'node', label: 'Node.js', hint: 'with Express' },
  ],
});
if (isCancel(template)) return cancelAndExit();

Multi-Select with Conditional Logic

The multiselect prompt enables users to select multiple options. This example also demonstrates conditional logic, where additional prompts appear based on previous selections, providing a dynamic and responsive user experience.

const addons = await multiselect({
  message: "Select additional features:",
  options: [
    { value: 'eslint', label: 'ESLint' },
    { value: 'prettier', label: 'Prettier' },
    { value: 'husky', label: 'Git Hooks', hint: 'recommended' },
  ],
});
if (isCancel(addons)) return cancelAndExit();

if (addons.includes('husky')) {
  const hooks = await multiselect({
    message: "Select Git hooks to install:",
    required: false,
    options: [
      { value: 'pre-commit', label: 'Pre-commit' },
      { value: 'pre-push', label: 'Pre-push' },
    ]
  });
  if (isCancel(hooks)) return cancelAndExit();
}

Advanced Input Handling (Grouped Prompts)

For collecting related pieces of information, Clack's group function is incredibly useful. It allows you to define a series of prompts that are presented together, streamlining the input process for the user. This is particularly effective for forms or configurations requiring multiple fields.

import { group, text, cancel, isCancel } from '@clack/prompts';

const credentials = await group(
  {
    username: () =>
      text({
        message: "Username:",
        validate: (v) => (v.length > 3 ? undefined : "Minimum 4 characters"),
      }),
    password: () =>
      text({
        message: "Password:",
        validate: (v) => (v.length > 6 ? undefined : "Minimum 7 characters"),
      }),
  },
  {
    onCancel: () => {
      cancel("Operation cancelled.");
      process.exit(0);
    },
  }
);

Progress Indicators

Providing feedback during long-running operations is essential for a good user experience. Clack's spinner component allows you to display progress indicators, keeping users informed about the status of a task. You can update the spinner's message to reflect different stages of the process.

import { spinner } from '@clack/prompts';
import { setTimeout } from 'node:timers/promises';

const installSpinner = spinner();
installSpinner.start('Installing dependencies...');

// Simulate installation process
await setTimeout(2000);
installSpinner.message('Configuring project...');
await setTimeout(1000);
installSpinner.stop('Installation complete!');

These examples demonstrate:

  • Input validation with custom error messages, ensuring data integrity.

  • Hint text for options, providing additional context to users.

  • Conditional prompts based on previous selections, creating dynamic workflows.

  • Grouped inputs for related information, improving user flow.

  • Progress feedback with spinner updates, enhancing user experience during operations.

  • Proper cancellation handling, allowing users to gracefully exit prompts.

Making Your CLI Globally Accessible

To make your CLI available from any directory in your terminal, follow these steps. This process involves configuring your package.json and making your main script executable, allowing you to invoke your CLI application with a simple command.

Add a bin field to package.json

Modify your package.json file to include a bin field, which specifies the entry point for your CLI. Replace my-app with your desired command name. This entry tells Bun (and npm, if you were to publish) where to find the executable script for your CLI.

{
  "name": "my-cli-app",
  "module": "index.ts",
  "type": "module",
  "scripts": {
    "start": "bun run index.ts"
  },
  "bin": {
    "my-app": "./index.ts"
  }
}

Add a shebang and make your entrypoint executable

First, add a shebang line to the top of your index.ts file to tell the system how to execute it:

#!/usr/bin/env bun

Then, ensure your index.ts file is executable by running the following command in your terminal. This grants the necessary permissions for the script to be run directly.

On Unix/Linux/macOS:

chmod +x index.ts

On Windows:

Windows doesn't require setting executable permissions like Unix systems. The shebang line and file association are sufficient.
Note: I have seen some issues with Windows and Bun with Clack. If you have problems with Clack throwing errors on Windows, switch to Node.

Finally, link your CLI globally using Bun. This command creates a symbolic link from your project's executable to a global location, making it accessible from any directory.

bun link

After these steps, you can run my-app (or your chosen command name) from any terminal window. If it doesn't work immediately, try opening a new terminal session to refresh your shell's path.

Test Drive Your CLI

Now, open your terminal and run your newly created CLI:

my-app

You'll experience a smooth and interactive command-line interface, designed for efficiency and ease of use. Interact with the prompts, test the validation, and observe the colorful output.

Unlinking Your CLI

If you need to remove your CLI from global commands, simply run:

bun unlink

This command will remove the symbolic link created by bun link, making your CLI no longer globally accessible.

Pro Tips & Next Steps

  • Expand Your CLI: Consider adding more prompts, commands, or even subcommands to enhance your CLI's functionality. Think about common tasks in your workflow that could be automated or simplified with a custom command-line tool.

  • Style It Up: Explore Chalk's extensive styling options, including bold, underline, and background colors, to make your CLI visually appealing. Effective use of color can significantly improve the user experience and make your CLI more intuitive.

  • Ship It: For broader accessibility, package and publish your CLI to npm, allowing others to easily install and use it. Sharing your tools can contribute to the developer community and establish your expertise.

  • Bun's Performance: Leverage Bun's impressive speed for near-instant startup and installation times, optimizing your development workflow. This performance advantage is a key benefit of building with Bun, especially for larger projects.

Conclusion

You've successfully built a modern, interactive CLI from the ground up! By combining the power of Bun, Clack, and Chalk, you can create command-line tools that are not only functional but also a pleasure to use. I encourage you to continue experimenting, building, and showcasing your creativity in the terminal.

Ready to elevate your CLI development skills? Let's connect and share ideas!

Taking Your CLI to the Next Level

Now that you have a working CLI, let's explore the professional considerations that separate hobby projects from production-ready tools. The following sections cover advanced concepts that become crucial as your CLI grows in complexity and user base.

Advanced CLI Concepts and Best Practices

Beyond the basics, building robust and maintainable CLIs involves several advanced concepts and best practices. Adhering to these principles will ensure your CLI applications are not only functional but also scalable, secure, and user-friendly in the long run.

Error Handling and Robustness

Effective error handling is paramount for any production-ready CLI. Instead of simply crashing, your CLI should gracefully handle unexpected inputs, network issues, or file system errors. Implement try-catch blocks around I/O operations and API calls. Provide clear, actionable error messages to the user, guiding them on how to resolve the issue. Consider logging errors to a file for debugging purposes, especially in non-interactive modes, or use a tool like Sentry.

Command Structure and Subcommands

As your CLI grows in complexity, a flat command structure can become unwieldy. Implementing subcommands, similar to git commit or npm install, can significantly improve usability and organization. Each subcommand can have its own set of options and arguments, making the CLI more modular and easier to navigate.

With Clack, you can create subcommands by parsing process.argv and creating different prompt flows based on the command. For example, you could structure your CLI to support commands like my-app init, my-app build, and my-app deploy, where each command presents its own specific Clack prompts and functionality. This allows you to maintain the beautiful interactive experience Clack provides while organizing your CLI into logical command groups.

Configuration Management

For CLIs that require persistent settings, robust configuration management is essential. Avoid hardcoding values; instead, allow users to configure settings via command-line flags, environment variables, or configuration files (e.g., .json, .yaml, .toml). Prioritize configuration sources, allowing command-line flags to override environment variables, which in turn override file-based settings. This flexibility empowers users to tailor the CLI to their specific needs.

Testing Your CLI

Thorough testing is crucial for ensuring the reliability of your CLI. This includes unit tests for individual functions, integration tests for command execution, and end-to-end tests that simulate real-world user interactions. Tools like jest or vitest (compatible with Bun) can be used for unit and integration testing. For end-to-end testing, consider libraries that allow you to programmatically execute your CLI and assert its output and behavior.

Security Considerations

When building CLIs, especially those that interact with sensitive data or external systems, security must be a top priority. Be mindful of how you handle user input, avoiding common vulnerabilities like command injection. Securely manage API keys and credentials, never hardcoding them directly in your code. Encourage the use of environment variables or secure configuration stores for sensitive information. If your CLI processes user files, ensure proper sanitization and validation to prevent malicious inputs.

Performance Optimization

While Bun provides a strong performance foundation, optimizing your CLI further can significantly enhance the user experience. Profile your CLI to identify performance bottlenecks, particularly in long-running operations or data processing tasks. Consider asynchronous operations where appropriate to prevent blocking the main thread. For very resource-intensive tasks, explore options like worker threads or offloading computation to external services.

Documentation and Help Systems

A well-documented CLI is a user-friendly CLI. Provide clear and concise documentation for all commands, options, and arguments. Implement a built-in help system (e.g., my-app --help or my-app --help) that automatically generates usage information. This allows users to quickly understand how to use your CLI without needing to consult external documentation.

Distribution and Packaging

Beyond global linking, consider how you will distribute your CLI to a wider audience. Packaging your CLI as a standalone executable (e.g., using pkg or nexe for Node.js, or Bun's upcoming native executable support) can simplify distribution, as users won't need to have Bun or Node.js installed. For JavaScript-based CLIs, publishing to npm is a common and effective distribution method, making your tool easily discoverable and installable by the community.

Integrating with External APIs and Services

Many powerful CLIs extend their functionality by interacting with external APIs and services. This allows your CLI to fetch data, trigger actions, or leverage cloud-based resources. When integrating with external services, consider the following aspects:

Authentication and Authorization

Securely authenticating your CLI with external services is paramount. Common methods include API keys, OAuth 2.0, or token-based authentication. Implement robust mechanisms to store and retrieve credentials, such as environment variables or secure configuration files, rather than embedding them directly in your code. Ensure your CLI requests only the necessary permissions (least privilege) from the external service.

Rate Limiting and Error Handling for APIs

External APIs often impose rate limits to prevent abuse. Your CLI should be designed to respect these limits, implementing strategies like exponential backoff and retry mechanisms for failed requests. Handle various API response codes gracefully, providing informative messages to the user for errors like 401 Unauthorized, 403 Forbidden, or 429 Too Many Requests.

Data Transformation and Presentation

When consuming data from external APIs, you'll often need to transform it into a format suitable for your CLI's output. This might involve parsing JSON or XML responses, filtering relevant data, and restructuring it for clear presentation in the terminal. Utilize libraries that simplify data manipulation and ensure your output is consistent and easy to read, perhaps using Chalk for highlighting key information.

Asynchronous Operations and Concurrency

API calls are typically asynchronous operations. Leverage JavaScript's async/await syntax to manage these operations effectively, preventing your CLI from blocking while waiting for responses. For scenarios involving multiple concurrent API calls, consider using Promise.all or similar constructs to improve efficiency without overwhelming the external service.

Webhooks and Real-time Updates

For scenarios requiring real-time updates or event-driven interactions, consider integrating webhooks. Your CLI could expose a local endpoint (with appropriate security measures) that external services can call to notify your application of events. This enables your CLI to react to changes without constantly polling the API, leading to more efficient and responsive applications.


Ready to build amazing CLI tools? Start experimenting with Bun, Clack, and Chalk today! 🚀

0
Subscribe to my newsletter

Read articles from Neil Brand directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Neil Brand
Neil Brand