BranchTrigger: Downloads Organizer

Trigger: Downloads Organizer

Watch ~/Downloads, auto-sort files by type and content.

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.