Python Automation Scripts skill for Codex
Scripts that are safe to run twice, and at 3 a.m.. Codex turns manual chores into Python scripts that separate planning from doing, default to a dry run, skip work already done, write files atomically and lock against overlapping runs. Dependencies are declared inline for uv, secrets come from the environment, and the script is scheduled with cron, launchd or a systemd timer only after you approve the entry.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- python-automation-scripts Installs to ~/.agents/skills/python-automation-scripts/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install python-automation-scripts 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 python-automation-scripts Installs to .agents/skills/python-automation-scripts/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/python-automation-scripts.zipunzip -o python-automation-scripts.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))) python-automation-scripts 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 $python-automation-scripts, 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
Writes standalone Python automation scripts that are safe to run unattended - dry-run by default, idempotent, logged, locked against overlap, configured by flags and environment variables, with PEP 723 inline dependencies run through uv - and schedules them with cron, launchd or systemd timers. Use when asked to automate a repetitive task with Python, turn a manual chore into a script, glue two tools or APIs together, or harden a fragile one-off script before putting it on a schedule.
Use it when
- A repetitive task should become a script you can rerun safely
- A quick script is about to be put on cron and needs hardening
- Two tools or APIs need glue code that runs unattended
Use something else when
- You are building a distributable command-line tool with subcommands: use python-cli-tools
- The task is mostly shell commands strung together: use bash-scripting
How to check the result
Run with the dry-run default and review the planned changes, apply once, then run again and confirm the second run reports nothing to do and exits zero.
The mistake it prevents
Relying on the terminal's working directory and PATH, so the script works by hand but fails silently under cron.
An automation script is done when a second run does no harm, a crash halfway leaves
nothing half-written, --dry-run shows exactly what would change, the log explains what
happened without reading the code, and the exit code tells the scheduler whether it worked.
Before writing
- Read
AGENTS.mdand any existingscripts/folder: match the repo’s Python version, formatter and dependency tool. - Write down the contract in three lines: input (files, API, folder), effect (what changes where), and how often it runs.
- Prefer the standard library. Each third-party dependency is something that can break on
a scheduled machine. Common justified ones:
httpx,openpyxl,python-dotenv. - Identify side effects that are destructive or outward-facing (deleting, overwriting,
sending email or messages, posting, paying). Those require
--apply(or--confirm) and must be shown to the user in a dry run before the first real run.
Single-file scripts with uv
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["httpx>=0.27"]
# ///
uv run script.py installs the declared dependencies into a cached environment (needs
network the first time; in a Codex sandbox with network off, ask for approval or use the
project’s existing environment). Add dependencies with uv add --script script.py httpx.
Without uv, a requirements.txt next to the script plus a venv is fine; never install into
the system Python.
Skeleton
"""Archive processed invoices from inbox/ into archive/YYYY/MM and log a summary."""
from __future__ import annotations
import argparse, fcntl, logging, os, shutil, sys
from datetime import datetime
from pathlib import Path
log = logging.getLogger("archive_invoices")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--src", type=Path, default=Path(os.environ.get("INVOICE_SRC", "inbox")))
p.add_argument("--dst", type=Path, default=Path("archive"))
p.add_argument("--apply", action="store_true", help="make changes (default: dry run)")
p.add_argument("-v", "--verbose", action="store_true")
return p.parse_args(argv)
def plan(src: Path, dst: Path) -> list[tuple[Path, Path]]:
moves = []
for f in sorted(src.glob("*.pdf")):
ts = datetime.fromtimestamp(f.stat().st_mtime)
target = dst / f"{ts:%Y}" / f"{ts:%m}" / f.name
if not target.exists(): # idempotent: skip done work
moves.append((f, target))
return moves
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
lock = open("/tmp/archive_invoices.lock", "w")
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) # POSIX; see Windows note below
except BlockingIOError:
log.warning("another run is in progress; exiting"); return 0
if not args.src.is_dir():
log.error("source %s does not exist", args.src); return 2
moves = plan(args.src, args.dst)
for src, dst in moves:
log.info("%s %s -> %s", "MOVE" if args.apply else "WOULD MOVE", src, dst)
if args.apply:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(src, dst)
log.info("%d file(s) %s", len(moves), "moved" if args.apply else "to move (dry run)")
return 0
if __name__ == "__main__":
sys.exit(main())
The shape to keep: plan() computes everything without side effects, main() applies it,
and main(argv) is callable from a test.
Rules that prevent 2 a.m. surprises
| Concern | Rule |
|---|---|
| Partial writes | Write to path.with_suffix(".tmp"), then os.replace(tmp, path) (atomic on one filesystem) |
| Paths | pathlib.Path everywhere; resolve relative to Path(__file__).parent or flags, never the cwd of cron |
| Secrets | From environment or the OS keychain (keyring); never in code, flags or logs |
| Subprocesses | subprocess.run([...], check=True, capture_output=True, text=True, timeout=300); never shell=True with interpolated input |
| HTTP | Always a timeout; retry 429/5xx with backoff; checkpoint progress for long batches |
| Time | Use timezone-aware datetimes (datetime.now(tz=ZoneInfo("Europe/London"))) |
| Exit codes | 0 success or nothing to do, 1 partial failure, 2 bad input/config |
| Logging | logging, not print; one summary line at the end with counts |
| Overlap | File lock (POSIX fcntl.flock; on Windows use msvcrt.locking or a lock-file library) |
Batch work with partial failure
Process items independently, collect failures, keep going, and report:
failed: list[tuple[str, str]] = []
for item in items:
try:
handle(item)
except Exception as exc: # narrow this when you know the failure modes
log.exception("failed on %s", item.id)
failed.append((item.id, str(exc)))
if failed:
log.error("%d of %d failed: %s", len(failed), len(items), [i for i, _ in failed])
return 1
For long jobs, persist a checkpoint (a JSON file of completed ids, or the output itself) after each item so a rerun skips finished work.
Scheduling
| Platform | Mechanism | Notes |
|---|---|---|
| Linux server | systemd timer + service | Persistent=true catches up missed runs; logs in journalctl -u name |
| Linux/macOS, simple | crontab | Minimal PATH; use absolute paths to uv/python; redirect output to a log |
| macOS desktop | launchd agent in ~/Library/LaunchAgents | Runs missed jobs after wake; load with launchctl bootstrap gui/$(id -u) file.plist |
| Windows | Task Scheduler | Set “Start in” directory explicitly |
| Repo chores | GitHub Actions schedule: | See $github-actions-automation |
Cron example: 15 6 * * 1-5 cd /opt/jobs && /usr/local/bin/uv run archive_invoices.py --apply >> /var/log/archive.log 2>&1.
Installing a crontab, timer or launchd job changes the user’s machine: show the entry and
get confirmation first. For overlap, timezone and monitoring detail, see $cron-scheduling.
Testing
- Unit-test
plan()withtmp_path(pytest) on a fake folder tree; assert the planned moves, then assert a secondplan()after applying returns nothing (idempotency). - Mock the network at the client boundary (
httpx.MockTransport) rather than patching internals. - Run once with
--dry-runon real data and show the user the output before--apply.
Gotchas
- Cron runs with a different
HOME,PATHand no shell profile: what works in the terminal fails on schedule. Logsys.executableandos.getcwd()at startup when debugging. shutil.moveacross filesystems copies then deletes; an interruption can leave both. Verify the copy before removing the source in cross-device moves.glob("*.PDF")is case-sensitive on Linux, insensitive on macOS default volumes.- Laptops sleep: cron skips jobs missed while asleep; launchd and systemd timers can catch up.
- Mail and chat notifications from scripts are outward-facing; confirm the recipient list with the user before enabling them.
Handoff note
**Script:** scripts/archive_invoices.py (Python 3.11, stdlib only)
**Run:** `uv run scripts/archive_invoices.py` (dry run) / `--apply` to move files
**Config:** INVOICE_SRC env var or --src; --dst defaults to ./archive
**Schedule:** launchd agent com.acme.archive-invoices, weekdays 06:15 (not yet installed)
**Exit codes:** 0 ok, 1 some files failed, 2 bad config
**Tested:** pytest tests/test_archive_invoices.py (plan, idempotency, missing source) What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Python Automation Scripts"
short_description: "Scripts that are safe to run twice, and at 3 a.m."
default_prompt: "$python-automation-scripts Write a Python script that moves processed invoices into year/month folders every morning" 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.