{"slug": "a-launchd-job-that-fires-once-on-deprecation-day-then-deletes-itself-and-the-4-i", "title": "A launchd Job That Fires Once on Deprecation Day, Then Deletes Itself — and the 4 Pitfalls I Hit", "summary": "A developer created a self-deleting launchd job for macOS that automatically updates the Claude model configuration when Fable 5 shuts down on 2026-07-07. The job uses a date gate, a jq-based JSON rewrite with backup, and a self-unload mechanism, addressing four pitfalls encountered during implementation.", "body_md": "This is a follow-up to my earlier post, \"[Automating a config migration with a one-shot launchd job](https://zenn.dev/bokuwalily/articles/one-shot-launchd-migration).\" This time the trigger is an **external event with a known end-of-life date** (Fable 5 shutting down on 2026-07-07), and the question is how to design a launchd job you can **set up today, have it fire only on that day, and have it remove itself once it's done**.\n\nSome deprecations come with a date stamped on them, and hand-editing a config on that exact day is the kind of chore you forget. But I also didn't want a script waking up every morning to rewrite the same JSON for no reason. What I landed on was a three-part set: **a date gate, a backed-up jq rewrite, and a self-unload**.\n\nRight now `~/.claude/settings.json`\n\nsays this:\n\n```\n{\n  \"model\": \"claude-fable-5[1m]\",\n  ...\n}\n```\n\nThe moment I found out Fable 5 ends on 2026-07-07, putting a calendar reminder to hand-edit that `\"model\"`\n\nvalue felt too flimsy — I'd forget it. On the other hand, a daemon that checks the date on every launch is overkill. What I wanted was a job I could **set once and stop thinking about, that fires when the day arrives and disappears afterward**.\n\nlaunchd can fire at specified times via `StartCalendarInterval`\n\n. But there's no way to express \"exactly once at 9:00 on 7/7\" — you only get recurrence or fixed date components. The standard macOS launchd move is to specify multiple slots and absorb the duplication with idempotency.\n\nHere's `~/.claude/scripts/model-transition-0707.sh`\n\nin full (comments trimmed).\n\n``` bash\n#!/bin/bash\nset -uo pipefail\nSETTINGS=\"$HOME/.claude/settings.json\"\nLOG=\"$HOME/.claude/logs/model-transition.log\"\nPLIST=\"$HOME/Library/LaunchAgents/com.shun.model-transition-0707.plist\"\n\nlog() { echo \"[$(date '+%F %T')] $*\" >> \"$LOG\"; }\n\n# ① 日付ゲート\nif [ \"$(date +%Y%m%d)\" -lt 20260707 ]; then\n  log \"skip: before 2026-07-07\"; exit 0\nfi\n\n# ② バックアップ付き jq 書き換え\ncurrent=$(jq -r '.model // empty' \"$SETTINGS\")\nif echo \"$current\" | grep -qi 'fable'; then\n  cp \"$SETTINGS\" \"$SETTINGS.bak-model-transition\"\n  jq '.model = \"opus\"' \"$SETTINGS\" > \"$SETTINGS.tmp\" \\\n    && jq . \"$SETTINGS.tmp\" > /dev/null \\\n    && mv \"$SETTINGS.tmp\" \"$SETTINGS\"\n  log \"switched model: $current -> opus\"\n  /usr/bin/osascript -e \\\n    'display notification \"Fable 5終了に伴いデフォルトモデルをOpusへ切替えました\" with title \"Claude model transition\"' \\\n    >/dev/null 2>&1 || true\nelse\n  log \"no-op: model is already '$current'\"\nfi\n\n# ③ 自己 unload\nlaunchctl unload \"$PLIST\" 2>/dev/null || true\nlog \"done (job unloaded)\"\n```\n\nLet's walk through the three parts in order.\n\n```\nif [ \"$(date +%Y%m%d)\" -lt 20260707 ]; then\n  log \"skip: before 2026-07-07\"; exit 0\nfi\n```\n\n`date +%Y%m%d`\n\nproduces a numeric string you can compare as an integer. `20260706 < 20260707`\n\n→ skip. That's all there is to it.\n\nWhy does this matter? Because the plist starts firing the instant you `launchctl load`\n\nit today. If the 6:50 AM slot comes around right after registration, that firing needs to be a no-op. Without the date gate, you'd get a misfire on the very day you plant the job: it would try to rewrite the model even though the value isn't `fable`\n\nyet.\n\nNote\n\nNumeric comparison with`date +%Y%m%d`\n\nworks as-is under macOS's`/bin/bash`\n\n.`-lt`\n\nis an arithmetic comparison, so as long as the strings are the same length, lexicographic and integer ordering give the same result.\n\n`jq`\n\nrewrite with a backup\n\n```\ncp \"$SETTINGS\" \"$SETTINGS.bak-model-transition\"\njq '.model = \"opus\"' \"$SETTINGS\" > \"$SETTINGS.tmp\" \\\n  && jq . \"$SETTINGS.tmp\" > /dev/null \\\n  && mv \"$SETTINGS.tmp\" \"$SETTINGS\"\n```\n\nThis breaks into three steps.\n\n| Step | Purpose |\n|---|---|\n`cp ... .bak-model-transition` |\nKeep the original as it was before the rewrite |\n`jq '.model = \"opus\"' > .tmp` |\nWrite out to a temp file |\n`jq . .tmp > /dev/null` |\nVerify the generated JSON isn't corrupt |\n`mv .tmp settings.json` |\nReplace the original only after verification passes |\n\nIf you write `jq ... settings.json > settings.json`\n\ndirectly, the original file is truncated to empty the moment the shell opens the redirect target. Going through a temp file is the basic pattern for avoiding redirect destruction. It also matters that the `&&`\n\nchaining means `mv`\n\nnever runs if verification fails.\n\nThe reason I test with `grep -qi 'fable'`\n\n— case-insensitive — is to cover `\"claude-fable-5[1m]\"`\n\nas well as any future variant spelling. Here's the value actually sitting in settings.json:\n\n```\n\"model\": \"claude-fable-5[1m]\"\n```\n\nAfter the rewrite it's just `\"opus\"`\n\n(an alias, not a model ID — this follows the \"don't hardcode model IDs in scripts\" policy from my CLAUDE.md).\n\n```\nlaunchctl unload \"$PLIST\" 2>/dev/null || true\nlog \"done (job unloaded)\"\n```\n\n`launchctl unload <plist>`\n\ndetaches that job from the daemon. The plist file itself stays on disk, so you can re-register it with `launchctl load`\n\nif you need to.\n\nThe `2>/dev/null || true`\n\nis there so an already-unloaded state doesn't abort with an error. Combined with the idempotent design described below, it guarantees the script is safe no matter how many times it's called.\n\nWarning\n\n`launchctl unload`\n\ndetaches the job immediately, even while it's running. That's exactly why the call sits at the end of the script — if you unload before finishing the rewrite, you cut yourself off mid-operation.\n\n```\n<key>StartCalendarInterval</key><array>\n  <dict><key>Hour</key><integer>6</integer><key>Minute</key><integer>50</integer></dict>\n  <dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>50</integer></dict>\n  <dict><key>Hour</key><integer>20</integer><key>Minute</key><integer>50</integer></dict>\n</array>\n```\n\nThree slots: 6:50, 12:50, and 20:50. Why not just one? Because launchd skips slots that fall while the Mac is asleep. If I sleep through the morning slot, the midday or evening one can still pick it up.\n\nThe firing flow on 7/7 looks like this:\n\n```\n6:50  → 日付ゲート通過 → fable 検出 → opus に書き換え → unload → ジョブ消滅\n12:50 → ジョブが存在しないので発火しない（unload済み）\n20:50 → 同上\n```\n\nOn 7/6 and earlier, each slot just leaves:\n\n```\nskip: before 2026-07-07\n```\n\nin the log and exits 0 immediately. No rewrite at all.\n\nDrawn out, it looks like this:\n\n```\n7/5         7/6         7/7\n6:50  skip  6:50  skip  6:50  書換+unload ←ここで終了\n12:50 skip  12:50 skip  12:50 (消滅)\n20:50 skip  20:50 skip  20:50 (消滅)\n```\n\nIdempotency is what makes \"configure multiple slots and reject early firings with the date gate\" work.\n\n`date +%Y%m%d`\n\ncomparison with a string `<`\n\n`[[ ]]`\n\n, that's lexicographic ordering, so I switched to `-lt`\n\n. With consistent 8-digit zero padding there's no actual harm, but use the arithmetic comparison that states the intent clearly.`/tmp/`\n\n`mv`\n\ncrosses filesystems, the rename can fail. Putting it in the same directory (`$HOME/.claude/`\n\n) guarantees the same fs.`launchctl unload`\n\n`com.shun.model-transition-0707`\n\n), or you get \"No such process.\"`StandardErrorPath`\n\n`log()`\n\nwrites to its own log file, but `StandardErrorPath`\n\nis still needed as the destination for output when the script itself dies on a syntax error.`[ \"$(date +%Y%m%d)\" -lt YYYYMMDD ]`\n\n, turns every firing between setup day and the target date into a skip`jq`\n\nrewrite`launchctl unload $PLIST`\n\nafter successFor anything with a fixed deprecation date, the best move is to plant it the day you find out and then forget about it. It's more reliable than a calendar entry, and easier to cancel than cron.\n\nNext time I might write about how to read the logs this job leaves behind to confirm the migration succeeded — or, if it failed, the recovery procedure from the backup.\n\nWhat deprecation date do you currently have sitting in a calendar reminder instead of in a script?\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/a-launchd-job-that-fires-once-on-deprecation-day-then-deletes-itself-and-the-4-i", "canonical_source": "https://dev.to/bokuwalily/a-launchd-job-that-fires-once-on-deprecation-day-then-deletes-itself-and-the-4-pitfalls-i-hit-3p3f", "published_at": "2026-08-29 00:00:04+00:00", "updated_at": "2026-08-29 00:18:55.710506+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["launchd", "jq", "Fable 5", "Claude", "macOS"], "alternates": {"html": "https://wpnews.pro/news/a-launchd-job-that-fires-once-on-deprecation-day-then-deletes-itself-and-the-4-i", "markdown": "https://wpnews.pro/news/a-launchd-job-that-fires-once-on-deprecation-day-then-deletes-itself-and-the-4-i.md", "text": "https://wpnews.pro/news/a-launchd-job-that-fires-once-on-deprecation-day-then-deletes-itself-and-the-4-i.txt", "jsonld": "https://wpnews.pro/news/a-launchd-job-that-fires-once-on-deprecation-day-then-deletes-itself-and-the-4-i.jsonld"}}