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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- file-organization Installs to ~/.agents/skills/file-organization/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install file-organization The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project file-organization Installs to .agents/skills/file-organization/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/file-organization.zipunzip -o file-organization.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) file-organization PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- Files should be sorted automatically as they arrive: use desktop-file-watchers
- The files are images that need resizing or converting: use image-batch-processing
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.
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
| Content | Rule that holds up |
|---|---|
| Photos and videos | YYYY/YYYY-MM/ from capture date (EXIF), not file mtime |
| Invoices, statements | YYYY/ + name YYYY-MM-DD_vendor_amount.pdf |
| Project documents | project/ then the project’s own subfolders; at most 3 levels |
| Downloads dump | By type into Documents/ Images/ Archives/ Installers/, plus _to_review/ |
| Mixed archive | Ask; 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.pdfandreport.pdfcollide, 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.pyon 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.yaml
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.py
#!/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.py
#!/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.