candourlabs
← All articles

Ship a Cross-Platform CLI Tool in One Evening

Ship a Cross-Platform CLI Tool in One Evening (No Framework Required)


Why CLI tools are the perfect first product

A CLI tool is the lowest-friction software product that exists:

And the distribution story is brutal in the best way: developers find tools through GitHub, dev.to, and word of mouth. You don't need a marketing team — you need a tool that solves a real annoyance and a README that proves it.

This article walks through building one end to end: a cross-platform tool that solves a specific, boring problem — cleaning up old, large files from a project directory — using zero frameworks and one dependency. By the end you'll have a working product you can actually sell or open-source.

Step 0: Pick a boring problem

The product rule that beats all others: build the thing you've already wanted five times. My "five times" was disk cleanup. Every project folder accumulates the same junk — node_modules reinstall caches, build outputs, logs, .DS_Store, old archives — and every time I'd delete it by hand, nervously, file by file.

The tool: declutter — a CLI that scans a directory, categorizes the biggest space hogs, shows you what's safe to delete, and cleans up with a dry-run mode. Boring, useful, done-in-an-evening.

Step 1: The foundation

Node.js gives us cross-platform file handling for free (Windows, macOS, Linux) — one codebase, no compilation. We use exactly one runtime dependency: picocolors for terminal colors (3KB, zero transitive deps). Everything else is Node built-ins.


mkdir declutter && cd declutter
npm init -y
npm install picocolors

The entry point is a single file, index.js, with a shebang for direct execution:


#!/usr/bin/env node
const { scan, formatBytes, categorize } = require('./lib/core');
const pc = require('picocolors');

async function main() {
  const target = process.argv[2] || '.';
  // ...CLI logic below
}

main().catch(err => {
  console.error(pc.red(`declutter: ${err.message}`));
  process.exit(1);
});

Three files total: index.js (CLI), lib/core.js (logic), lib/scan.js (filesystem walking). Keeping logic out of the CLI file means we can test it without spawning processes.

Step 2: The core scan

The heart of the tool is a recursive walk that categorizes files by type and sums sizes. Node's fs.promises.readdir with { withFileTypes: true } gives us directory detection without a stat call per entry:


const { promises: fs } = require('fs');
const path = require('path');

const CATEGORIES = {
  cache: ['node_modules', '.cache', '.npm', 'dist', 'build', 'coverage'],
  logs: ['*.log', '*.tmp'],
  archives: ['*.zip', '*.tar', '*.gz', '*.7z', '*.rar'],
  trash: ['.DS_Store', 'Thumbs.db', '.Trash-*'],
};

async function walk(dir, depth = 0) {
  const results = { files: [], dirs: [], totalBytes: 0 };
  if (depth > 8) return results; // safety cap on recursion

  let entries;
  try {
    entries = await fs.readdir(dir, { withFileTypes: true });
  } catch {
    return results; // skip unreadable dirs silently
  }

  for (const entry of entries) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      const sub = await walk(full, depth + 1);
      results.dirs.push(...sub.dirs);
      results.files.push(...sub.files);
      results.totalBytes += sub.totalBytes;
    } else if (entry.isFile()) {
      const stat = await fs.stat(full);
      results.files.push({ path: full, size: stat.size });
      results.totalBytes += stat.size;
    }
  }
  return results;
}

The depth cap is the kind of detail that separates a tool that hangs from a tool that finishes — a runaway recursion into a symlinked directory is the #1 CLI-killer.

Step 3: Categorize and rank


function categorize(files) {
  const buckets = Object.fromEntries(Object.keys(CATEGORIES).map(k => [k, []]));
  buckets.other = [];

  for (const f of files) {
    const name = path.basename(f.path).toLowerCase();
    const matched = Object.entries(CATEGORIES).find(([, patterns]) =>
      patterns.some(p => p.includes('/') || p.startsWith('.')
        ? name.includes(p.replace('*', '').replace('.', ''))
        : name.endsWith(p.replace('*', '')))
    );
    (matched ? buckets[matched[0]] : buckets.other).push(f);
  }
  return buckets;
}

Then sort each bucket by size descending and keep the top N — nobody needs the 400th log file in the output.

Step 4: The CLI experience

The difference between a tool people use and a tool people uninstall is the interaction:


const summary = summarize(buckets);
console.log(pc.bold(`\n📦 ${pc.cyan(target)} — ${formatBytes(summary.total)} analyzed\n`));

for (const [category, files] of Object.entries(summary.topCategories)) {
  const size = files.reduce((s, f) => s + f.size, 0);
  console.log(`${pc.yellow(category.padEnd(10))} ${pc.bold(formatBytes(size))}  (${files.length} items)`);
}

And the safety rule that matters most: dry-run by default. declutter never deletes unless you pass --yes. Deletion without confirmation is how tools earn "are you sure you want to run this" reputations.

Step 5: Make it installable

Three lines in package.json turn this from "a script" into "a product":


{
  "bin": { "declutter": "./index.js" },
  "preferGlobal": true,
  "files": ["index.js", "lib/"]
}

npm install -g . and it's on the PATH. Add --version and --help flags — every CLI tool must have them; it's the first thing a potential buyer tries.

Step 6: Test the safety rails

The highest-risk code in any deletion tool is the deletion code. Test it like it's production (because it is):

A 60-line test file using node:test (built into Node 20+) covers all of it with zero test dependencies.

The packaging question: sell it or open-source it?

Two legitimate paths:

Sell it ($9-29 one-time on Lemon Squeezy/Gumroad): works when the tool is polished enough that "it just works" is worth more than free alternatives. Add a pro differentiator later (scheduled cleanup, config files, report export).

Open-source it (MIT on GitHub): works when you want reputation and inbound. Sponsorships (GitHub Sponsors, Polar) follow traction, not the other way around — this is upside on work you'd do anyway.

The hybrid that most indie devs miss: open-source the core, sell the convenience (installer, config presets, priority support). Either way, the code you wrote this evening is now an asset that can generate income while you sleep — which is the entire point.

What I'd do differently next time

Every paragraph above is a repeatable template: pick a boring annoyance → build the minimal fix → add safety rails → make it installable → write the post that markets it. The skills compound. The second tool takes half the time of the first, and the second article writes itself using the first one as structure.

That's the evening. One dependency. Three files. A product.


This tutorial documents a real build completed with AI-assisted development in a production environment. The full source code is available in the companion repository.