08 - Build a CLI Tool
📋 Jump to TakeawaysThis lesson uses ES Modules. Make sure your package.json has
"type": "module". See Modules if you need a refresher.
What We're Building
A command-line tool that organizes files in a directory by their extension. Run it on a messy Downloads folder and it creates subdirectories (images/, documents/, code/) and moves files into them.
node organize.js ~/DownloadsThe problem: You have a folder full of mixed files. You want to sort them into subfolders by type automatically.
The steps:
- Read the target directory from command line arguments
- Define file categories (images, documents, code, etc.)
- Loop through files, determine each file's category by extension
- Create category folders and move files into them
- Add a
--dry-runflag to preview without moving
Try building it yourself first using what you've learned (fs, path, process.argv). Then compare with the solution below.
Step 1: Parse Arguments
#!/usr/bin/env node
import path from 'node:path';
import fs from 'node:fs/promises';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const targetDir = args.find(a => !a.startsWith('--'));
if (!targetDir) {
console.error('Usage: node organize.js <directory> [--dry-run]');
process.exit(1);
}
const resolvedDir = path.resolve(targetDir);Get the directory from arguments. Support a --dry-run flag from the start. Exit with an error if no directory provided.
Step 2: Define Categories
const CATEGORIES = {
images: ['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp'],
documents: ['.pdf', '.doc', '.docx', '.txt', '.md', '.csv'],
code: ['.js', '.ts', '.py', '.go', '.rs', '.html', '.css', '.json'],
audio: ['.mp3', '.wav', '.flac', '.aac'],
video: ['.mp4', '.mov', '.avi', '.mkv'],
};
function getCategory(ext) {
for (const [category, extensions] of Object.entries(CATEGORIES)) {
if (extensions.includes(ext.toLowerCase())) return category;
}
return 'other';
}Map extensions to folder names. Anything unrecognized goes to other/.
Step 3: Read and Organize
async function organize() {
console.log(`${dryRun ? '[DRY RUN] ' : ''}Organizing: ${resolvedDir}`);
const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
let moved = 0;
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!ext) continue;
const category = getCategory(ext);
const categoryDir = path.join(resolvedDir, category);
const oldPath = path.join(resolvedDir, entry.name);
const newPath = path.join(categoryDir, entry.name);
if (dryRun) {
console.log(` ${entry.name} -> ${category}/`);
} else {
await fs.mkdir(categoryDir, { recursive: true });
await fs.rename(oldPath, newPath);
console.log(` ${entry.name} -> ${category}/`);
}
moved++;
}
console.log(`\n${dryRun ? 'Would move' : 'Moved'} ${moved} files.`);
}
organize().catch((err) => {
console.error('Error:', err.message);
process.exit(1);
});readdir with withFileTypes tells us if each entry is a file or directory without extra stat calls. We skip directories and files without extensions.
The Complete File
#!/usr/bin/env node
import path from 'node:path';
import fs from 'node:fs/promises';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const targetDir = args.find(a => !a.startsWith('--'));
if (!targetDir) {
console.error('Usage: node organize.js <directory> [--dry-run]');
process.exit(1);
}
const resolvedDir = path.resolve(targetDir);
const CATEGORIES = {
images: ['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp'],
documents: ['.pdf', '.doc', '.docx', '.txt', '.md', '.csv'],
code: ['.js', '.ts', '.py', '.go', '.rs', '.html', '.css', '.json'],
audio: ['.mp3', '.wav', '.flac', '.aac'],
video: ['.mp4', '.mov', '.avi', '.mkv'],
};
function getCategory(ext) {
for (const [category, extensions] of Object.entries(CATEGORIES)) {
if (extensions.includes(ext.toLowerCase())) return category;
}
return 'other';
}
async function organize() {
console.log(`${dryRun ? '[DRY RUN] ' : ''}Organizing: ${resolvedDir}`);
const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
let moved = 0;
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!ext) continue;
const category = getCategory(ext);
const categoryDir = path.join(resolvedDir, category);
const oldPath = path.join(resolvedDir, entry.name);
const newPath = path.join(categoryDir, entry.name);
if (dryRun) {
console.log(` ${entry.name} -> ${category}/`);
} else {
await fs.mkdir(categoryDir, { recursive: true });
await fs.rename(oldPath, newPath);
console.log(` ${entry.name} -> ${category}/`);
}
moved++;
}
console.log(`\n${dryRun ? 'Would move' : 'Moved'} ${moved} files.`);
}
organize().catch((err) => {
console.error('Error:', err.message);
process.exit(1);
});Test it:
# Preview first
node organize.js ~/Downloads --dry-run
# Then run for real
node organize.js ~/DownloadsMake It Executable
Add a shebang line at the top (already included above): #!/usr/bin/env node
Then make it executable:
chmod +x organize.js
./organize.js ~/DownloadsYou can also register it as a CLI command in package.json:
{
"name": "file-organizer",
"type": "module",
"bin": {
"organize": "./organize.js"
}
}After npm link, you can run organize ~/Downloads from anywhere.
Key Takeaways
process.argv.slice(2)gets user arguments, validate them earlyreaddirwith{ withFileTypes: true }avoids extrastatcallspath.extname(),path.join(), andpath.resolve()handle all path workfs.mkdirwith{ recursive: true }creates directories safely- Add
--dry-runflags so users can preview destructive operations #!/usr/bin/env node+chmod +xmakes scripts directly executable- The
binfield in package.json registers CLI commands