{"slug": "four-failures-that-made-a-weekly-launchd-job-actually-run", "title": "Four Failures That Made a Weekly launchd Job Actually Run", "summary": "A developer built a weekly launchd job that automatically distributes AI-learned skills from Claude Code's global skills folder into every local git repository, eliminating the need for manual copying. The script, named autoskills-sync, runs every Sunday at 6:10 AM, scans projects, filters exclusions, and installs skills via npx, logging results. The developer documented four failures encountered while getting the job to run unattended, including PATH configuration issues.", "body_md": "Every skill my AI setup learns lives in one folder on my laptop — and none of it reaches the repo I created yesterday. That gap is why I built a weekly job that pushes my accumulated skills into every project on the machine. This is what it does, and the four failures I hit getting it to run unattended.\n\nClaude Code's `~/.claude/skills/auto/`\n\nis essentially a personal \"habits library.\" Workarounds, completion criteria, and verification commands discovered mid-task get written out to skill files automatically by the AI, and can be referenced immediately on the next request — that's how the mechanism is designed.\n\nReality is a little different, though.\n\nSkills keep piling up in `.claude/skills/auto/`\n\n. But a project in a freshly created git repo, a side-gig job opened for the first time in weeks, a set of tools written in another language — those **don't have the skills at all to begin with**. Unless a human copies them by hand, or I type \"refer to that skill\" every single time, the habits I so carefully accumulated are completely dead in other projects.\n\n**The structure of the problem looks like this.**\n\n`.claude/skills/auto/`\n\n(global)`.agents/`\n\nor `.claude/skills/`\n\n\" (local)This isn't \"growing your environment,\" it's \"regrowing it every time.\" Once monthly revenue crosses a certain line, the number of concurrent jobs rises, and there are weeks where I cut two or three new repos. Each time, noticing the missing skills, copying manually, verifying — that work quietly eats time. Not the duration of a single tool call, but the opportunity cost of \"if that skill had been here, this would have taken three minutes.\"\n\nThe weekly auto-distribution script solves this. Early every Sunday morning, it scans all git repositories and pours the skills in. Without a human doing anything, the project you open on Monday has the latest skills in place.\n\n**Don't increase the amount of work — raise the baseline quality of the environment.** In building an autonomous Claude Code environment, this design philosophy has been the most effective one.\n\nOne important premise. \"Skills\" here means the files under `~/.claude/skills/auto/`\n\nthat I built up myself. Bundled skills and `~/.claude/skills/ecc/`\n\nare never touched. The distribution target is strictly my own habits library.\n\nHere's a bird's-eye view of the whole mechanism.\n\n```\n[launchd]\ncom.shun.autoskills-sync\n日曜 06:10 起動\n        |\n        v\n[autoskills-sync.sh]\n        |\n        +-- ① オンライン確認\n        |   curl -sf -m 8 https://registry.npmjs.org/\n        |   オフライン → exit 0（何もせず正常終了）\n        |\n        +-- ② プロジェクト探索\n        |   find ~ ~/dev -maxdepth 2\n        |     -name .git  →  親ディレクトリをリスト\n        |     -name package.json / pyproject.toml /\n        |           requirements.txt / go.mod /\n        |           Cargo.toml / pubspec.yaml /\n        |           skills-lock.json\n        |   sort -u で重複除去\n        |\n        +-- ③ 除外フィルタ\n        |   oss-trial/* / *-public / node_modules/*\n        |   Documents/ Library/ Applications/ go/\n        |   .claude/ config-snapshots/ claude-obsidian/\n        |   digital-products\n        |\n        +-- ④ 各プロジェクトへ配布\n        |   npx -y autoskills --yes\n        |   出力から「N skills installed」をパース\n        |\n        +-- ⑤ gitignore 追記（本番のみ）\n        |   .agents/ / .claude/skills/ / skills-lock.json\n        |\n        +-- ⑥ ログ記録\n            ~/.claude/logs/com.shun.autoskills-sync.log\n```\n\nlaunchd, the macOS job scheduler, loads plists placed in `~/Library/LaunchAgents/`\n\nand runs them automatically. The contents of `com.shun.autoskills-sync.plist`\n\nlook like this.\n\n```\n<key>StartCalendarInterval</key>\n<array>\n  <dict>\n    <key>Hour</key>\n    <integer>6</integer>\n    <key>Minute</key>\n    <integer>10</integer>\n    <key>Weekday</key>\n    <integer>0</integer>\n  </dict>\n</array>\n```\n\nA `Weekday`\n\nof `0`\n\nis Sunday. It fires at 6:10 every Sunday. Since `RunAtLoad`\n\nis `false`\n\n, it doesn't run the instant the plist is loaded. It waits until the next Sunday.\n\nPATH configuration is in there too.\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n  <key>PATH</key>\n  <string>~/.nvm/versions/node/v24.13.0/bin:\n          /opt/homebrew/bin:/opt/homebrew/sbin:\n          /usr/local/bin:/usr/bin:/bin:...</string>\n</dict>\n```\n\nlaunchd doesn't go through a shell, so your usual `.zshrc`\n\nisn't read. Unless you explicitly write the path to the nvm-managed Node.js into PATH, the `npx`\n\ncommand won't be found and the job fails. It's launched with `/bin/zsh -lc`\n\n, but even then `.zshrc`\n\nisn't read in launchd's environment, so this PATH injection is mandatory.\n\n`StandardOutPath`\n\nand `StandardErrorPath`\n\nboth point at `~/.claude/logs/com.shun.autoskills-sync.log`\n\n. The `log()`\n\nfunction inside the script appends to the same file, so logs are consolidated into one file.\n\n```\nif ! curl -sf -m 8 https://registry.npmjs.org/ >/dev/null 2>&1; then\n  log \"offline — skip\"; exit 0\nfi\n```\n\nThe trigger time can coincide with waking from sleep or with airplane mode. Calling `npx`\n\nat that point would just fail, so the first thing it does is check HTTP connectivity to npmjs. With `-m 8`\n\n, no response within 8 seconds is treated as offline, and it exits normally with `exit 0`\n\n. The reason it doesn't return an error code is that launchd sometimes uses an error exit as a retry trigger.\n\n```\nfind \"$HOME_DIR\" \"$HOME_DIR/dev\" -maxdepth 2 -name .git -type d 2>/dev/null \\\n  | sed 's|/\\.git$||'\n\nfind \"$HOME_DIR\" \"$HOME_DIR/dev\" -maxdepth 2 \\\n  \\( -name package.json -o -name pyproject.toml -o -name requirements.txt \\\n     -o -name go.mod -o -name Cargo.toml -o -name pubspec.yaml \\\n     -o -name skills-lock.json \\) \\\n  -not -path '*/node_modules/*' 2>/dev/null | sed -E 's|/[^/]+$||'\n```\n\nDiscovery is `-maxdepth 2`\n\nunder `~`\n\nand `~/dev`\n\n— two levels. Directories nested deeper than that are out of scope. This limit exists for both performance and deliberate scoping.\n\nTwo routes run in parallel, OR'd together: one that looks for the ** .git directory** and takes its parent, and one that looks for\n\n`sort -u`\n\nremoves duplicates. Something that isn't a git repository still qualifies if it has `package.json`\n\n, and a git repository without `package.json`\n\nwon't be picked up by the manifest route — the two routes together prevent misses.Supported languages are JavaScript/TypeScript (package.json), Python (pyproject.toml, requirements.txt), Go (go.mod), Rust (Cargo.toml), Flutter/Dart (pubspec.yaml), and the lock file Autoskills generates (skills-lock.json). In practice that covers virtually every personal-development stack.\n\nFor bash 3.2 compatibility, it avoids `mapfile`\n\n/`readarray`\n\nand fills the array with `while IFS= read -r line`\n\n. macOS's `/bin/bash`\n\ndefaults to 3.2. Even if you've installed bash 5 via Homebrew, if the script's first line is `#!/usr/bin/env bash`\n\n, `/bin/bash`\n\ngets invoked. This pitfall is spelled out in a comment too.\n\n```\n# NOTE: mapfile/readarray は macOS 標準 /bin/bash 3.2 に無いため使わない（3.2/5 両対応の while-read）\nis_excluded() {\n  local d=\"$1\"\n  [ \"$d\" = \"$HOME_DIR\" ] && return 0\n  [ \"$d\" = \"$HOME_DIR/dev\" ] && return 0\n  case \"$d\" in\n    */oss-trial/*)  return 0 ;;   # 第三者fork\n    *-public)       return 0 ;;   # 公開OSSミラー\n    */node_modules/*) return 0 ;;\n    \"$HOME_DIR\"/Documents/*|\"$HOME_DIR\"/Library/*|\"$HOME_DIR\"/Applications/*) return 0 ;;\n    \"$HOME_DIR\"/Public/*|\"$HOME_DIR\"/go|\"$HOME_DIR\"/go/*) return 0 ;;\n    */.claude/*|*config-snapshots*|*claude-obsidian*) return 0 ;;\n    \"$HOME_DIR\"/digital-products|\"$HOME_DIR\"/digital-products/*) return 0 ;;\n  esac\n  return 1\n}\n```\n\nThe exclusion rules are easier to read when split into four categories.\n\n**Protecting the root directories themselves.** `$HOME`\n\nand `$HOME/dev`\n\nare excluded as directories in their own right, because of the risk of overwriting configuration areas like `~/.claude/`\n\nor `~/.agents/`\n\n. These two directories are the starting points of discovery: their contents are in scope, but the parents themselves are not.\n\n**Protecting third-party code.** `*/oss-trial/*`\n\nis where I fork and experiment with other people's OSS repositories. Writing my own skills there means injecting changes unrelated to the repository owner's intent. The same goes for `*-public`\n\n, which points at public OSS mirrors. Running autoskills against these projects risks unintended `.gitignore`\n\nchanges or `skills-lock.json`\n\ngeneration, contaminating a public repository.\n\n**Protecting macOS system areas.** `Documents/`\n\n, `Library/`\n\n, `Applications/`\n\n, `Public/`\n\n, and `go/`\n\nare not code projects. Even if a `package.json`\n\nhappens to exist in them, they're excluded. `Library/`\n\ncontains a huge amount of app data unrelated to Autoskills, and running against it by mistake generates files of unclear purpose.\n\n**Protecting Claude Code's own configuration areas.** `.claude/`\n\n, `config-snapshots`\n\n, and `claude-obsidian`\n\nare Claude Code's config files, conversation logs, and the Obsidian vault. They're under git management, but they aren't targets for skill distribution. In particular, `~/.claude/`\n\nitself could get caught by discovery, so it's excluded explicitly.\n\n**Protecting content directories.** `digital-products`\n\nis a directory for content sales such as prompt collections, and has no code stack. There's no point putting skills in it, so it's excluded. The reason \"no stack\" is spelled out in a comment is so that whoever reads this configuration in the future understands the reason for the exclusion.\n\n```\nout=\"$(cd \"$d\" && npx -y autoskills ${DRY:---yes} 2>&1)\"\nn=\"$(echo \"$out\" | grep -oE '([0-9]+) skills installed' | grep -oE '^[0-9]+' | head -1)\"\n[ -z \"$n\" ] && n=\"$(echo \"$out\" | grep -oE 'Skills to install \\([0-9]+\\)' | grep -oE '[0-9]+' | head -1)\"\n[ -z \"$n\" ] && n=0\n```\n\nIt runs `npx -y autoskills --yes`\n\nin the project directory. `-y`\n\nskips npx's confirmation prompt; `--yes`\n\nskips autoskills' own interaction. `2>&1`\n\ncaptures standard error as well, storing all output in the `$out`\n\nvariable before parsing.\n\nTo handle the two output formats — \"N skills installed\" and \"Skills to install (N)\" — grep is written in two stages. If neither matches, it treats the value as `n=0`\n\n, logs projects with 0 as \"0 (skip),\" and moves right along to the next.\n\n`${DRY:---yes}`\n\nis bash parameter expansion. When the `DRY`\n\nvariable is non-empty (the `--dry-run`\n\nflag is present) it passes `--dry-run`\n\n; when empty it passes `--yes`\n\n. This lets the same script be reused for both manual runs and production runs.\n\n```\nif [ -z \"$DRY\" ] && (cd \"$d\" && git rev-parse --git-dir >/dev/null 2>&1); then\n  for pat in \".agents/\" \".claude/skills/\" \"skills-lock.json\"; do\n    grep -qxF \"$pat\" \"$d/.gitignore\" 2>/dev/null || echo \"$pat\" >> \"$d/.gitignore\"\n  done\nfi\n```\n\nThis runs only against git repositories where at least one skill was installed. It appends the three patterns `.agents/`\n\n, `.claude/skills/`\n\n, and `skills-lock.json`\n\nto `.gitignore`\n\n, but checks for existence first with `grep -qxF`\n\nso that lines already present aren't appended twice.\n\nThis step matters because it prevents the risk of accidentally committing skills to a team repository or a public repository. Skill files depend on a personal environment; in another developer's environment they're either meaningless or actively confusing.\n\nWith `--dry-run`\n\n, `DRY`\n\nis non-empty, so `[ -z \"$DRY\" ]`\n\nis false and the gitignore append is skipped. The principle that a dry run is read-only is enforced throughout.\n\nOn every run it appends timestamped entries to `~/.claude/logs/com.shun.autoskills-sync.log`\n\n.\n\n```\n[2026-07-13 06:10:03] ==== autoskills-sync start ====\n[2026-07-13 06:10:04]   lead-finder: 12 skills\n[2026-07-13 06:10:06]   affiliate-fc2: 8 skills\n[2026-07-13 06:10:08]   note-autolike: 0 (skip)\n[2026-07-13 06:10:09]   oss-trial: (excluded)\n[2026-07-13 06:10:10] ==== done: 2 projects / 20 skills, 1 excluded ====\n```\n\nThe completion line's format, `done: N projects / M skills, K excluded`\n\n, makes the number of projects distributed to, the total skill count, and the exclusion count visible at a glance. Since it runs only once a week, the log grows at a gentle pace.\n\n`set -uo pipefail`\n\n— why these three flags line up this way\nIt's the first line of the script.\n\n```\nset -uo pipefail\n```\n\n`-u`\n\n(nounset) halts with an error when an undefined variable is expanded. It prevents code like `rm -rf \"$UNDEFINED_DIR/\"`\n\nfrom running with a typo intact. That said, as described later, ** -u behaves unexpectedly against empty arrays**, so a separate guard turned out to be necessary.\n\n`-o pipefail`\n\nmakes the exit code of the whole pipeline \"the code of the first command that failed\" when an intermediate command in a pipeline fails. In multi-stage pipes like `grep -oE '...' | head -1`\n\n, it prevents the trap where the first grep matches nothing but `head -1`\n\nreturns `0`\n\n, making the whole thing look successful.\n\n`-e`\n\n(errexit) is deliberately omitted. The behavior of the `is_excluded`\n\nfunction returning `return 0`\n\n(excluded = true) looks, from the shell's perspective, like \"the command failed.\" With `-e`\n\non, the whole script terminates at the point that function is called. `-e`\n\nis treacherous for functions containing conditional branches. Error handling written as an explicit `|| { log \"...\"; exit 1; }`\n\nmisfires less.\n\n```\nout=\"$(cd \"$d\" && npx -y autoskills ${DRY:---yes} 2>&1)\"\n```\n\n`cd \"$d\" && npx ...`\n\nis wrapped in the command substitution `$()`\n\n. **This is to confine the effect of cd to a subshell.** Inside\n\n`$()`\n\nis an independent shell environment, so a `cd`\n\nin there doesn't affect the parent shell's current directory. Without this, from the second loop iteration onward, `npx`\n\ncould keep executing in the previous project's directory.The gitignore check part follows the same idea.\n\n```\nif [ -z \"$DRY\" ] && (cd \"$d\" && git rev-parse --git-dir >/dev/null 2>&1); then\n```\n\nThis one uses `()`\n\nrather than `$()`\n\n, but the purpose is the same. It functions as a one-liner for \"check whether $d is a git repo without cd-ing the parent shell.\" When you want to `cd`\n\nfor a conditional test but don't want that `cd`\n\ncarried into the loop, `()`\n\nis a simple and reliable means.\n\nAt first I tried to build the collection with `mapfile`\n\n.\n\n```\n# 書きたかったが書けなかった\nmapfile -t CANDIDATES < <(find ...)\n```\n\nmacOS's default `/bin/bash`\n\nis version 3.2. `mapfile`\n\n(aka `readarray`\n\n) is a bash 4.0-and-later feature, so running under `/bin/bash`\n\ngives you a plain `command not found`\n\n. Even if the script starts with `#!/usr/bin/env bash`\n\n, depending on launchd's PATH configuration, `/bin/bash`\n\n(3.2) is what gets invoked. It's spelled out in a comment as well.\n\n```\n# NOTE: mapfile/readarray は macOS 標準 /bin/bash 3.2 に無いため使わない（3.2/5 両対応の while-read）\n```\n\nThe solution is a `while IFS= read -r`\n\nloop.\n\n```\nCANDIDATES=()\nwhile IFS= read -r line; do\n  [ -n \"$line\" ] && CANDIDATES+=(\"$line\")\ndone < <(\n  { find \"$HOME_DIR\" \"$HOME_DIR/dev\" -maxdepth 2 -name .git -type d 2>/dev/null | sed 's|/\\.git$||'\n    find \"$HOME_DIR\" \"$HOME_DIR/dev\" -maxdepth 2 \\\n      \\( -name package.json -o -name pyproject.toml -o -name requirements.txt \\\n         -o -name go.mod -o -name Cargo.toml -o -name pubspec.yaml \\\n         -o -name skills-lock.json \\) \\\n      -not -path '*/node_modules/*' 2>/dev/null | sed -E 's|/[^/]+$||'\n  } | sort -u\n)\n```\n\nDisabling field splitting with `IFS=`\n\nand ignoring backslash escapes with `-r`\n\nlets it read paths containing spaces or parentheses accurately, one line at a time. The `[ -n \"$line\" ]`\n\nrejects blank lines because an empty line can end up mixed into the tail of `sort -u`\n\n's output (environment-dependent).\n\n`set -u`\n\ntrap\n\n```\n# bash 3.2 + set -u では空配列の \"${arr[@]}\" が unbound で落ちるためガード\nif [ \"${#CANDIDATES[@]}\" -eq 0 ]; then\n  log \"==== no candidates found; nothing to sync ====\"\n  exit 0\nfi\n```\n\nWhen `set -u`\n\nis active, expanding `\"${CANDIDATES[@]}\"`\n\nagainst the empty array `CANDIDATES=()`\n\nraises an unbound variable error under bash 3.2. bash 5.x has no problem expanding an empty array, but 3.2 has cases where it treats \"an array that exists but is empty\" as an undefined variable.\n\n`${#CANDIDATES[@]}`\n\nreturns the number of elements in the array. It returns `0`\n\neven for an empty array and doesn't error under `set -u`\n\n. With this guard in place, even a zero-candidate run can leave the reason in the log and exit normally.\n\n```\nn=\"$(echo \"$out\" | grep -oE '([0-9]+) skills installed' | grep -oE '^[0-9]+' | head -1)\"\n[ -z \"$n\" ] && n=\"$(echo \"$out\" | grep -oE 'Skills to install \\(([0-9]+)\\)' | grep -oE '[0-9]+' | head -1)\"\n[ -z \"$n\" ] && n=0\n```\n\nautoskills' output format changed between versions. It used to be the form `12 skills installed`\n\n, but from some version onward the form `Skills to install (12)`\n\nbecame part of the mix as well. If you handle only one of them, the other version always yields `n=0`\n\n, and every project gets recorded as \"skip\" even though skills are actually being installed.\n\n`head -1`\n\nis there to protect against `grep -oE`\n\nemitting all matches across multiple lines, which would pass a value containing a newline to the subsequent numeric comparison `[ \"$n\" -gt 0 ]`\n\nand cause an error.\n\nWhen I first wrote the plist and loaded it, the script was starting but nothing appeared in the log. Checking the status with `launchctl list com.shun.autoskills-sync`\n\nshowed exit code `127`\n\n— command not found.\n\n**Symptom:** There's a record of the job launching, but the log file is empty. No autoskills output either.\n\n**Cause:** launchd runs in an environment independent of a normal login shell, so the nvm path configuration written in `.zshrc`\n\nisn't read at all. `/usr/bin/npx`\n\ndoesn't exist, and the npx under `.nvm`\n\nisn't in launchd's bare PATH. The script itself could start, but the `npx`\n\nit calls inside couldn't be found.\n\n**Fix:** Explicitly write a full PATH including the nvm path into the plist's `EnvironmentVariables`\n\n.\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n  <key>PATH</key>\n  <string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:\n          /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>\n</dict>\n```\n\nPinning the nvm version to `v24.13.0`\n\nis a deliberate decision. Trying to resolve it dynamically from `.nvm/alias/default`\n\nwould make the plist description complicated, since shell expansion isn't available there. Pinning it makes the configuration work reliably. In exchange, the operational rule is that when I switch Node versions with nvm, the plist has to be updated too.\n\nThe fact that `ProgramArguments`\n\nis `/bin/zsh -lc <script path>`\n\ncomes from this same history. It was `/bin/bash <script path>`\n\nat first, but adding `-l`\n\n(login shell) causes `/etc/zprofile`\n\nand friends to be read, bringing the environment closer to production. Even so, the nvm path is written only in `.zshrc`\n\n, so explicit injection via `EnvironmentVariables`\n\nwas ultimately required.\n\nIn an early version I ran with the `--dry-run`\n\nflag, and the `.gitignore`\n\nof multiple projects got rewritten.\n\n**Symptom:** It was supposed to be a dry run, but running `git diff`\n\nshowed 3 lines appended to `.gitignore`\n\n.\n\n**Cause:** In the initial implementation, the gitignore-appending logic had no `DRY`\n\ncheck. `--dry-run`\n\nwas being passed to npx, but the gitignore-appending part wasn't looking at the flag — it was conditioned only on the number of skills installed (`n > 0`\n\n), so the rewrite ran even during a dry run.\n\n```\n# 問題のあった初期バージョン\nout=\"$(cd \"$d\" && npx -y autoskills --dry-run 2>&1)\"\nn=...  # パース\nif [ \"$n\" -gt 0 ]; then\n  # DRYチェックなしでgitignoreを書き換えていた\n  for pat in \".agents/\" \".claude/skills/\" \"skills-lock.json\"; do\n    grep -qxF \"$pat\" \"$d/.gitignore\" 2>/dev/null || echo \"$pat\" >> \"$d/.gitignore\"\n  done\nfi\n```\n\n**Fix:** Add `[ -z \"$DRY\" ]`\n\nin front of the gitignore-appending block.\n\n```\nif [ -z \"$DRY\" ] && (cd \"$d\" && git rev-parse --git-dir >/dev/null 2>&1); then\n  for pat in \".agents/\" \".claude/skills/\" \"skills-lock.json\"; do\n    grep -qxF \"$pat\" \"$d/.gitignore\" 2>/dev/null || echo \"$pat\" >> \"$d/.gitignore\"\n  done\nfi\n```\n\nAppend only when `$DRY`\n\nis empty (i.e. a production run). After this fix, `--dry-run`\n\nstarted functioning correctly as a \"zero-side-effect verification mode.\"\n\nThe lesson is \"unify the meaning of a flag at the implementation level.\" If you accept `--dry-run`\n\n, wrap every side-effecting operation in a `DRY`\n\ncheck. Partial application — \"I passed it to npx, so we're fine\" — leaves unexpected rewrites behind.\n\nRight after adding `set -uo pipefail`\n\n, the script started terminating without emitting even a single startup log line.\n\n**Symptom:** launchd status code `1`\n\n. The log file completely empty. Not even a trace of it having started.\n\n**Identifying the cause:** Running a trace with `bash -x autoskills-sync.sh`\n\nproduced this error.\n\n```\n+ for d in \"${CANDIDATES[@]}\"\nautoskills-sync.sh: line 63: CANDIDATES[@]: unbound variable\n```\n\nWhen `set -u`\n\nis active, expanding `\"${CANDIDATES[@]}\"`\n\nagainst the empty array `CANDIDATES=()`\n\nraises an unbound variable error under bash 3.2. **bash dies before the log() function is ever called, so nothing is left in the log.** That's why the log was empty.\n\n**Fix:** Put an array-size check in front of the `for`\n\nloop.\n\n```\nif [ \"${#CANDIDATES[@]}\" -eq 0 ]; then\n  log \"==== no candidates found; nothing to sync ====\"\n  exit 0\nfi\n```\n\n`${#CANDIDATES[@]}`\n\nreturns `0`\n\neven for an empty array and doesn't error under `set -u`\n\n. After adding this guard, even an empty array leaves the reason in the log and exits with `exit 0`\n\n.\n\n**The debugging step that worked:** Since a launchd job runs only once a week, it takes a week just to notice that \"something's off.\" The fastest way to isolate the problem is running `bash -x autoskills-sync.sh --dry-run`\n\nmanually from the terminal. The trace output shows the entire variable-expansion process, so it's immediately obvious which line it died on. This incident convinced me that to shorten the debug cycle for a weekly job, **a design where you can run the same script manually with only the arguments changed is mandatory**.\n\nOne week's run log had an entry I didn't recognize.\n\n```\n[2026-XX-XX 06:10:33]   Caches: 3 skills\n```\n\nInside `~/Library/Caches`\n\nthere was an npm package cache, and it had a `package.json`\n\n. Because the manifest-discovery `find`\n\nlooks two levels into `~/Library/`\n\n, it picked up the cache's `package.json`\n\nas a \"project.\" At the time, `is_excluded`\n\ndidn't yet have a `Library/*`\n\nexclusion rule.\n\n**Symptom:** autoskills runs against an unintended cache directory and generates a `skills-lock.json`\n\n. Since it isn't a git repo, no gitignore append occurred, but a lock file was left behind in the cache.\n\n**Fix:** Add `Documents/*`\n\n, `Library/*`\n\n, and `Applications/*`\n\nto the exclusion rules.\n\n```\n\"$HOME_DIR\"/Documents/*|\"$HOME_DIR\"/Library/*|\"$HOME_DIR\"/Applications/*) return 0 ;;\n```\n\nThe current script includes this line, and everything under `Library`\n\nis excluded.\n\nThis experience firmed up the design policy. Rather than \"actively defining where code projects are,\" err on the safe side by \"excluding places that aren't code projects.\" macOS home directories have `package.json`\n\nand `go.mod`\n\nscattered around in more places than you'd think. The wider you cast the discovery net, the more misfires you get, so the current balance is to keep discovery narrow — two levels under `~`\n\nand `~/dev`\n\n— while making the exclusion rules generous.\n\nAll four sticking points were \"problems that didn't show up when running manually in local.\" A mechanism that runs via launchd once a week means that if something is wrong, you won't notice for a week. Because that cost is high, it's structured so you can verify up front with `--dry-run`\n\nand follow up afterward with logs. The reason the project I open on Monday morning has the latest skills is precisely this accumulation of unglamorous failures.\n\nThe previous chapter covered four cases of \"launchd-specific problems that didn't appear during local manual runs.\" Here I'll cover, as a bullet list, the finer traps I stepped on around the periphery.\n\n`~`\n\nin a plist doesn't get tilde-expanded\n\nEven if you write `~/.nvm/versions/node/v24.13.0/bin`\n\nin `EnvironmentVariables`\n\n' `PATH`\n\n, launchd does not perform tilde expansion. It interprets `~`\n\nas a literal string, leaving you unable to find either `/usr/bin/npx`\n\nor the npx under `~/.nvm/...`\n\n. The only solution is to write the full path to the home directory. The actual plist does use full paths, with no `~`\n\nanywhere.\n\n**Setting RunAtLoad to true fires a production run right after the plist loads**\n\nThe script starts the instant you run `launchctl load ~/Library/LaunchAgents/com.shun.autoskills-sync.plist`\n\n. That means every project gets processed at a stage where you haven't yet verified the exclusion rules or done a `--dry-run`\n\n. For the first time, always keep `RunAtLoad`\n\nas `false`\n\n, trigger it manually with `launchctl start com.shun.autoskills-sync`\n\nto confirm the behavior, and then wait for the next Sunday. That's why the current plist is pinned to `<false/>`\n\n.\n\n`Weekday=0`\n\nmeaning Sunday is a launchd-specific counting scheme\n\nmacOS launchd plists use `0=Sunday, 1=Monday, …, 6=Saturday`\n\n. If you set `Weekday`\n\nto `1`\n\nwith the intuition that \"the week starts on Monday = 1,\" you've configured it to run on Tuesday, not Monday. `com.shun.autoskills-sync.plist`\n\nhas `Weekday`\n\nat `0`\n\nwith the intent of \"run early Sunday and finish distribution before Monday's work begins.\" Right after writing a plist, run it immediately with `launchctl start`\n\nand confirm from the timestamp left in the log that there's no day-of-week drift.\n\n`launchctl load`\n\nalone doesn't apply your changes\n\nIf you only re-run `launchctl load`\n\nafter editing a plist, launchd keeps holding the old configuration. To apply changes, run `launchctl unload ~/Library/LaunchAgents/com.shun.autoskills-sync.plist`\n\nfirst, then `launchctl load`\n\n. The situation \"I fixed the config but the behavior didn't change\" is caused by this almost without exception.\n\n`LastExitStatus=0`\n\ndoesn't necessarily mean \"it exited normally\"\n\nEven if `launchctl list com.shun.autoskills-sync`\n\noutputs `\"LastExitStatus\" = 0;`\n\n, that means not only \"the previous run exited normally\" but possibly also \"it has never run yet (the initial value is 0).\" Confirm \"whether it really ran\" by whether entries exist in the log file. A situation with an empty log and `LastExitStatus=0`\n\nis usually \"just after the first load, and Sunday hasn't come yet.\"\n\n`npx`\n\n's `-y`\n\nand `autoskills`\n\n' `--yes`\n\nskip different confirmations\n\n`npx -y`\n\nskips npx's \"do you want to download this package?\" prompt. `autoskills --yes`\n\nskips autoskills' own interactive confirmation. If you add only one of them, it stalls on the other. In launchd's unattended execution, waiting on a prompt persists forever, making it the worst-case pattern where it stays stuck until the next Sunday and nobody notices. The script's `${DRY:---yes}`\n\nis parameter expansion that passes `--yes`\n\nwhen DRY is empty, but in the initial implementation it was expanded before the variable assignment, so an empty string was always being passed.\n\n**Omitting sort -u runs the same project twice**\n\nThe `.git`\n\ndiscovery route and the manifest-file discovery route run independently. A project that has both `.git`\n\nand `package.json`\n\nyields the same path from both routes. Without `sort -u`\n\n, the loop executes twice on the same directory. The second time just comes back as 0 items (\"already installed\"), so the actual harm is small, but it adds unnecessary network traffic, time, and log entries.\n\n**The intent behind grep -qxF's -F (fixed string) and -x (whole-line match)**\n\nIn the gitignore append check, omitting `-F`\n\nturns the leading `.`\n\nof `.agents/`\n\ninto a regex wildcard. Even a line reading `xagents/`\n\nwould be judged \"matched,\" and the intended `.agents/`\n\npattern would never be appended. Omitting `-x`\n\ncauses a partial match against a comment line like `# .agents/`\n\n, misjudging it as \"no append needed.\" Missing either one results in \"believing something was appended when it actually isn't in effect.\"\n\n**With ProcessType at Adaptive (the default when omitted), power-saving mode can cancel the job**\n\nIf you don't write `ProcessType`\n\nin the plist, or set it to `Adaptive`\n\n, the job can be deferred or interrupted under macOS power management. If the Sunday 06:10 trigger had passed while asleep, `Background`\n\nmaintains that window, whereas with `Adaptive`\n\nit can vanish. The cost of losing your once-a-week chance to power saving isn't negligible. The current plist explicitly has `<key>ProcessType</key><string>Background</string>`\n\n.\n\n**The relationship between execute permission and the plist's launch method**\n\nIf you make the plist's `ProgramArguments`\n\nthe form `[\"/bin/zsh\", \"-lc\", \"<script path>\"]`\n\n, execution goes through `zsh`\n\n, so the script's own execute permission (+x) isn't needed. If you use the form `[\"/path/to/script.sh\"]`\n\n(direct execution), +x is mandatory. Forgetting +x in the direct-execution form fails silently with exit code `126`\n\n(Permission denied). The current plist uses the `/bin/zsh -lc`\n\nform not just for login-shell compatibility, but also to sidestep the permission problem.\n\nHere are the operational patterns I got from actually running this, ordered by how reproducible they are.\n\n`--dry-run`\n\nfirst, immediately after any change\n\n```\nbash ~/.claude/scripts/autoskills-sync.sh --dry-run\n```\n\nWhen you add an exclusion rule, change the discovery paths, or edit the plist — after any change at all, this one line goes first. A dry run has zero side effects and can be run as many times as you like. The biggest weakness of a weekly job is \"it takes a week to notice a problem,\" and this single step preemptively kills that. In particular, visually confirm that the number in `done: N projects / M skills`\n\nappears at the end of the log after the dry run.\n\n`stdout`\n\nand `stderr`\n\ninto the same file\nPoint the plist's `StandardOutPath`\n\nand `StandardErrorPath`\n\nat the same path, and have the script's `log()`\n\nfunction append to the same file with `tee -a`\n\n. Splitting into two files makes it hard to reconstruct \"in what order errors and normal output appeared.\" A single chronological log file lets you trace the entire sequence. In `com.shun.autoskills-sync.plist`\n\n, both paths point at `~/.claude/logs/com.shun.autoskills-sync.log`\n\n.\n\nA design that \"denies places you can definitively identify as not code projects\" is safer than one that \"allows only places you can tell are code projects.\" In macOS home directories, `package.json`\n\nand `go.mod`\n\nare scattered in surprising places. Narrowing discovery to maxdepth 2 under `~`\n\nand `~/dev`\n\nwhile making the denylist (the `is_excluded`\n\nfunction) generous is the balance that stays stable. When you feel like adding a new area to the discovery scope, always confirm which projects get added with `--dry-run`\n\nfirst.\n\n`EnvironmentVariables`\n\n`~`\n\nisn't expanded in a plist. If you're using nvm-managed Node.js, write a version-pinned full path into `PATH`\n\n. When you change the Node.js version with `nvm use`\n\n, update the corresponding part of the plist at the same time. Forget it and you get a quiet accident next Sunday: \"npx: command not found\" and everything skipped. Version pinning adds management overhead, but being explicit is more trustworthy than a complicated dynamic resolution from `.nvm/alias/default`\n\n.\n\n```\nout=\"$(cd \"$d\" && npx -y autoskills ${DRY:---yes} 2>&1)\"\n```\n\nDoing the `cd`\n\ninside `$()`\n\nconfines the directory change to a subshell. The effect of `cd`\n\nisn't carried into the loop's next iteration. The same goes for the gitignore check's `(cd \"$d\" && git rev-parse ...)`\n\n. When you're \"looping over commands that depend on the current directory,\" this pattern is the simplest and most reliable.\n\n`while IFS= read -r`\n\ninstead of `mapfile`\n\nmacOS's default `/bin/bash`\n\nis version 3.2. `mapfile`\n\n(`readarray`\n\n) is a bash 4.0-and-later feature. Substitute a `while IFS= read -r`\n\nloop, and leave a comment explaining \"why we don't use mapfile.\" That prevents the accident where six-months-from-now you rewrites it to `mapfile`\n\nin the name of \"optimization\" and breaks it. Disabling field splitting with `IFS=`\n\nand ignoring backslash escapes with `-r`\n\nlets you read paths containing spaces or parentheses accurately.\n\n`set -u`\n\n```\nif [ \"${#CANDIDATES[@]}\" -eq 0 ]; then\n  log \"==== no candidates found; nothing to sync ====\"; exit 0\nfi\n```\n\nWhen `set -u`\n\nis active, expanding `${arr[@]}`\n\nagainst an empty array raises an unbound variable error under bash 3.2. `${#arr[@]}`\n\nreturns `0`\n\neven for an empty array and doesn't error under `set -u`\n\n. Without this guard, zero candidates produces the situation \"empty log, exit code 1.\" Since the error happens silently, you end up not noticing for a week.\n\n```\nn=\"$(echo \"$out\" | grep -oE '([0-9]+) skills installed' | grep -oE '^[0-9]+' | head -1)\"\n[ -z \"$n\" ] && n=\"$(echo \"$out\" | grep -oE 'Skills to install \\(([0-9]+)\\)' | grep -oE '[0-9]+' | head -1)\"\n[ -z \"$n\" ] && n=0\n```\n\nAn npm package's output format changes between versions. Handle only one pattern and, after a version bump, everything gets recorded as \"0 items (skip)\" while you remain unaware that skills are actually being installed. `head -1`\n\nis there to protect against a multi-line match returning and causing an error in the subsequent numeric comparison.\n\n`-qxF`\n\nas a three-character set for gitignore appending\n\n```\ngrep -qxF \"$pat\" \"$d/.gitignore\" 2>/dev/null || echo \"$pat\" >> \"$d/.gitignore\"\n```\n\n`-q`\n\n(quiet — return only whether there was a match), `-x`\n\n(whole-line match), and `-F`\n\n(fixed string) work as a set of three. Without `-F`\n\n, the leading `.`\n\nof `.agents/`\n\nbecomes a regex wildcard. Without `-x`\n\n, it partially matches a comment line like `# .agents/`\n\n. Missing either one produces one of two outcomes: \"misjudging that it found a line that isn't there and skipping,\" or \"matching a different line and appending a duplicate.\"\n\n```\n==== done: 2 projects / 20 skills, 1 excluded ====\n```\n\nBecause it's a weekly run, fix a format that makes comparison with past logs easy. You can immediately detect a change like \"20 last week, 0 this week.\" Since your chance to notice \"something's wrong\" comes only once a week, keep the information density of the log high. Building the habit of checking just the most recent completion line with `tail -5 ~/.claude/logs/com.shun.autoskills-sync.log`\n\ngets the weekly check done in 30 seconds.\n\n`RunAtLoad`\n\nto `false`\n\nand verify the first time with `launchctl start`\n\n```\nlaunchctl load ~/Library/LaunchAgents/com.shun.autoskills-sync.plist\nlaunchctl start com.shun.autoskills-sync\ntail -20 ~/.claude/logs/com.shun.autoskills-sync.log\n```\n\nLoading the plist with `RunAtLoad=false`\n\nand triggering manually with `launchctl start`\n\ngives you both \"verify behavior without waiting until Sunday\" and \"prevention of an unintended immediate run.\" If the `done:`\n\nline appears in the log during the first check, the configuration is working correctly.\n\n`ProcessType`\n\nexplicitly to `Background`\n\n```\n<key>ProcessType</key>\n<string>Background</string>\n```\n\nOmitting it (`Adaptive`\n\n) leaves open the possibility that the job is skipped when macOS decides \"this can wait.\" The cost of losing your once-a-week chance to power saving isn't negligible. Declaring `Background`\n\nexplicitly is insurance in job scheduling.\n\n`unload → load`\n\norder\n\n```\nlaunchctl unload ~/Library/LaunchAgents/com.shun.autoskills-sync.plist\nlaunchctl load  ~/Library/LaunchAgents/com.shun.autoskills-sync.plist\n```\n\nRe-running only `load`\n\nleaves the old configuration in place. \"I fixed the config but the behavior didn't change\" is almost without exception caused by forgetting the unload. Prevent it by either making the pair into a single command or leaving the procedure in a comment.\n\nWhen you switch Node.js versions with nvm, the version number inside the plist's `EnvironmentVariables`\n\n`PATH`\n\nneeds updating as well. Forget it and an old version of npx keeps getting used. Either make updating the plist a habit at the moment you run `nvm use`\n\n, or write the version in use in a comment and check it periodically. Automation-for-automation quietly continuing to run on stale settings is a trap anyone can fall into.\n\n`set -e`\n\nThe reason there's no `set -e`\n\nat the top of the script is that when the `is_excluded`\n\nfunction returns `return 1`\n\n(not excluded = false), the shell interprets it as \"the command failed.\" With `set -e`\n\nactive, there are cases where the whole script terminates the moment `return 1`\n\noccurs during the condition evaluation of `if is_excluded \"$d\"; then`\n\n. When you make heavy use of functions containing conditional branches, an explicit `|| { log \"error: ...\"; exit 1; }`\n\nmisfires less than `set -e`\n\n. The current configuration, keeping only `set -u`\n\nand `pipefail`\n\n, is the result of that judgment.\n\nSkills accumulating is meaningless if they never arrive — this simple problem is what produced the weekly auto-distribution mechanism.\n\nThe 88 lines of `autoskills-sync.sh`\n\nstring together six steps into one line of flow: online check, two-route discovery, five categories of exclusion, npx execution, gitignore appending, and count logging. The plist launches it automatically at 06:10 every Sunday. It's the minimum contraption for creating a state where, without doing anything manually, the project you open on Monday morning has the latest skills in place.\n\nUnderneath the gotchas and best practices covered in this article lies one shared design philosophy: \"a job that runs only once a week takes a week before you notice a problem.\" That's why you verify with `--dry-run`\n\nfirst, leave a count summary in the log, and design so the same script can be run manually. With those three in place, most of the accidents that happen around launchd can be prevented in advance.\n\nGrowing your environment isn't only about accumulating skills. It's about building a mechanism that delivers those skills where they're needed, and growing the design that operates that mechanism safely.\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/four-failures-that-made-a-weekly-launchd-job-actually-run", "canonical_source": "https://dev.to/bokuwalily/four-failures-that-made-a-weekly-launchd-job-actually-run-5hhb", "published_at": "2026-08-17 00:00:07+00:00", "updated_at": "2026-08-17 00:11:38.239455+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "mlops"], "entities": ["Claude Code", "launchd", "autoskills-sync", "npm"], "alternates": {"html": "https://wpnews.pro/news/four-failures-that-made-a-weekly-launchd-job-actually-run", "markdown": "https://wpnews.pro/news/four-failures-that-made-a-weekly-launchd-job-actually-run.md", "text": "https://wpnews.pro/news/four-failures-that-made-a-weekly-launchd-job-actually-run.txt", "jsonld": "https://wpnews.pro/news/four-failures-that-made-a-weekly-launchd-job-actually-run.jsonld"}}