{"slug": "auto-migrating-claude-code-with-a-throwaway-launchd-job-a-script-that-deletes-is", "title": "Auto-Migrating Claude Code with a Throwaway launchd Job — a Script That Deletes Itself When Its Work Is Done", "summary": "A developer created a self-deleting launchd job that automatically migrates Claude Code's model setting from Fable 5 to Opus on the shutdown date of 2026-07-07. The script runs exactly once, updates settings.json with jq, sends a notification, then unloads itself via launchctl to avoid permanent no-op processes.", "body_md": "This continues my \"Claude Code environment\" series that started with [showing rate limits live in the status line](https://zenn.dev/bokuwalily/articles/statusline-live-limits). This time it's a one-shot model-migration job — the **\"throwaway job\" pattern, where the job removes its own plist with launchctl unload once it's finished**.\n\nFable 5 shuts down on 2026-07-07. If your Claude Code `settings.json`\n\nhas `model: fable-5`\n\n, it will keep pointing at that stale ID from that day onward. You could just run `jq`\n\nby hand, but **missing the shutdown moment, or being mid-task and forgetting**, is almost guaranteed to happen. So I handed it off to launchd.\n\nWriting a job that runs `jq '.model = \"opus\"' ~/.claude/settings.json`\n\nevery day is trivial, but the day after the switch is done, a no-op process runs every single day. **Having something written down permanently for a task you only do once** just feels wrong.\n\nThe ideal is a job that \"runs exactly once on 07-07 and then disappears.\"\n\nIt breaks into four steps.\n\n| Step | Purpose |\n|---|---|\n| Date guard | Neutralize catch-up firing and early launches |\n| jq surgery | Replace only the `model` key in settings.json |\n| osascript notification | Let a human know the unattended run happened |\n`launchctl unload` |\nDe-register the job to make it single-use |\n\nHere is the full text of `~/.claude/scripts/model-transition-0707.sh`\n\n.\n\n``` bash\n#!/bin/bash\n# model-transition-0707.sh — 2026-07-07にFable 5が終了するため、settings.jsonのmodelをopusへ自動切替\n# 冪等: 7/7以降かつ model が fable の時だけ書き換える。成功したら自分のplistをunload。\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# 7/7より前なら何もしない（catch-up発火対策）\nif [ \"$(date +%Y%m%d)\" -lt 20260707 ]; then\n  log \"skip: before 2026-07-07\"; exit 0\nfi\n\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\" && jq . \"$SETTINGS.tmp\" > /dev/null && mv \"$SETTINGS.tmp\" \"$SETTINGS\"\n  log \"switched model: $current -> opus\"\n  /usr/bin/osascript -e 'display notification \"Fable 5終了に伴いデフォルトモデルをOpusへ切替えました\" with title \"Claude model transition\"' >/dev/null 2>&1 || true\nelse\n  log \"no-op: model is already '$current'\"\nfi\n\n# 役目を終えたらジョブを外す（plistは残す＝再登録可能）\nlaunchctl unload \"$PLIST\" 2>/dev/null || true\nlog \"done (job unloaded)\"\nif [ \"$(date +%Y%m%d)\" -lt 20260707 ]; then\n  log \"skip: before 2026-07-07\"; exit 0\nfi\n```\n\nlaunchd will sometimes catch-up-fire scheduled jobs when the Mac boots. There's also the case where the first firing arrives right after you `launchctl load`\n\n. By comparing `date +%Y%m%d`\n\nas an integer against `20260707`\n\n, no matter when it's launched, it always bails out at zero cost if the date is before 07-07.\n\n```\ncp \"$SETTINGS\" \"$SETTINGS.bak-model-transition\"\njq '.model = \"opus\"' \"$SETTINGS\" > \"$SETTINGS.tmp\" && jq . \"$SETTINGS.tmp\" > /dev/null && mv \"$SETTINGS.tmp\" \"$SETTINGS\"\n```\n\nGoing through `.tmp`\n\nis so that if `jq`\n\nfails it won't corrupt the original file. After writing out, it verifies syntax with `jq .`\n\nand then does an atomic `mv`\n\n. Since there's a backup, worst case you can restore with `cp \"$SETTINGS.bak-model-transition\" \"$SETTINGS\"`\n\n.\n\nThe reason for matching case-insensitively with `grep -qi 'fable'`\n\nis to catch any of `fable-5`\n\n, `claude-fable-5`\n\n, or `FABLE`\n\n. Using `'.model // empty'`\n\nmakes it return an empty string instead of null when the `model`\n\nkey itself is absent (i.e. the default is in use), which prevents an unbound variable error under `set -uo pipefail`\n\n.\n\n```\nlaunchctl unload \"$PLIST\" 2>/dev/null || true\nlog \"done (job unloaded)\"\n```\n\nWhether it switched the model or it was already opus (`no-op`\n\n), whichever path it takes, it always reaches this line at the end. The `|| true`\n\nabsorbs the error when it's already been unloaded.\n\n:::message\n\n`launchctl unload`\n\ndoes not delete the plist file. `~/Library/LaunchAgents/com.shun.model-transition-0707.plist`\n\nstays in place, so you can re-register it anytime with `launchctl load`\n\n. What's \"throwaway\" is only the job's registration state.\n\n:::\n\n`~/Library/LaunchAgents/com.shun.model-transition-0707.plist`\n\n(home path shown with `~`\n\nnotation):\n\n```\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\"><dict>\n  <key>Label</key><string>com.shun.model-transition-0707</string>\n  <key>ProgramArguments</key><array>\n    <string>/bin/bash</string>\n    <string>~/.claude/scripts/model-transition-0707.sh</string>\n  </array>\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  <key>EnvironmentVariables</key><dict>\n    <key>PATH</key><string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>\n  </dict>\n  <key>StandardErrorPath</key><string>~/.claude/logs/model-transition.err</string>\n</dict></plist>\n```\n\nThree times a day (06:50 / 12:50 / 20:50) are specified via the `StartCalendarInterval`\n\narray. Even if you miss the morning firing on 07-07, you can catch it at noon or in the evening. Because of the date guard, everything before 07-07 is skipped, and on and after 07-07 the first run does the switch and immediately self-destructs, so no later firings arrive.\n\nMaking `StartCalendarInterval`\n\nan array lets you bundle multiple times into a single plist. Be careful not to confuse it with the seconds-interval `StartInterval`\n\n.\n\n:::message\n\nThe paths written in `ProgramArguments`\n\nneed to be full paths, because launchd does not expand `~`\n\n. The `~`\n\nnotation above is for explanation; the actual plist needs to contain the paths with `$HOME`\n\nalready expanded.\n\n:::\n\n```\n# 登録\nlaunchctl load ~/Library/LaunchAgents/com.shun.model-transition-0707.plist\n\n# 確認\nlaunchctl list | grep model-transition\n\n# 手動テスト（07-07より前なら \"skip: before 2026-07-07\" でログに記録されて終わる）\n~/.claude/scripts/model-transition-0707.sh\ntail -5 ~/.claude/logs/model-transition.log\n```\n\nIf you want to try the actual switch locally, temporarily comment out the date-guard line in the script and run it (a backup is taken in `settings.json.bak-model-transition`\n\n).\n\nThe log from a normal firing at 06:50 on 07-07:\n\n``` php\n[2026-07-07 06:50:02] switched model: claude-fable-5 -> opus\n[2026-07-07 06:50:02] done (job unloaded)\n```\n\nIt's done in two lines, and because the job is gone, the later 12:50 and 20:50 don't fire.\n\n`set -uo pipefail`\n\n, a missing `model`\n\nkey kills the script with an unbound variable`'.model // empty'`\n\nreturn an empty string. Even if `grep -qi`\n\ngets an empty string, it's false and bails, so that's fine.`.tmp`\n\nis left behind by a kill, the next launch references the old contents`mv`\n\nis only reached after the syntax check, so it normally isn't left behind, but if you're worried you could add a guard at the top that deletes `.tmp`\n\nif it exists at launch.`launchctl unload`\n\nin an already-unloaded state exits with an error`|| true`\n\n.`/opt/homebrew/bin`\n\nin the plist's `EnvironmentVariables.PATH`\n\n.`/usr/bin/osascript`\n\n. `>/dev/null 2>&1 || true`\n\nkeeps a failed notification from halting the whole script.`date +%Y%m%d`\n\n) neutralizes catch-up firing and early launches.`.tmp`\n\n`launchctl unload \"$PLIST\"`\n\nA \"time-limited one-shot job\" isn't limited to model migration — it's a pattern usable for any work with a fixed end: deprecations, campaign periods, migration schedules, and the like. You can handle the next migration just by changing the plist's label, date, and path.\n\nNext time I plan to write about managing these launchd jobs as they accumulate — how to take stock of a `~/Library/LaunchAgents/`\n\ncluttered with scripts.\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/auto-migrating-claude-code-with-a-throwaway-launchd-job-a-script-that-deletes-is", "canonical_source": "https://dev.to/bokuwalily/auto-migrating-claude-code-with-a-throwaway-launchd-job-a-script-that-deletes-itself-when-its-e7k", "published_at": "2026-07-18 11:00:05+00:00", "updated_at": "2026-07-18 11:28:36.218699+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "large-language-models"], "entities": ["Claude Code", "Fable 5", "Opus", "launchd", "jq"], "alternates": {"html": "https://wpnews.pro/news/auto-migrating-claude-code-with-a-throwaway-launchd-job-a-script-that-deletes-is", "markdown": "https://wpnews.pro/news/auto-migrating-claude-code-with-a-throwaway-launchd-job-a-script-that-deletes-is.md", "text": "https://wpnews.pro/news/auto-migrating-claude-code-with-a-throwaway-launchd-job-a-script-that-deletes-is.txt", "jsonld": "https://wpnews.pro/news/auto-migrating-claude-code-with-a-throwaway-launchd-job-a-script-that-deletes-is.jsonld"}}