{"slug": "retiring-a-claude-code-model-unattended-with-launchd-a-self-destructing-job-that", "title": "Retiring a Claude Code Model Unattended with launchd: A Self-Destructing Job That Auto-Patches settings.json", "summary": "A developer automated the migration of Claude Code's model setting ahead of Fable 5's end of life using a self-destructing launchd job. The script rewrites the model field in settings.json, fires a desktop notification, and unregisters itself via launchctl unload. A date guard prevents premature execution due to launchd's catch-up behavior.", "body_md": "This is another entry in my \"Claude Code environment\" series. Last time, in [Multi-slot launchd retry design](https://zenn.dev/bokuwalily/articles/multi-slot-launchd-retry), I wrote about the \"idempotent morning/noon/night triple-fire\" pattern. This time I'm applying it to something concrete: **the \"disposable migration job\" I set up ahead of Fable 5's end of life (2026-07-07)**, and I'm publishing the full implementation.\n\nIt rewrites the `model`\n\nfield in `settings.json`\n\nwith `jq`\n\n, fires a desktop notification, and finally removes its own registration with `launchctl unload`\n\nand disappears. The design wraps launch, execution, and self-destruction into a single script. This isn't specific to model migration — it's a general-purpose pattern for \"a job that changes a setting exactly once on a specific day and then vanishes,\" so I'll show all of the actual code.\n\nIf your environment has `\"model\": \"claude-fable-5\"`\n\nwritten in Claude Code's `~/.claude/settings.json`\n\n, and that setting lingers past the EOL date, you quietly end up with unintended behavior. No error is raised — it might be running on a fallback or a different model — but **you lose track of which model you're actually using**.\n\nOpening `settings.json`\n\nby hand and editing it is the simplest approach, but getting a human to line up \"on the exact day,\" \"without forgetting,\" and \"exactly once\" is surprisingly hard. I delegated it to launchd and automated it.\n\n```\n1. Date guard        → if before 7/7, exit 0 immediately (catch-up firing defense)\n2. Patch settings.json → backup → jq rewrite → JSON validation → mv\n3. Notify + self-unload → notify via osascript → remove self with launchctl unload\n```\n\nThe core of \"disposable\" is that **step 3 unregisters the job itself**. This ensures there's no firing from that point forward. However, the plist file is not deleted — it stays in `~/Library/LaunchAgents/`\n\n. I keep it so that running `launchctl load`\n\nagain can bring it back (a comment in the script even notes \"keep the plist = re-registerable\").\n\nlaunchd has a catch-up behavior where, when a Mac boots up, it retroactively runs past slots that \"should have fired.\" If you register the job before 7/7, there's a chance it fires early at the moment of a Mac reboot. The very first line stops this.\n\n```\n# Do nothing if before 7/7 (catch-up firing defense)\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\nreturns an integer in `20260707`\n\nformat, so you can decide with a numeric comparison. The actual processing only runs for the first time on or after 7/7.\n\nWithout this date guard, the script runs every time you register it before 7/7 and reboot the Mac. Catch-up is behavior specific to\n\n`StartCalendarInterval`\n\njobs, so always keep it in mind when building scheduled jobs.\n\nThe model rewrite is done as an atomic four-step operation.\n\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\" \\\n    && jq . \"$SETTINGS.tmp\" > /dev/null \\\n    && mv \"$SETTINGS.tmp\" \"$SETTINGS\"\n  log \"switched model: $current -> opus\"\nelse\n  log \"no-op: model is already '$current'\"\nfi\n```\n\nThe intent of each step:\n\n`jq -r '.model // empty'`\n\n— returns an empty string if the `.model`\n\nkey doesn't exist. This keeps the string `null`\n\nfrom flowing downstream.`grep -qi 'fable'`\n\n— rewrites only when the value contains `fable`\n\n, case-insensitively. If it has already been migrated to another model, it falls into the `else`\n\nbranch, writes only a `no-op`\n\nlog, and exits (idempotent).`cp \"$SETTINGS\" \"$SETTINGS.bak-model-transition\"`\n\n— a backup taken before the rewrite, for recovery on failure.`jq . \"$SETTINGS.tmp\" > /dev/null`\n\n— validates that the rewritten `.tmp`\n\nis valid JSON before the `mv`\n\n. This keeps broken JSON from being placed on the production path.Since the script declares `set -uo pipefail`\n\nat the top, if any part of the `&&`\n\nchain fails, the `mv`\n\ndoesn't run.\n\nThe reason for inserting\n\n`jq . file > /dev/null`\n\nvalidation is to stop the`mv`\n\nfrom`.tmp`\n\nto production if`jq`\n\n's output somehow becomes broken JSON. With this structure — which embeds a string literal instead of using`--argjson`\n\n— it's actually unlikely to happen, but keeping it as a habit makes it safe when reusing the pattern in other rewrite scripts.\n\nOnce the rewrite is complete, it sends a desktop notification via `osascript`\n\n.\n\n```\n/usr/bin/osascript -e 'display notification \"Fable 5終了に伴いデフォルトモデルをOpusへ切替えました\" with title \"Claude model transition\"' \\\n  >/dev/null 2>&1 || true\n```\n\nThe `|| true`\n\nis there because I don't want a failed notification to mark the whole job as an error. The notification is purely a report to a human, not the main body of the work.\n\nOnce its job is done, it unloads itself.\n\n```\n# Once the job is done, remove it (keep the plist = re-registerable)\nlaunchctl unload \"$PLIST\" 2>/dev/null || true\nlog \"done (job unloaded)\"\n```\n\nThe `PLIST`\n\nvariable is defined at the top of the script as `PLIST=\"$HOME/Library/LaunchAgents/com.shun.model-transition-0707.plist\"`\n\n. `launchctl unload`\n\ndoes not delete the plist file, so the file remains in `~/Library/LaunchAgents/`\n\n. Running `launchctl load \"$PLIST\"`\n\nnext time re-registers it instantly.\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>\n    <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 slots a day, at 6:50, 12:50, and 20:50. It's a direct application of the multi-slot design from my previous article. Once the first run completes and self-unloads, the second and later runs don't fire. Even if the Mac is asleep and misses 6:50, 12:50 and 20:50 can pick it up.\n\nBecause `StandardErrorPath`\n\nis set in the plist, you don't need to write stderr redirection inside the script. When debugging, look at `~/.claude/logs/model-transition.err`\n\n.\n\n`jq`\n\nnot on PATH, exit 127`/opt/homebrew/bin`\n\nisn't in the plist's `EnvironmentVariables.PATH`\n\n, the Homebrew-installed `jq`\n\nisn't found. This is the classic pattern where manual runs from the terminal succeed but only launchd breaks. A staple Apple Silicon trap.`.model`\n\nkey`jq -r '.model'`\n\n, the string `null`\n\nis returned; `grep -qi 'fable'`\n\nwould fail to match and be fine, but considering future key-name changes or omissions, I made the empty-string fallback explicit with `// empty`\n\n.`launchctl unload`\n\nruns, that job's schedule is gone. It won't re-fire without another `launchctl load`\n\n.`StandardErrorPath`\n\n, launchd discards stderr to /dev/null or some unpredictable place. That makes debugging impossible, so always set it.This job looks specific to Fable 5's EOL, but it can be reused as-is whenever these three requirements line up.\n\n| Requirement | Implementation in this job |\n|---|---|\n| Run exactly once on or after a specific day | date guard via `date +%Y%m%d` comparison |\n| Idempotent (same result no matter how many times it runs) | check current value before rewriting |\n| Automatically disappear once complete | `launchctl unload \"$PLIST\"` |\n\nApplication examples:\n\nA \"cron that should run only once\" fires every time when persistently registered with cron or launchd, making idempotency tedious to guarantee. Designing it to \"disappear once it runs\" via self-unload is simpler than writing dedupe handling for a persistent job.\n\n`date +%Y%m%d`\n\ncomparison) neutralizes catch-up firing — include it from the start.`jq`\n\npatching`grep -qi 'fable'`\n\n) makes it idempotent. It's safe to re-run even in an already-migrated environment.`launchctl unload \"$PLIST\"`\n\n`jq`\n\n, don't forget `/opt/homebrew/bin`\n\nin the plist's PATHNext time, I plan to write about `automation-health.sh`\n\n, which checks the liveness of the entire launchd setup — including disposable jobs like this one — with a single command.\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/retiring-a-claude-code-model-unattended-with-launchd-a-self-destructing-job-that", "canonical_source": "https://dev.to/bokuwalily/retiring-a-claude-code-model-unattended-with-launchd-a-self-destructing-job-that-auto-patches-4jee", "published_at": "2026-07-19 05:00:05+00:00", "updated_at": "2026-07-19 05:27:41.961190+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models"], "entities": ["Claude Code", "Fable 5", "launchd", "jq", "macOS"], "alternates": {"html": "https://wpnews.pro/news/retiring-a-claude-code-model-unattended-with-launchd-a-self-destructing-job-that", "markdown": "https://wpnews.pro/news/retiring-a-claude-code-model-unattended-with-launchd-a-self-destructing-job-that.md", "text": "https://wpnews.pro/news/retiring-a-claude-code-model-unattended-with-launchd-a-self-destructing-job-that.txt", "jsonld": "https://wpnews.pro/news/retiring-a-claude-code-model-unattended-with-launchd-a-self-destructing-job-that.jsonld"}}