The Schedule
{"type": "on-change", "paths": ["/Users/YOU/Downloads"]}
Replace YOU with your username. This path must be absolute — launchd doesn't expand ~.
The Run Script
Classify by extension (instant, no LLM), with optional AI classification for ambiguous files:
#!/usr/bin/env bun
import { readdir, rename, mkdir, stat } from "node:fs/promises";
import { join, extname } from "node:path";
import { homedir } from "node:os";
const DOWNLOADS = join(homedir(), "Downloads");
const ORGANIZED = join(DOWNLOADS, "_sorted");
const CATEGORIES: Record<string, string> = {
".pdf": "documents", ".doc": "documents", ".docx": "documents",
".txt": "documents", ".md": "documents",
".jpg": "images", ".jpeg": "images", ".png": "images",
".gif": "images", ".svg": "images", ".webp": "images", ".heic": "images",
".ts": "code", ".js": "code", ".py": "code", ".go": "code",
".zip": "archives", ".tar": "archives", ".gz": "archives",
".dmg": "installers", ".pkg": "installers",
".mp3": "media", ".mp4": "media", ".mov": "media", ".m4a": "media",
};
const files = await readdir(DOWNLOADS);
let moved = 0;
for (const file of files) {
if (file.startsWith(".") || file.startsWith("_")) continue;
const fullPath = join(DOWNLOADS, file);
const s = await stat(fullPath);
if (s.isDirectory()) continue;
if (Date.now() - s.mtimeMs < 10_000) continue; // still downloading
const category = CATEGORIES[extname(file).toLowerCase()] || "other";
const destDir = join(ORGANIZED, category);
await mkdir(destDir, { recursive: true });
await rename(fullPath, join(destDir, file));
console.log(`[downloads] ${file} → ${category}/`);
moved++;
}
console.log(`[downloads] Organized ${moved} files`);
The WatchPaths Gotcha
The organized folder is _sorted (underscore prefix) INSIDE Downloads. When you move files into it, that's a change to Downloads — WatchPaths fires again. The file.startsWith("_") guard prevents the infinite loop.
Test It
bun run sync
# Drop a file in Downloads or just wait for the next download
bun run src/cli.ts logs downloads-organizer
Companion Notes
Branch: Downloads Organizer
Watch ~/Downloads. When files appear, classify and move them.
Schedule
{"type": "on-change", "paths": ["/Users/YOU/Downloads"]}
Replace YOU with your username, or use the relative path trick if Downloads is accessible from the project.
Run Script
#!/usr/bin/env bun
import { readdir, rename, mkdir, stat } from "node:fs/promises";
import { join, extname } from "node:path";
import { homedir } from "node:os";
const DOWNLOADS = join(homedir(), "Downloads");
const ORGANIZED = join(homedir(), "Downloads", "_organized");
// Extension-based classification (fast, no LLM needed)
const CATEGORIES: Record<string, string> = {
// Documents
".pdf": "documents", ".doc": "documents", ".docx": "documents",
".txt": "documents", ".md": "documents", ".rtf": "documents",
// Images
".jpg": "images", ".jpeg": "images", ".png": "images",
".gif": "images", ".svg": "images", ".webp": "images",
".heic": "images",
// Code
".ts": "code", ".js": "code", ".py": "code", ".go": "code",
".rs": "code", ".swift": "code", ".json": "code",
// Archives
".zip": "archives", ".tar": "archives", ".gz": "archives",
".dmg": "archives", ".pkg": "archives",
// Media
".mp3": "media", ".mp4": "media", ".mov": "media",
".wav": "media", ".m4a": "media",
};
async function main() {
const files = await readdir(DOWNLOADS);
const toOrganize = files.filter(f =>
!f.startsWith(".") &&
!f.startsWith("_") &&
f !== ".localized"
);
let moved = 0;
for (const file of toOrganize) {
const fullPath = join(DOWNLOADS, file);
const s = await stat(fullPath);
// Skip directories and very recent files (still downloading)
if (s.isDirectory()) continue;
if (Date.now() - s.mtimeMs < 10_000) continue; // less than 10s old
const ext = extname(file).toLowerCase();
const category = CATEGORIES[ext] || "other";
const destDir = join(ORGANIZED, category);
await mkdir(destDir, { recursive: true });
await rename(fullPath, join(destDir, file));
console.log(`[downloads] ${file} → ${category}/`);
moved++;
}
console.log(`[downloads] Organized ${moved} files.`);
}
main().catch(e => { console.error("[downloads]", e); process.exit(1); });
LLM-Enhanced Version
Want smarter classification? Add an LLM call for ambiguous files:
// For files where extension isn't enough
const ext = extname(file).toLowerCase();
let category = CATEGORIES[ext];
if (!category || category === "other") {
// Read first 500 bytes and ask the LLM
const content = await Bun.file(fullPath).text().catch(() => `[binary: ${s.size} bytes]`);
const proc = Bun.spawnSync(["claude", "-p",
`Classify this file into one category (documents/images/code/archives/media/other). Reply with ONLY the category.\n\nFilename: ${file}\nContent preview: ${content.slice(0, 500)}`
], { timeout: 15_000 });
category = new TextDecoder().decode(proc.stdout).trim().toLowerCase() || "other";
}
Gotcha: WatchPaths Fires on YOUR Writes
If you move files within the watched directory, WatchPaths fires again. Guard against it:
// Skip the organized subdirectory
if (file === "_organized") continue;
That's why the organized folder is _organized (underscore prefix) — easy to skip.