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:
- No servers. It runs on the user's machine. No uptime, no scaling, no billing infrastructure.
- No sign-up. Download, run, done. The entire sales funnel is one command.
- Ships in days, not months. A focused tool is 300-800 lines, not 30,000.
- Sells for years. Utility tools don't rot the way web apps do.
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):
- Dry run reports but touches nothing
--yesdeletes only matched categories- Absolute paths are handled (
/home/user/project) - Unreadable directories don't crash the scan
- Empty directory doesn't crash
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
- Ship a
--configfile from day one instead of retrofitting it - Publish the npm package before writing the blog post (the post markets the package; the package proves the post)
- Record a 60-second usage demo — demos convert better than READMEs
The template
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.