{"slug": "planting-a-future-breaking-change-today-a-launchd-timer-job-that-deletes-itself", "title": "Planting a Future Breaking Change Today: A launchd Timer Job That Deletes Itself When Done", "summary": "A developer created a self-destructing launchd timer job that automatically updates a Claude configuration file on the Fable 5 end-of-life date (2026-07-07) and then unloads itself. The job uses a date gate, a jq rewrite with backup, and self-unload to ensure it runs exactly once on the target day without manual intervention.", "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).\" Some breaking changes come with a known expiration date, and you can prepare for them long before they land. This time the external event was the end-of-life of Fable 5 (2026-07-07), and I'll walk through how I designed a launchd job you set up today, that fires only on the target day, and that removes itself once it's done.\n\nThe whole thing started with the thought, \"manually fixing this on the shutdown day is going to be annoying.\" But I also didn't want to run a script every morning that needlessly rewrites JSON. What I landed on was a three-part set: **a date gate, a jq rewrite with a backup, and self-unload.**\n\nRight now, `~/.claude/settings.json`\n\nlooks like this:\n\n```\n{\n  \"model\": \"claude-fable-5[1m]\",\n  ...\n}\n```\n\nThe moment I learned Fable 5 would end on 2026-07-07, creating a calendar reminder to manually rewrite this `\"model\"`\n\nfelt too flimsy — I'll forget. On the other hand, making \"a daemon that checks the date every time it boots\" is overkill. What I wanted was a job I could **set once and leave alone, that runs when the day arrives, and then disappears.**\n\nlaunchd can fire at a specified time via `StartCalendarInterval`\n\n. But you can't express \"just once at 9:00 on 7/7\"; you need a combination of recurring and date-fixed slots. Specifying multiple slots and absorbing the redundancy with idempotency is the standard trick on macOS launchd.\n\nHere's the full `~/.claude/scripts/model-transition-0707.sh`\n\n(comments omitted).\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 me explain 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\ncan be compared as an integer since it's a numeric string. `20260706 < 20260707`\n\n→ skip. That's all.\n\nWhy this matters: the plist starts firing the instant you `launchctl load`\n\nit today. Even if the 6:50 AM slot fires on the same day you register it, you need it to be a no-op. Without the date gate, on the very day you plant it you'd hit \"trying to rewrite even though the model isn't fable\" — a misfire.\n\n`date +%Y%m%d`\n\nnumeric comparison works as-is in macOS's`/bin/bash`\n\n.`-lt`\n\nis an arithmetic comparison, so when the string lengths are equal, lexicographic order and integer order give the same result.\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 splits into three steps.\n\n| Step | Purpose |\n|---|---|\n`cp ... .bak-model-transition` |\nKeep the original before rewriting |\n`jq '.model = \"opus\"' > .tmp` |\nWrite out to a temp file |\n`jq . .tmp > /dev/null` |\nVerify the generated JSON isn't broken |\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's also important that the `&&`\n\nchaining means `mv`\n\nisn't run if verification fails.\n\nUsing `grep -qi 'fable'`\n\nfor a case-insensitive check is so it handles `\"claude-fable-5[1m]\"`\n\nand any future variant spellings. The value actually sitting in settings.json is this:\n\n```\n\"model\": \"claude-fable-5[1m]\"\n```\n\nAfter the rewrite it becomes just `\"opus\"`\n\n(an alias, not a model ID. This follows the \"don't hardcode model IDs in scripts\" policy from 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 remains, so you can re-register it with `launchctl load`\n\nif needed.\n\nI add `2>/dev/null || true`\n\nso that if it's already unloaded, it doesn't abort with an error. Combined with the idempotent design described below, this guarantees \"safe no matter how many times it's called.\"\n\nNote:`launchctl unload`\n\ndetaches the job immediately, even while it's running. That's why I call it at the very end of the script — if you didn't remove it only after finishing the rewrite, you'd cut yourself off mid-process.\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 once?\" Because launchd skips any slot while the Mac is asleep. Even if the morning slot passed while asleep, this lets the noon or evening slot catch it.\n\nThe firing flow (on 7/7) is as follows.\n\n```\n6:50  → 日付ゲート通過 → fable 検出 → opus に書き換え → unload → ジョブ消滅\n12:50 → ジョブが存在しないので発火しない（unload済み）\n20:50 → 同上\n```\n\nEach slot before 7/6 leaves this in the log:\n\n```\nskip: before 2026-07-07\n```\n\nand immediately `exit 0`\n\n. No rewrite happens at all.\n\nDiagrammed, 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\nThanks to idempotency, \"set multiple slots and reject early firing with the date gate\" holds together.\n\n`date +%Y%m%d`\n\ncomparison with string comparison `<`\n\n`[[ ]]`\n\nthis becomes lexicographic string order, so I switched to `-lt`\n\n. There's no real harm if the 8-digit zero-padding is consistent, but I use arithmetic comparison to make the intent clear.`/tmp/`\n\n`mv`\n\ncan fail to rename across filesystems. Placing it in the same directory (`$HOME/.claude/`\n\n) guarantees the same fs.`com.shun.model-transition-0707`\n\n), or you get \"No such process.\"`StandardErrorPath`\n\n`log()`\n\ninside the script writes to its own log file, but `StandardErrorPath`\n\nis still needed as the output destination for when the script itself dies with a syntax error.`[ \"$(date +%Y%m%d)\" -lt YYYYMMDD ]`\n\nskips all early firing from the day you plant it onward.`launchctl unload $PLIST`\n\nFor things with a fixed deprecation date, \"plant it the day you notice and forget about it\" is the best approach. It's more reliable than putting an event on the calendar, 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\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/planting-a-future-breaking-change-today-a-launchd-timer-job-that-deletes-itself", "canonical_source": "https://dev.to/bokuwalily/planting-a-future-breaking-change-today-a-launchd-timer-job-that-deletes-itself-when-done-53li", "published_at": "2026-07-11 03:08:06+00:00", "updated_at": "2026-07-11 03:41:26.970977+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Fable 5", "Claude", "launchd", "jq"], "alternates": {"html": "https://wpnews.pro/news/planting-a-future-breaking-change-today-a-launchd-timer-job-that-deletes-itself", "markdown": "https://wpnews.pro/news/planting-a-future-breaking-change-today-a-launchd-timer-job-that-deletes-itself.md", "text": "https://wpnews.pro/news/planting-a-future-breaking-change-today-a-launchd-timer-job-that-deletes-itself.txt", "jsonld": "https://wpnews.pro/news/planting-a-future-breaking-change-today-a-launchd-timer-job-that-deletes-itself.jsonld"}}