File Organization skill for Codex

Rename and dedupe thousands of files, reversibly. Codex inventories the folder, agrees a single organising rule with you, and writes every rename or move into a CSV plan you can review before anything changes. The bundled scripts find duplicates by content hash and apply plans with collision checks, case-only rename handling and an undo log. Duplicates go to a quarantine folder; nothing is deleted.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- file-organization

Installs to ~/.agents/skills/file-organization/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $file-organization, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Organizes, bulk-renames and deduplicates large folders of files safely - inventory first, a reviewable rename/move plan as CSV, collision and case-sensitivity checks, an undo log, content-hash duplicate detection, and EXIF-date sorting of photos with exiftool. Ships two stdlib Python scripts for duplicate reports and applying plans. Use when asked to clean up Downloads, rename hundreds of files to a pattern, sort photos into year/month folders, find duplicate files, or restructure a shared drive without losing anything.

Use it when

  • Hundreds of files need renaming to a consistent pattern
  • Photos or videos should be sorted into folders by capture date
  • You suspect a drive is full of duplicate copies
  • A shared folder needs restructuring without losing anything

Use something else when

How to check the result

File count and total bytes match the pre-run inventory, the undo log has one row per move, and a rerun of the duplicate report on the new tree finds only intended copies.

The mistake it prevents

Renaming in place on a case-insensitive macOS or Windows volume, where two names that differ only by case silently collide.

~/.agents/skills/file-organization/SKILL.md

--- name: file-organization description: Organizes, bulk-renames and deduplicates large folders of files safely - inventory first, a reviewable rename/move plan as CSV, collision and… license: MIT ---

Done well means: nothing is lost or overwritten, every change was visible in a plan the user approved before it ran, the batch can be undone from a log, and the final structure follows one rule the user can state in a sentence.

Hard rules

  • Never delete. Move duplicates and junk into a quarantine folder (_to_review/) or the OS trash; actual deletion is the user’s decision, made after they have looked.
  • Never overwrite. A collision stops the plan; it does not get a silent (1) suffix unless the user chose that policy.
  • Plan, show, then apply. The plan is a file (CSV) the user can open and edit.
  • Work on a copy or after a backup when the folder is irreplaceable (photo libraries, client documents). Ask whether one exists.
  • Leave managed libraries alone: Photos/Lightroom catalogues, iCloud/Dropbox placeholder files, git repositories, application bundles. Moving files inside them breaks the app.

Workflow

- [ ] 1. Inventory: counts, sizes, extensions, depth, oldest/newest
- [ ] 2. Agree the target rule with the user (by type? by date? by project?)
- [ ] 3. Generate plan.csv (src,dst) with a script; no changes yet
- [ ] 4. Validate: missing sources, collisions, case-only renames, path length
- [ ] 5. User reviews plan.csv (show the first 30 rows and the counts per folder)
- [ ] 6. Apply with an undo log; report moved / failed
- [ ] 7. Verify file count and total bytes are unchanged

Inventory

find "$DIR" -type f | wc -l
du -sh "$DIR"
find "$DIR" -type f -name '*.*' | sed 's/.*\.//' | tr 'A-Z' 'a-z' | sort | uniq -c | sort -rn | head -20
find "$DIR" -type f -size +500M -exec ls -lh {} +        # surprises worth asking about

Record the file count and total bytes now; step 7 compares against them.

Choosing the structure

ContentRule that holds up
Photos and videosYYYY/YYYY-MM/ from capture date (EXIF), not file mtime
Invoices, statementsYYYY/ + name YYYY-MM-DD_vendor_amount.pdf
Project documentsproject/ then the project’s own subfolders; at most 3 levels
Downloads dumpBy type into Documents/ Images/ Archives/ Installers/, plus _to_review/
Mixed archiveAsk; do not invent a taxonomy the user will not maintain

Filename conventions: ISO dates first (2026-03-14) so names sort chronologically, lowercase or consistent case, hyphens or underscores instead of spaces when the files go to scripts or the web, no characters illegal on Windows (<>:"/\|?*) if files are shared across platforms.

Generating a plan

import csv, re
from pathlib import Path

root = Path("~/Downloads").expanduser()
buckets = {"pdf": "Documents", "docx": "Documents", "jpg": "Images", "png": "Images",
           "heic": "Images", "zip": "Archives", "dmg": "Installers", "pkg": "Installers"}
rows = []
for f in sorted(p for p in root.iterdir() if p.is_file() and not p.name.startswith(".")):
    folder = buckets.get(f.suffix.lower().lstrip("."), "_to_review")
    name = re.sub(r"\s+", "-", f.stem.strip()) + f.suffix.lower()
    rows.append({"src": str(f), "dst": str(root / folder / name)})
with open("plan.csv", "w", newline="", encoding="utf-8") as fh:
    w = csv.DictWriter(fh, fieldnames=["src", "dst"]); w.writeheader(); w.writerows(rows)
print(len(rows), "rows written to plan.csv")

Applying a plan (bundled script)

python3 scripts/apply_plan.py plan.csv            # validate + dry run, changes nothing
python3 scripts/apply_plan.py plan.csv --apply    # moves; writes plan.csv.undo.csv
python3 scripts/apply_plan.py plan.csv.undo.csv --apply   # reverse the batch

It refuses the whole plan (exit 2) on a missing source, an existing target, two sources mapping to one target, or a target that is also a source; it handles case-only renames on case-insensitive volumes with a two-step rename. Only run --apply after the user has approved the dry-run output.

Duplicates (bundled script)

python3 scripts/find_duplicates.py ~/Pictures ~/Desktop --min-size 4096 --csv dupes.csv

Read-only. It narrows by size, then a 64 KiB head hash, then full SHA-256, skips symlinks and hard links, and writes every group to CSV (exit 1 when duplicates exist). Then decide which copy to keep per group with a stated rule, for example: keep the copy inside the organised tree, else the oldest mtime, else the shortest path. Turn the losers into a plan that moves them to _to_review/duplicates/ and apply it with apply_plan.py.

Same name is not same content, and same content can have different names; only hashes decide. Near-duplicate photos (resized, re-encoded) need perceptual hashing, which is a separate, explicitly approved step.

Alternatives when installed: jdupes -r -S DIR or rmlint DIR (rmlint writes a removal script you must review; never run it unread).

Photos by capture date with exiftool

# Dry run: print the new names without moving anything
exiftool -r -d '%Y/%Y-%m/%Y-%m-%d_%H%M%S%%-c.%%e' '-TestName<DateTimeOriginal' ./inbox

# Apply into a separate tree (exiftool never overwrites; %-c adds -1, -2 on clashes)
exiftool -r -o . -d './sorted/%Y/%Y-%m/%Y-%m-%d_%H%M%S%%-c.%%e' \
  '-FileName<CreateDate' '-FileName<DateTimeOriginal' ./inbox

Later assignments win, so DateTimeOriginal overrides CreateDate where present. Files with neither tag stay where they are; list them with exiftool -r -if 'not $DateTimeOriginal and not $CreateDate' -p '$Directory/$FileName' ./inbox. -o . copies instead of moving; keep it until the result is verified. WhatsApp and screenshot images often lack EXIF dates; fall back to a date in the filename, then mtime, and say which rule each file used.

Gotchas

  • macOS (APFS default) and Windows are case-insensitive: Report.pdf and report.pdf collide, and a case-only rename needs a temporary name.
  • macOS can store names in decomposed Unicode (NFD); compare names after unicodedata.normalize("NFC", ...) or accents look like different files.
  • Cloud-synced folders may hold online-only placeholders; hashing them forces a download of everything. Check before running duplicate scans on Dropbox, OneDrive or iCloud.
  • Moving files between volumes is a copy plus delete; verify the copy before trusting it.
  • Windows paths over 260 characters fail in many tools; keep new trees shallow.
  • In a Codex sandbox, folders outside the workspace (Downloads, Pictures) may need the user to grant access; ask rather than widening permissions yourself.

Verification

find "$DIR" -type f | wc -l          # equals the inventory count (plus quarantine)
du -s "$DIR"                         # total bytes unchanged within rounding
  • Undo log row count equals moves reported.
  • Spot-open five moved files from different folders.
  • Duplicate quarantine: rerun find_duplicates.py on the organised tree; groups that remain are expected (for example, deliberate copies) or the plan missed them.

Report

**Folder:** ~/Downloads (2,314 files, 18.2 GB before and after)
**Rule:** by type into Documents/Images/Archives/Installers; unknown types to _to_review/
**Moved:** 2,301 (13 skipped: in use or permission denied, listed in failed.txt)
**Duplicates:** 412 copies (6.1 GB) moved to _to_review/duplicates/, nothing deleted
**Undo:** `python3 scripts/apply_plan.py plan.csv.undo.csv --apply`

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "File Organization"
  short_description: "Rename and dedupe thousands of files, reversibly"
  default_prompt: "$file-organization Clean up my Downloads folder and sort everything by file type"
scripts/apply_plan.py103 lines, 3.8 KB
#!/usr/bin/env python3
"""Apply a reviewed rename/move plan from a CSV with columns: src,dst.

Dry run by default. With --apply it moves files, refuses to overwrite anything, creates
parent folders, and appends every completed move to an undo log so the batch can be
reversed with --undo.

Usage:
    python3 apply_plan.py plan.csv                 # validate and show what would happen
    python3 apply_plan.py plan.csv --apply         # do it, writing plan.csv.undo.csv
    python3 apply_plan.py plan.csv.undo.csv --apply # reverse a previous batch
Exit codes: 0 = ok, 1 = some moves failed, 2 = plan invalid (nothing was moved).
"""
from __future__ import annotations

import argparse
import csv
import os
import shutil
import sys
import unicodedata
from pathlib import Path


def norm(p: Path) -> str:
    # Case- and Unicode-insensitive key: catches collisions on macOS/Windows volumes.
    return unicodedata.normalize("NFC", str(p.resolve(strict=False))).casefold()


def load(plan: Path) -> list[tuple[Path, Path]]:
    with plan.open(newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    if rows and not {"src", "dst"} <= set(rows[0]):
        raise SystemExit("plan must have src,dst columns")
    return [(Path(r["src"]), Path(r["dst"])) for r in rows if r["src"] != r["dst"]]


def validate(moves: list[tuple[Path, Path]]) -> list[str]:
    problems: list[str] = []
    sources = {norm(s) for s, _ in moves}
    targets: dict[str, Path] = {}
    for src, dst in moves:
        if not src.exists():
            problems.append(f"missing source: {src}")
        key = norm(dst)
        if key in targets:
            problems.append(f"two files map to {dst} (also {targets[key]})")
        targets[key] = src
        case_only = norm(src) == key
        if dst.exists() and not case_only and key not in sources:
            problems.append(f"target exists: {dst}")
        if key in sources and not case_only:
            problems.append(f"target {dst} is also a source in this plan; split into two passes")
    return problems


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Apply a src,dst move plan safely.")
    ap.add_argument("plan", type=Path)
    ap.add_argument("--apply", action="store_true")
    args = ap.parse_args(argv)

    moves = load(args.plan)
    problems = validate(moves)
    if problems:
        print(f"plan invalid, nothing moved ({len(problems)} problems):")
        for p in problems[:50]:
            print(f"  {p}")
        return 2
    if not args.apply:
        for src, dst in moves[:30]:
            print(f"WOULD MOVE {src} -> {dst}")
        print(f"{len(moves)} moves validated (dry run). Re-run with --apply.")
        return 0

    undo_path = args.plan.with_name(args.plan.name + ".undo.csv")
    failed = 0
    with undo_path.open("a", newline="", encoding="utf-8") as uf:
        undo = csv.writer(uf)
        if uf.tell() == 0:
            undo.writerow(["src", "dst"])
        for src, dst in moves:
            try:
                dst.parent.mkdir(parents=True, exist_ok=True)
                if norm(src) == norm(dst):                 # case-only rename
                    tmp = src.with_name(src.name + ".renaming")
                    os.rename(src, tmp)
                    os.rename(tmp, dst)
                else:
                    if dst.exists():
                        raise FileExistsError(dst)
                    shutil.move(str(src), str(dst))
                undo.writerow([str(dst), str(src)])
                uf.flush()
            except OSError as exc:
                failed += 1
                print(f"FAILED {src} -> {dst}: {exc}", file=sys.stderr)
    print(f"moved {len(moves) - failed}/{len(moves)}; undo log: {undo_path}")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
scripts/find_duplicates.py118 lines, 4.3 KB
#!/usr/bin/env python3
"""Report duplicate files under one or more folders. Read-only: never deletes anything.

Groups files by size, then by a 64 KiB head hash, then by full SHA-256, so most files
are never read in full. Writes a CSV with one row per file in a duplicate group.

Usage:
    python3 find_duplicates.py DIR [DIR ...] [--min-size BYTES] [--csv dupes.csv]
Exit codes: 0 = no duplicates, 1 = duplicates found, 2 = bad arguments.
"""
from __future__ import annotations

import argparse
import csv
import hashlib
import os
import sys
from collections import defaultdict
from pathlib import Path

CHUNK = 1 << 20
HEAD = 64 * 1024
SKIP_NAMES = {".DS_Store", "Thumbs.db", "desktop.ini"}


def digest(path: Path, limit: int | None = None) -> str:
    h = hashlib.sha256()
    read = 0
    with path.open("rb") as f:
        while chunk := f.read(CHUNK if limit is None else min(CHUNK, limit - read)):
            h.update(chunk)
            read += len(chunk)
            if limit is not None and read >= limit:
                break
    return h.hexdigest()


def walk(roots: list[Path], min_size: int):
    seen_inodes: set[tuple[int, int]] = set()
    for root in roots:
        for dirpath, dirnames, filenames in os.walk(root):
            dirnames[:] = [d for d in dirnames if not d.startswith(".")]
            for name in filenames:
                if name in SKIP_NAMES:
                    continue
                p = Path(dirpath) / name
                try:
                    st = p.lstat()
                except OSError as exc:
                    print(f"skip {p}: {exc}", file=sys.stderr)
                    continue
                if not p.is_file() or p.is_symlink() or st.st_size < min_size:
                    continue
                key = (st.st_dev, st.st_ino)
                if key in seen_inodes:          # hard link to a file already counted
                    continue
                seen_inodes.add(key)
                yield p, st.st_size


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Report duplicate files (read-only).")
    ap.add_argument("dirs", nargs="+", type=Path)
    ap.add_argument("--min-size", type=int, default=1, help="ignore files smaller than this (bytes)")
    ap.add_argument("--csv", type=Path, help="write groups to this CSV file")
    args = ap.parse_args(argv)
    for d in args.dirs:
        if not d.is_dir():
            print(f"not a directory: {d}", file=sys.stderr)
            return 2

    by_size: dict[int, list[Path]] = defaultdict(list)
    total = 0
    for p, size in walk(args.dirs, args.min_size):
        by_size[size].append(p)
        total += 1

    groups: list[tuple[int, str, list[Path]]] = []
    for size, paths in by_size.items():
        if len(paths) < 2:
            continue
        by_head: dict[str, list[Path]] = defaultdict(list)
        for p in paths:
            by_head[digest(p, HEAD)].append(p)
        for head_paths in by_head.values():
            if len(head_paths) < 2:
                continue
            by_full: dict[str, list[Path]] = defaultdict(list)
            for p in head_paths:
                by_full[digest(p) if size > HEAD else digest(p, HEAD)].append(p)
            for h, same in by_full.items():
                if len(same) > 1:
                    groups.append((size, h, sorted(same)))

    groups.sort(key=lambda g: g[0] * (len(g[2]) - 1), reverse=True)
    wasted = sum(size * (len(paths) - 1) for size, _, paths in groups)
    print(f"scanned {total} files; {len(groups)} duplicate groups; "
          f"{sum(len(g[2]) - 1 for g in groups)} redundant copies; {wasted / 1e6:.1f} MB reclaimable")
    for size, h, paths in groups[:20]:
        print(f"\n{size} bytes  sha256 {h[:12]}")
        for p in paths:
            print(f"  {p}")
    if len(groups) > 20:
        print(f"\n... {len(groups) - 20} more groups (see CSV)")

    if args.csv:
        with args.csv.open("w", newline="", encoding="utf-8") as f:
            w = csv.writer(f)
            w.writerow(["group", "sha256", "size", "path", "mtime"])
            for i, (size, h, paths) in enumerate(groups, start=1):
                for p in paths:
                    w.writerow([i, h, size, str(p), int(p.stat().st_mtime)])
        print(f"\nwrote {args.csv}")
    return 1 if groups else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem