Skip to content

Automating Developer Workflows with Custom CLI Tools

How to spot repetitive development tasks and build custom CLI tools for them — task analysis, script design patterns and the compounding payoff.

6 min read
Terminal window showing custom developer automation scripts in action

The Three-Hour Rule for Automation

If you do something manually more than three times, automate it. If the manual process takes more than five minutes and you do it weekly, the automation will pay for itself within a month. These are not precise thresholds—they are heuristics for overcoming the inertia that keeps developers doing repetitive tasks by hand.

The irony of software engineering is that we build automation for everyone else while manually copying environment variables between terminals, hand-crafting database seed commands, and typing the same Git incantation for the fifteenth time this week.

This guide covers how to identify automation opportunities, design CLI tools that stick, and build a personal automation toolkit that compounds over time.

Identifying Automation Candidates

Not every repetitive task is worth automating. The sweet spot is tasks that are frequent, error-prone, and have well-defined steps.

tstypescript
interface AutomationCandidate {
  task: string;
  frequencyPerWeek: number;
  minutesPerExecution: number;
  errorRate: string;
  automationEffort: string;
  weeklyTimeSaved: number;
  paybackWeeks: number;
}
 
function evaluateCandidates(
  candidates: AutomationCandidate[]
): AutomationCandidate[] {
  return candidates
    .map((c) => {
      const automationHours =
        c.automationEffort === "low"
          ? 1
          : c.automationEffort === "medium"
            ? 4
            : 12;
      const weeklySavedHours =
        (c.frequencyPerWeek * c.minutesPerExecution) / 60;
      const payback = automationHours / weeklySavedHours;
 
      return {
        ...c,
        weeklyTimeSaved: weeklySavedHours * 60,
        paybackWeeks: Math.ceil(payback),
      };
    })
    .sort((a, b) => a.paybackWeeks - b.paybackWeeks);
}
 
const candidates: AutomationCandidate[] = [
  {
    task: "Set up new feature branch with ticket reference",
    frequencyPerWeek: 8,
    minutesPerExecution: 3,
    errorRate: "low",
    automationEffort: "low",
    weeklyTimeSaved: 0,
    paybackWeeks: 0,
  },
  {
    task: "Seed local database with test data",
    frequencyPerWeek: 5,
    minutesPerExecution: 8,
    errorRate: "medium",
    automationEffort: "medium",
    weeklyTimeSaved: 0,
    paybackWeeks: 0,
  },
  {
    task: "Generate API client from OpenAPI spec",
    frequencyPerWeek: 2,
    minutesPerExecution: 15,
    errorRate: "high",
    automationEffort: "medium",
    weeklyTimeSaved: 0,
    paybackWeeks: 0,
  },
];

The payback calculation is deliberately simple. Over-analyzing ROI for automation is itself a form of procrastination. If the payback is under four weeks and the task annoys you, build the tool.

Building Your First Automation Script

Start with shell scripts wrapped in a consistent interface. No framework needed—just a file in your project's scripts/ directory with a clear name and help output.

tstypescript
#!/usr/bin/env node
// scripts/new-feature.ts
import { execSync } from "child_process";
 
const args = process.argv.slice(2);
 
function printHelp(): void {
  console.log(`
Usage: ./scripts/new-feature.ts <ticket-id> [description]
 
Creates a new feature branch from latest main with:
- Branch name: feature/<ticket-id>-<description>
- Commits an empty .feature file with ticket metadata
 
Examples:
  ./scripts/new-feature.ts PROJ-123 add-payment-flow
  ./scripts/new-feature.ts PROJ-456 refactor-auth
`);
}
 
if (args.length < 1 || args[0] === "--help") {
  printHelp();
  process.exit(args[0] === "--help" ? 0 : 1);
}
 
const ticketId = args[0];
const description = args[1] || "feature";
const branchName = `feature/${ticketId}-${description}`.toLowerCase();
 
function run(cmd: string): string {
  return execSync(cmd, { encoding: "utf-8" }).trim();
}
 
try {
  // Ensure clean working directory
  const status = run("git status --porcelain");
  if (status) {
    console.error("Error: Working directory is not clean. Commit or stash changes first.");
    process.exit(1);
  }
 
  // Update main and create branch
  console.log("Updating main branch...");
  run("git checkout main");
  run("git pull origin main");
 
  console.log(`Creating branch: ${branchName}`);
  run(`git checkout -b ${branchName}`);
 
  console.log(`\nBranch '${branchName}' created and checked out.`);
  console.log(`Ticket: ${ticketId}`);
} catch (error) {
  console.error("Failed:", (error as Error).message);
  process.exit(1);
}

The script validates inputs, checks preconditions, and provides clear error messages. These three qualities determine whether a script gets used once and forgotten or becomes part of the team's daily workflow.

Composable Script Architecture

As your automation toolkit grows, individual scripts should compose into larger workflows. A shared library of utility functions prevents duplication.

tstypescript
// scripts/lib/git.ts
import { execSync } from "child_process";
 
export function getCurrentBranch(): string {
  return execSync("git branch --show-current", {
    encoding: "utf-8",
  }).trim();
}
 
export function isClean(): boolean {
  const status = execSync("git status --porcelain", {
    encoding: "utf-8",
  }).trim();
  return status === "";
}
 
export function getLastTag(): string | null {
  try {
    return execSync("git describe --tags --abbrev=0", {
      encoding: "utf-8",
    }).trim();
  } catch {
    return null;
  }
}
 
export function getCommitsSince(ref: string): string[] {
  return execSync(`git log ${ref}..HEAD --oneline`, {
    encoding: "utf-8",
  })
    .trim()
    .split("\n")
    .filter(Boolean);
}
tstypescript
// scripts/lib/env.ts
import { readFileSync, existsSync } from "fs";
import path from "path";
 
export function loadEnvFile(envPath: string): Record<string, string> {
  if (!existsSync(envPath)) {
    throw new Error(`Environment file not found: ${envPath}`);
  }
 
  const content = readFileSync(envPath, "utf-8");
  const vars: Record<string, string> = {};
 
  for (const line of content.split("\n")) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith("#")) continue;
 
    const eqIndex = trimmed.indexOf("=");
    if (eqIndex === -1) continue;
 
    const key = trimmed.slice(0, eqIndex).trim();
    let value = trimmed.slice(eqIndex + 1).trim();
 
    // Remove surrounding quotes
    if (
      (value.startsWith('"') && value.endsWith('"')) ||
      (value.startsWith("'") && value.endsWith("'"))
    ) {
      value = value.slice(1, -1);
    }
 
    vars[key] = value;
  }
 
  return vars;
}
 
export function requireEnvVars(vars: string[]): void {
  const missing = vars.filter((v) => !process.env[v]);
  if (missing.length > 0) {
    throw new Error(
      `Missing required environment variables: ${missing.join(", ")}`
    );
  }
}
tstypescript
// scripts/lib/prompt.ts
import readline from "readline";
 
export async function confirm(message: string): Promise<boolean> {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });
 
  return new Promise((resolve) => {
    rl.question(`${message} (y/N): `, (answer) => {
      rl.close();
      resolve(answer.toLowerCase() === "y");
    });
  });
}
 
export async function select(
  message: string,
  options: string[]
): Promise<string> {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });
 
  console.log(message);
  options.forEach((opt, i) => console.log(`  ${i + 1}. ${opt}`));
 
  return new Promise((resolve) => {
    rl.question("Choice: ", (answer) => {
      rl.close();
      const idx = parseInt(answer, 10) - 1;
      resolve(options[idx] || options[0]);
    });
  });
}

These utilities—Git operations, environment management, interactive prompts—form the building blocks for any automation script. Import what you need; ignore what you don't.

Database Seeding Automation

Database seeding is one of the highest-value automation targets. It is done frequently, varies by context, and errors cause cascading development issues.

tstypescript
#!/usr/bin/env node
// scripts/seed.ts
import { confirm, select } from "./lib/prompt";
 
interface SeedProfile {
  name: string;
  description: string;
  users: number;
  projects: number;
  includeEdgeCases: boolean;
}
 
const profiles: SeedProfile[] = [
  {
    name: "minimal",
    description: "1 admin, 2 users, 1 project — fast startup",
    users: 3,
    projects: 1,
    includeEdgeCases: false,
  },
  {
    name: "development",
    description: "10 users, 5 projects, realistic data distribution",
    users: 10,
    projects: 5,
    includeEdgeCases: false,
  },
  {
    name: "stress-test",
    description: "1000 users, 50 projects, includes edge cases",
    users: 1000,
    projects: 50,
    includeEdgeCases: true,
  },
];
 
async function main(): Promise<void> {
  const profileName = await select(
    "Select seed profile:",
    profiles.map((p) => `${p.name} — ${p.description}`)
  );
 
  const profile = profiles.find((p) =>
    profileName.startsWith(p.name)
  );
 
  if (!profile) {
    console.error("Invalid profile selected");
    process.exit(1);
  }
 
  const shouldReset = await confirm(
    "Reset database before seeding?"
  );
 
  if (shouldReset) {
    console.log("Resetting database...");
    // Reset logic here
  }
 
  console.log(`Seeding with profile: ${profile.name}`);
  console.log(`  Users: ${profile.users}`);
  console.log(`  Projects: ${profile.projects}`);
 
  // Seed execution here
  console.log("Seeding complete.");
}
 
main().catch(console.error);

The Automation Index: Tracking Your Toolkit

Keep a README in your scripts directory that documents what each tool does, when to use it, and who maintains it:

tstypescript
// scripts/README.ts — Generate automation index from script metadata
import { readdirSync, readFileSync } from "fs";
import path from "path";
 
interface ScriptMeta {
  name: string;
  description: string;
  usage: string;
  author: string;
  lastUpdated: string;
}
 
function extractMeta(filepath: string): ScriptMeta | null {
  const content = readFileSync(filepath, "utf-8");
  const lines = content.split("\n").slice(0, 20);
 
  const descLine = lines.find((l) => l.includes("@description"));
  const usageLine = lines.find((l) => l.includes("@usage"));
  const authorLine = lines.find((l) => l.includes("@author"));
 
  if (!descLine) return null;
 
  return {
    name: path.basename(filepath),
    description: descLine.replace(/.*@description\s*/, "").trim(),
    usage: usageLine?.replace(/.*@usage\s*/, "").trim() || "See --help",
    author: authorLine?.replace(/.*@author\s*/, "").trim() || "team",
    lastUpdated: "",
  };
}
 
function generateIndex(scriptsDir: string): string {
  const files = readdirSync(scriptsDir).filter(
    (f) => f.endsWith(".ts") || f.endsWith(".mjs")
  );
 
  const metas = files
    .map((f) => extractMeta(path.join(scriptsDir, f)))
    .filter(Boolean) as ScriptMeta[];
 
  let index = "# Developer Scripts\n\n";
  index += "| Script | Description | Usage |\n";
  index += "|--------|-------------|-------|\n";
 
  for (const meta of metas) {
    index += `| \`${meta.name}\` | ${meta.description} | \`${meta.usage}\` |\n`;
  }
 
  return index;
}

A discoverable toolkit gets used. An undiscoverable one gets rebuilt from scratch by the next developer who encounters the same problem.

Integrating Scripts with Package.json

jsonjson
{
  "scripts": {
    "new-feature": "tsx scripts/new-feature.ts",
    "seed": "tsx scripts/seed.ts",
    "seed:minimal": "tsx scripts/seed.ts --profile minimal --no-prompt",
    "db:reset": "tsx scripts/db-reset.ts",
    "release": "tsx scripts/release.ts",
    "check:deps": "tsx scripts/check-deps.ts",
    "scripts:index": "tsx scripts/generate-index.ts"
  }
}

Exposing scripts through package.json makes them discoverable via npm run tab completion and documents them alongside the project's standard commands. npm run seed is more likely to be found and used than tsx scripts/seed.ts.

Key Takeaways

Automation is the highest-leverage activity in software engineering. Every minute spent building a reliable script multiplies across every future execution. The compound returns are enormous—a script that saves five minutes daily saves over 20 hours per year, and that is just for one person on the team.

Start small: pick the task that annoys you most, automate it in the simplest way possible, and put it where the team can find it. Build a shared library of utilities for Git operations, environment management, and interactive prompts. Document your scripts in a central index.

The best developer tooling is not the fanciest—it is the most reliable. A script that works every time, handles errors clearly, and takes ten seconds to run will get used daily. A beautiful CLI framework that takes three days to build might not.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX