Engineering Note
Engineering

Building Developer Tools With CLI

Treating the Terminal as a Product Interface

9 min read
AdvancedEngineering

Introduction

A CLI is not just a wrapper around commands. It is a developer-facing user interface that helps engineers operate complex systems with speed, clarity, and control.

Good developer tools reduce repeated manual work, make infrastructure easier to understand, and turn complex workflows into simple commands that can be reused safely.

A strong CLI improves developer experience because it hides unnecessary operational complexity while still exposing enough control for debugging, automation, and system management.

This note focuses on practical engineering decisions behind building developer tools with a CLI, especially the parts that affect reliability, maintainability, developer experience, operational confidence, and safe infrastructure control.

The Problem

Complex systems often require repeated terminal commands for starting services, running tests, streaming logs, creating incidents, triggering chaos tests, checking status, or managing infrastructure. Without a clear CLI, developers depend on memory, documentation, or copied commands.

Common Failures

  • Complex infrastructure requires repeated manual commands
  • Raw terminal output is hard to understand during debugging
  • Error messages often lack useful next steps
  • Developers need fast feedback while operating systems
  • Risky commands can run without validation or confirmation
  • Different developers run slightly different workflows manually

Engineering Impact

  • Developers waste time remembering commands
  • Operational workflows become inconsistent
  • Debugging becomes slower when output is noisy
  • Risky commands can be executed without validation
  • New contributors take longer to understand the system
  • Infrastructure tasks become harder to automate in CI or scripts

The challenge is to design a CLI that feels simple for developers while safely controlling complex infrastructure and system workflows underneath.

System Design / Approach

The approach is to design commands around developer intent, not internal implementation details. A good CLI should guide users toward the correct action and provide clear feedback when something fails.

Developer Intent
    ↓
CLI Command
    ↓
Input Validation
    ↓
Workflow Executor
    ↓
System Command / API / Event Stream
    ↓
Readable Output
    ↓
Error Handling and Next Steps

1. Design Commands Around Workflows

Commands should map to real tasks such as starting a cluster, triggering chaos tests, streaming events, checking status, running diagnostics, or managing services.

2. Make Output Clear and Useful

Terminal output should show readable status messages, success states, failures, warnings, and suggested next steps instead of raw noise.

3. Validate Before Running Risky Commands

A CLI should reject invalid inputs before touching infrastructure, containers, services, databases, or production-like systems.

4. Keep Commands Scriptable

Commands should work for humans and automation. Flags, exit codes, JSON output, and non-interactive modes make the tool useful in CI and scripts.

Implementation

Step 1: Design Clear Commands

Commands should map directly to developer workflows. Good naming reduces memory load and makes the tool easier to use without constantly checking documentation.

commands.sh
aegis cluster up
aegis cluster status
aegis cluster down

aegis chaos oom
aegis chaos timeout
aegis chaos port

aegis stream
aegis doctor

Clear command naming helps developers understand what the tool does before they even run it.

Step 2: Structure the CLI Entry Point

The CLI should have a clean entry point that registers commands, descriptions, options, and help output. This makes the tool easier to expand as workflows grow.

cli.ts
import { Command } from "commander";

const program = new Command();

program
  .name("aegis")
  .description("Developer CLI for operating Project Aegis")
  .version("1.0.0");

program
  .command("cluster")
  .argument("<action>", "up, down, or status")
  .description("Manage the local Aegis infrastructure")
  .action(handleClusterCommand);

program
  .command("chaos")
  .argument("<type>", "oom, timeout, or port")
  .description("Trigger a controlled chaos event")
  .action(handleChaosCommand);

program
  .command("stream")
  .description("Stream telemetry events from Kafka")
  .action(handleStreamCommand);

program.parse();

A clean command registry keeps the CLI organized and easier to maintain as more commands are added.

Step 3: Validate User Input

The CLI should reject invalid or unsafe inputs before executing commands. This prevents accidental misuse and makes errors easier to understand.

validation.ts
const allowedChaosTypes = ["oom", "timeout", "port"];

if (!allowedChaosTypes.includes(type)) {
  throw new Error(
    "Invalid chaos type. Use one of: oom, timeout, port."
  );
}

Validation protects developers from running incorrect commands and helps the CLI fail before the system is affected.

Step 4: Wrap Infrastructure Commands Safely

A CLI can hide long infrastructure commands behind clear workflows. This reduces manual mistakes and makes repeated operations easier to perform.

cluster.ts
import { execa } from "execa";

export async function handleClusterCommand(action: string) {
  if (action === "up") {
    await execa("docker", ["compose", "up", "-d"], {
      stdio: "inherit",
    });

    return;
  }

  if (action === "down") {
    await execa("docker", ["compose", "down"], {
      stdio: "inherit",
    });

    return;
  }

  if (action === "status") {
    await execa("docker", ["ps"], {
      stdio: "inherit",
    });

    return;
  }

  throw new Error("Invalid cluster action. Use one of: up, down, status.");
}

Command wrappers make infrastructure workflows consistent across developers and environments.

Step 5: Add Dry-Run Support

Risky commands should support dry-run mode when possible. This lets developers preview what the CLI will do before it changes system state.

dry-run.ts
async function runCommand(command: string, args: string[], dryRun: boolean) {
  if (dryRun) {
    console.log("[dry-run]", command, args.join(" "));
    return;
  }

  await execa(command, args, {
    stdio: "inherit",
  });
}

Dry-run mode improves safety because developers can inspect command behavior before execution.

Step 6: Show Clear Status Output

A CLI should communicate what is happening in simple language. Useful output helps developers understand progress, success, failure, and next steps.

output.ts
console.log("Starting Aegis cluster...");
console.log("Kafka: running");
console.log("MongoDB: running");
console.log("Redis: running");
console.log("Aegis cluster is ready.");

Clear output reduces debugging friction and makes the system feel easier to operate.

Step 7: Stream Useful Events

Logs, telemetry, and system events should be readable in real time. Streaming output helps developers understand what the system is doing while it runs.

stream.ts
consumer.on("event", (event) => {
  console.log({
    service: event.service,
    type: event.type,
    severity: event.severity,
    timestamp: event.timestamp,
  });
});

Streaming output makes infrastructure easier to observe because developers can see system behavior as it happens.

Step 8: Handle Errors with Next Steps

Error messages should not only say that something failed. They should explain what failed, why it might have failed, and what the developer can try next.

error-handler.ts
try {
  await handleClusterCommand(action);
} catch (error) {
  console.error("Command failed:", error.message);
  console.info("Next steps:");
  console.info("1. Check if Docker is running");
  console.info("2. Run: aegis doctor");
  console.info("3. Check docker-compose.yml configuration");

  process.exit(1);
}

Helpful errors reduce context switching because developers get recovery guidance directly in the terminal.

Step 9: Add a Doctor Command

A doctor command helps developers diagnose local setup problems quickly. It can check Docker, environment variables, ports, service health, and dependency status.

doctor.ts
async function runDoctor() {
  const checks = [
    checkDocker(),
    checkEnvFile(),
    checkPort(9092),
    checkPort(27017),
    checkPort(6379),
  ];

  const results = await Promise.all(checks);

  for (const result of results) {
    console.log(result.name, result.ok ? "ok" : "failed");
  }
}

A doctor command turns setup debugging into a repeatable workflow instead of manual investigation.

Step 10: Support JSON Output for Automation

Human-readable output is useful in the terminal, but automation often needs structured output. JSON mode makes CLI results easier to consume in scripts, dashboards, and CI pipelines.

json-output.ts
function printStatus(status: ClusterStatus, json: boolean) {
  if (json) {
    console.log(JSON.stringify(status, null, 2));
    return;
  }

  console.log(`Kafka: ${status.kafka}`);
  console.log(`MongoDB: ${status.mongodb}`);
  console.log(`Redis: ${status.redis}`);
}

Structured output makes the CLI useful for both developers and automated workflows.

Trade-offs

Approach Benefit Cost
CLI Interface Fast developer control over complex systems and workflows Requires careful command design and strong terminal UX
Interactive Prompts Guided usage for developers who do not remember every flag Slower for power users who prefer direct commands
Streaming Logs Real-time visibility into services, events, and system activity Adds terminal complexity and requires readable formatting
Dry-Run Mode Makes risky commands easier to preview before execution Requires commands to separate planning from execution
Doctor Command Improves setup debugging and local environment confidence Needs ongoing maintenance as dependencies change
JSON Output Makes the CLI useful for scripts, CI, and automation Requires stable output contracts and versioning discipline

Real-World Impact

Easier Operations

Infrastructure becomes easier to operate because repeated workflows are packaged into clear commands.

Less Context Switching

Developers spend less time remembering commands or searching docs and more time operating the system confidently.

Faster Debugging

Debugging workflows become faster because logs, status checks, diagnostics, and events are available directly from the CLI.

What I Learned

  • A CLI is a developer-facing product, not just a group of scripts.
  • Command names should match developer workflows instead of internal implementation details.
  • Input validation protects infrastructure from accidental misuse.
  • Clear terminal output improves confidence during debugging and operations.
  • Dry-run modes make risky workflows safer to preview.
  • Doctor commands make local setup and dependency checks much easier.
  • JSON output makes the CLI useful beyond humans, especially for CI and automation.

Conclusion

Developer tools are part of the engineering experience. A well-designed CLI turns complex system operations into clear, repeatable, and safer workflows.

A strong CLI uses workflow-based commands, input validation, readable output, dry-run support, helpful errors, diagnostics, streaming logs, and structured output for automation.

The key lesson is simple: a CLI should not only execute commands. It should help developers understand, operate, and trust the system with less friction.

Key Takeaways

A CLI should be designed like a product interface

Command names should match user intent

Readable errors improve developer experience

Streaming output is powerful for real-time systems

Validation matters before running risky commands

Future Improvements

Add interactive command prompts

Add command autocomplete support

Improve error messages with suggested fixes

Add JSON output mode for automation

Add config profiles for different environments