DEV Community

Lily
Lily

Posted on • Originally published at dev.to

Four Failures That Made a Weekly launchd Job Actually Run

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.


Why this mechanism works

Claude Code's ~/.claude/skills/auto/ is 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.

Reality is a little different, though.

Skills keep piling up in .claude/skills/auto/. 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.

The structure of the problem looks like this.

  • Skills accumulate in one place, .claude/skills/auto/ (global)
  • They're actually referenced only "when that project has .agents/ or .claude/skills/" (local)
  • That bridging doesn't happen each time you create a new project (zero start)

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."

The 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.

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.

One important premise. "Skills" here means the files under ~/.claude/skills/auto/ that I built up myself. Bundled skills and ~/.claude/skills/ecc/ are never touched. The distribution target is strictly my own habits library.


The overall flow

Here's a bird's-eye view of the whole mechanism.

[launchd]
com.shun.autoskills-sync
日曜 06:10 起動
        |
        v
[autoskills-sync.sh]
        |
        +-- ① オンライン確認
        |   curl -sf -m 8 https://registry.npmjs.org/
        |   オフライン → exit 0(何もせず正常終了)
        |
        +-- ② プロジェクト探索
        |   find ~ ~/dev -maxdepth 2
        |     -name .git  →  親ディレクトリをリスト
        |     -name package.json / pyproject.toml /
        |           requirements.txt / go.mod /
        |           Cargo.toml / pubspec.yaml /
        |           skills-lock.json
        |   sort -u で重複除去
        |
        +-- ③ 除外フィルタ
        |   oss-trial/* / *-public / node_modules/*
        |   Documents/ Library/ Applications/ go/
        |   .claude/ config-snapshots/ claude-obsidian/
        |   digital-products
        |
        +-- ④ 各プロジェクトへ配布
        |   npx -y autoskills --yes
        |   出力から「N skills installed」をパース
        |
        +-- ⑤ gitignore 追記(本番のみ)
        |   .agents/ / .claude/skills/ / skills-lock.json
        |
        +-- ⑥ ログ記録
            ~/.claude/logs/com.shun.autoskills-sync.log
Enter fullscreen mode Exit fullscreen mode

launchd configuration: pinning "Sunday 06:10" in a plist

launchd, the macOS job scheduler, loads plists placed in ~/Library/LaunchAgents/ and runs them automatically. The contents of com.shun.autoskills-sync.plist look like this.

<key>StartCalendarInterval</key>
<array>
  <dict>
    <key>Hour</key>
    <integer>6</integer>
    <key>Minute</key>
    <integer>10</integer>
    <key>Weekday</key>
    <integer>0</integer>
  </dict>
</array>
Enter fullscreen mode Exit fullscreen mode

A Weekday of 0 is Sunday. It fires at 6:10 every Sunday. Since RunAtLoad is false, it doesn't run the instant the plist is loaded. It waits until the next Sunday.

PATH configuration is in there too.

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>~/.nvm/versions/node/v24.13.0/bin:
          /opt/homebrew/bin:/opt/homebrew/sbin:
          /usr/local/bin:/usr/bin:/bin:...</string>
</dict>
Enter fullscreen mode Exit fullscreen mode

launchd doesn't go through a shell, so your usual .zshrc isn't read. Unless you explicitly write the path to the nvm-managed Node.js into PATH, the npx command won't be found and the job fails. It's launched with /bin/zsh -lc, but even then .zshrc isn't read in launchd's environment, so this PATH injection is mandatory.

StandardOutPath and StandardErrorPath both point at ~/.claude/logs/com.shun.autoskills-sync.log. The log() function inside the script appends to the same file, so logs are consolidated into one file.

① Online check: bailing out safely with an 8-second timeout

if ! curl -sf -m 8 https://registry.npmjs.org/ >/dev/null 2>&1; then
  log "offline — skip"; exit 0
fi
Enter fullscreen mode Exit fullscreen mode

The trigger time can coincide with waking from sleep or with airplane mode. Calling npx at that point would just fail, so the first thing it does is check HTTP connectivity to npmjs. With -m 8, no response within 8 seconds is treated as offline, and it exits normally with exit 0. The reason it doesn't return an error code is that launchd sometimes uses an error exit as a retry trigger.

② Project discovery: crossing languages with six manifest types

find "$HOME_DIR" "$HOME_DIR/dev" -maxdepth 2 -name .git -type d 2>/dev/null \
  | sed 's|/\.git$||'

find "$HOME_DIR" "$HOME_DIR/dev" -maxdepth 2 \
  \( -name package.json -o -name pyproject.toml -o -name requirements.txt \
     -o -name go.mod -o -name Cargo.toml -o -name pubspec.yaml \
     -o -name skills-lock.json \) \
  -not -path '*/node_modules/*' 2>/dev/null | sed -E 's|/[^/]+$||'
Enter fullscreen mode Exit fullscreen mode

Discovery is -maxdepth 2 under ~ and ~/dev — two levels. Directories nested deeper than that are out of scope. This limit exists for both performance and deliberate scoping.

Two routes run in parallel, OR'd together: one that looks for the .git directory and takes its parent, and one that looks for manifest files and takes the parent directory. sort -u removes duplicates. Something that isn't a git repository still qualifies if it has package.json, and a git repository without package.json won'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.

For bash 3.2 compatibility, it avoids mapfile/readarray and fills the array with while IFS= read -r line. macOS's /bin/bash defaults to 3.2. Even if you've installed bash 5 via Homebrew, if the script's first line is #!/usr/bin/env bash, /bin/bash gets invoked. This pitfall is spelled out in a comment too.

# NOTE: mapfile/readarray は macOS 標準 /bin/bash 3.2 に無いため使わない(3.2/5 両対応の while-read)
Enter fullscreen mode Exit fullscreen mode

③ Exclusion filter: why those four categories get rejected

is_excluded() {
  local d="$1"
  [ "$d" = "$HOME_DIR" ] && return 0
  [ "$d" = "$HOME_DIR/dev" ] && return 0
  case "$d" in
    */oss-trial/*)  return 0 ;;   # 第三者fork
    *-public)       return 0 ;;   # 公開OSSミラー
    */node_modules/*) return 0 ;;
    "$HOME_DIR"/Documents/*|"$HOME_DIR"/Library/*|"$HOME_DIR"/Applications/*) return 0 ;;
    "$HOME_DIR"/Public/*|"$HOME_DIR"/go|"$HOME_DIR"/go/*) return 0 ;;
    */.claude/*|*config-snapshots*|*claude-obsidian*) return 0 ;;
    "$HOME_DIR"/digital-products|"$HOME_DIR"/digital-products/*) return 0 ;;
  esac
  return 1
}
Enter fullscreen mode Exit fullscreen mode

The exclusion rules are easier to read when split into four categories.

Protecting the root directories themselves. $HOME and $HOME/dev are excluded as directories in their own right, because of the risk of overwriting configuration areas like ~/.claude/ or ~/.agents/. These two directories are the starting points of discovery: their contents are in scope, but the parents themselves are not.

Protecting third-party code. */oss-trial/* is 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, which points at public OSS mirrors. Running autoskills against these projects risks unintended .gitignore changes or skills-lock.json generation, contaminating a public repository.

Protecting macOS system areas. Documents/, Library/, Applications/, Public/, and go/ are not code projects. Even if a package.json happens to exist in them, they're excluded. Library/ contains a huge amount of app data unrelated to Autoskills, and running against it by mistake generates files of unclear purpose.

Protecting Claude Code's own configuration areas. .claude/, config-snapshots, and claude-obsidian are 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/ itself could get caught by discovery, so it's excluded explicitly.

Protecting content directories. digital-products is 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.

④ Distribution: fetching the latest each time via npx

out="$(cd "$d" && npx -y autoskills ${DRY:---yes} 2>&1)"
n="$(echo "$out" | grep -oE '([0-9]+) skills installed' | grep -oE '^[0-9]+' | head -1)"
[ -z "$n" ] && n="$(echo "$out" | grep -oE 'Skills to install \([0-9]+\)' | grep -oE '[0-9]+' | head -1)"
[ -z "$n" ] && n=0
Enter fullscreen mode Exit fullscreen mode

It runs npx -y autoskills --yes in the project directory. -y skips npx's confirmation prompt; --yes skips autoskills' own interaction. 2>&1 captures standard error as well, storing all output in the $out variable before parsing.

To 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, logs projects with 0 as "0 (skip)," and moves right along to the next.

${DRY:---yes} is bash parameter expansion. When the DRY variable is non-empty (the --dry-run flag is present) it passes --dry-run; when empty it passes --yes. This lets the same script be reused for both manual runs and production runs.

⑤ gitignore appending: isolating skills from the repository

if [ -z "$DRY" ] && (cd "$d" && git rev-parse --git-dir >/dev/null 2>&1); then
  for pat in ".agents/" ".claude/skills/" "skills-lock.json"; do
    grep -qxF "$pat" "$d/.gitignore" 2>/dev/null || echo "$pat" >> "$d/.gitignore"
  done
fi
Enter fullscreen mode Exit fullscreen mode

This runs only against git repositories where at least one skill was installed. It appends the three patterns .agents/, .claude/skills/, and skills-lock.json to .gitignore, but checks for existence first with grep -qxF so that lines already present aren't appended twice.

This 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.

With --dry-run, DRY is non-empty, so [ -z "$DRY" ] is false and the gitignore append is skipped. The principle that a dry run is read-only is enforced throughout.

⑥ Logging: all information in one file

On every run it appends timestamped entries to ~/.claude/logs/com.shun.autoskills-sync.log.

[2026-07-13 06:10:03] ==== autoskills-sync start ====
[2026-07-13 06:10:04]   lead-finder: 12 skills
[2026-07-13 06:10:06]   affiliate-fc2: 8 skills
[2026-07-13 06:10:08]   note-autolike: 0 (skip)
[2026-07-13 06:10:09]   oss-trial: (excluded)
[2026-07-13 06:10:10] ==== done: 2 projects / 20 skills, 1 excluded ====
Enter fullscreen mode Exit fullscreen mode

The completion line's format, done: N projects / M skills, K excluded, 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.

Implementation details

set -uo pipefail — why these three flags line up this way

It's the first line of the script.

set -uo pipefail
Enter fullscreen mode Exit fullscreen mode

-u (nounset) halts with an error when an undefined variable is expanded. It prevents code like rm -rf "$UNDEFINED_DIR/" from 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.

-o pipefail makes 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, it prevents the trap where the first grep matches nothing but head -1 returns 0, making the whole thing look successful.

-e (errexit) is deliberately omitted. The behavior of the is_excluded function returning return 0 (excluded = true) looks, from the shell's perspective, like "the command failed." With -e on, the whole script terminates at the point that function is called. -e is treacherous for functions containing conditional branches. Error handling written as an explicit || { log "..."; exit 1; } misfires less.

The subshell cd pattern — not polluting the working directory

out="$(cd "$d" && npx -y autoskills ${DRY:---yes} 2>&1)"
Enter fullscreen mode Exit fullscreen mode

cd "$d" && npx ... is wrapped in the command substitution $(). This is to confine the effect of cd to a subshell. Inside $() is an independent shell environment, so a cd in there doesn't affect the parent shell's current directory. Without this, from the second loop iteration onward, npx could keep executing in the previous project's directory.

The gitignore check part follows the same idea.

if [ -z "$DRY" ] && (cd "$d" && git rev-parse --git-dir >/dev/null 2>&1); then
Enter fullscreen mode Exit fullscreen mode

This one uses () rather than $(), 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 for a conditional test but don't want that cd carried into the loop, () is a simple and reliable means.

bash 3.2 compatibility — the background of the mapfile ban

At first I tried to build the collection with mapfile.

# 書きたかったが書けなかった
mapfile -t CANDIDATES < <(find ...)
Enter fullscreen mode Exit fullscreen mode

macOS's default /bin/bash is version 3.2. mapfile (aka readarray) is a bash 4.0-and-later feature, so running under /bin/bash gives you a plain command not found. Even if the script starts with #!/usr/bin/env bash, depending on launchd's PATH configuration, /bin/bash (3.2) is what gets invoked. It's spelled out in a comment as well.

# NOTE: mapfile/readarray は macOS 標準 /bin/bash 3.2 に無いため使わない(3.2/5 両対応の while-read)
Enter fullscreen mode Exit fullscreen mode

The solution is a while IFS= read -r loop.

CANDIDATES=()
while IFS= read -r line; do
  [ -n "$line" ] && CANDIDATES+=("$line")
done < <(
  { find "$HOME_DIR" "$HOME_DIR/dev" -maxdepth 2 -name .git -type d 2>/dev/null | sed 's|/\.git$||'
    find "$HOME_DIR" "$HOME_DIR/dev" -maxdepth 2 \
      \( -name package.json -o -name pyproject.toml -o -name requirements.txt \
         -o -name go.mod -o -name Cargo.toml -o -name pubspec.yaml \
         -o -name skills-lock.json \) \
      -not -path '*/node_modules/*' 2>/dev/null | sed -E 's|/[^/]+$||'
  } | sort -u
)
Enter fullscreen mode Exit fullscreen mode

Disabling field splitting with IFS= and ignoring backslash escapes with -r lets it read paths containing spaces or parentheses accurately, one line at a time. The [ -n "$line" ] rejects blank lines because an empty line can end up mixed into the tail of sort -u's output (environment-dependent).

The empty-array guard — the set -u trap

# bash 3.2 + set -u では空配列の "${arr[@]}" が unbound で落ちるためガード
if [ "${#CANDIDATES[@]}" -eq 0 ]; then
  log "==== no candidates found; nothing to sync ===="
  exit 0
fi
Enter fullscreen mode Exit fullscreen mode

When set -u is active, expanding "${CANDIDATES[@]}" against the empty array CANDIDATES=() raises 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.

${#CANDIDATES[@]} returns the number of elements in the array. It returns 0 even for an empty array and doesn't error under set -u. With this guard in place, even a zero-candidate run can leave the reason in the log and exit normally.

Why the two-stage grep is necessary

n="$(echo "$out" | grep -oE '([0-9]+) skills installed' | grep -oE '^[0-9]+' | head -1)"
[ -z "$n" ] && n="$(echo "$out" | grep -oE 'Skills to install \(([0-9]+)\)' | grep -oE '[0-9]+' | head -1)"
[ -z "$n" ] && n=0
Enter fullscreen mode Exit fullscreen mode

autoskills' output format changed between versions. It used to be the form 12 skills installed, but from some version onward the form Skills to install (12) became part of the mix as well. If you handle only one of them, the other version always yields n=0, and every project gets recorded as "skip" even though skills are actually being installed.

head -1 is there to protect against grep -oE emitting all matches across multiple lines, which would pass a value containing a newline to the subsequent numeric comparison [ "$n" -gt 0 ] and cause an error.


Where I got stuck

Stuck ①: launchd can't find npx

When 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 showed exit code 127 — command not found.

Symptom: There's a record of the job launching, but the log file is empty. No autoskills output either.

Cause: launchd runs in an environment independent of a normal login shell, so the nvm path configuration written in .zshrc isn't read at all. /usr/bin/npx doesn't exist, and the npx under .nvm isn't in launchd's bare PATH. The script itself could start, but the npx it calls inside couldn't be found.

Fix: Explicitly write a full PATH including the nvm path into the plist's EnvironmentVariables.

<key>EnvironmentVariables</key>
<dict>
  <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>
</dict>
Enter fullscreen mode Exit fullscreen mode

Pinning the nvm version to v24.13.0 is a deliberate decision. Trying to resolve it dynamically from .nvm/alias/default would 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.

The fact that ProgramArguments is /bin/zsh -lc <script path> comes from this same history. It was /bin/bash <script path> at first, but adding -l (login shell) causes /etc/zprofile and friends to be read, bringing the environment closer to production. Even so, the nvm path is written only in .zshrc, so explicit injection via EnvironmentVariables was ultimately required.

Stuck ②: gitignore got rewritten despite --dry-run

In an early version I ran with the --dry-run flag, and the .gitignore of multiple projects got rewritten.

Symptom: It was supposed to be a dry run, but running git diff showed 3 lines appended to .gitignore.

Cause: In the initial implementation, the gitignore-appending logic had no DRY check. --dry-run was 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), so the rewrite ran even during a dry run.

# 問題のあった初期バージョン
out="$(cd "$d" && npx -y autoskills --dry-run 2>&1)"
n=...  # パース
if [ "$n" -gt 0 ]; then
  # DRYチェックなしでgitignoreを書き換えていた
  for pat in ".agents/" ".claude/skills/" "skills-lock.json"; do
    grep -qxF "$pat" "$d/.gitignore" 2>/dev/null || echo "$pat" >> "$d/.gitignore"
  done
fi
Enter fullscreen mode Exit fullscreen mode

Fix: Add [ -z "$DRY" ] in front of the gitignore-appending block.

if [ -z "$DRY" ] && (cd "$d" && git rev-parse --git-dir >/dev/null 2>&1); then
  for pat in ".agents/" ".claude/skills/" "skills-lock.json"; do
    grep -qxF "$pat" "$d/.gitignore" 2>/dev/null || echo "$pat" >> "$d/.gitignore"
  done
fi
Enter fullscreen mode Exit fullscreen mode

Append only when $DRY is empty (i.e. a production run). After this fix, --dry-run started functioning correctly as a "zero-side-effect verification mode."

The lesson is "unify the meaning of a flag at the implementation level." If you accept --dry-run, wrap every side-effecting operation in a DRY check. Partial application — "I passed it to npx, so we're fine" — leaves unexpected rewrites behind.

Stuck ③: an empty array made set -u kill the script silently

Right after adding set -uo pipefail, the script started terminating without emitting even a single startup log line.

Symptom: launchd status code 1. The log file completely empty. Not even a trace of it having started.

Identifying the cause: Running a trace with bash -x autoskills-sync.sh produced this error.

+ for d in "${CANDIDATES[@]}"
autoskills-sync.sh: line 63: CANDIDATES[@]: unbound variable
Enter fullscreen mode Exit fullscreen mode

When set -u is active, expanding "${CANDIDATES[@]}" against the empty array CANDIDATES=() raises 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.

Fix: Put an array-size check in front of the for loop.

if [ "${#CANDIDATES[@]}" -eq 0 ]; then
  log "==== no candidates found; nothing to sync ===="
  exit 0
fi
Enter fullscreen mode Exit fullscreen mode

${#CANDIDATES[@]} returns 0 even for an empty array and doesn't error under set -u. After adding this guard, even an empty array leaves the reason in the log and exits with exit 0.

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 manually 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.

Stuck ④: mystery projects under Library/ became distribution targets

One week's run log had an entry I didn't recognize.

[2026-XX-XX 06:10:33]   Caches: 3 skills
Enter fullscreen mode Exit fullscreen mode

Inside ~/Library/Caches there was an npm package cache, and it had a package.json. Because the manifest-discovery find looks two levels into ~/Library/, it picked up the cache's package.json as a "project." At the time, is_excluded didn't yet have a Library/* exclusion rule.

Symptom: autoskills runs against an unintended cache directory and generates a skills-lock.json. Since it isn't a git repo, no gitignore append occurred, but a lock file was left behind in the cache.

Fix: Add Documents/*, Library/*, and Applications/* to the exclusion rules.

"$HOME_DIR"/Documents/*|"$HOME_DIR"/Library/*|"$HOME_DIR"/Applications/*) return 0 ;;
Enter fullscreen mode Exit fullscreen mode

The current script includes this line, and everything under Library is excluded.

This 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 and go.mod scattered 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 ~ and ~/dev — while making the exclusion rules generous.


All 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 and 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.

Gotchas

The 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.

~ in a plist doesn't get tilde-expanded

Even if you write ~/.nvm/versions/node/v24.13.0/bin in EnvironmentVariables' PATH, launchd does not perform tilde expansion. It interprets ~ as a literal string, leaving you unable to find either /usr/bin/npx or the npx under ~/.nvm/.... The only solution is to write the full path to the home directory. The actual plist does use full paths, with no ~ anywhere.

Setting RunAtLoad to true fires a production run right after the plist loads

The script starts the instant you run launchctl load ~/Library/LaunchAgents/com.shun.autoskills-sync.plist. That means every project gets processed at a stage where you haven't yet verified the exclusion rules or done a --dry-run. For the first time, always keep RunAtLoad as false, trigger it manually with launchctl start com.shun.autoskills-sync to confirm the behavior, and then wait for the next Sunday. That's why the current plist is pinned to <false/>.

Weekday=0 meaning Sunday is a launchd-specific counting scheme

macOS launchd plists use 0=Sunday, 1=Monday, …, 6=Saturday. If you set Weekday to 1 with the intuition that "the week starts on Monday = 1," you've configured it to run on Tuesday, not Monday. com.shun.autoskills-sync.plist has Weekday at 0 with 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 and confirm from the timestamp left in the log that there's no day-of-week drift.

launchctl load alone doesn't apply your changes

If you only re-run launchctl load after editing a plist, launchd keeps holding the old configuration. To apply changes, run launchctl unload ~/Library/LaunchAgents/com.shun.autoskills-sync.plist first, then launchctl load. The situation "I fixed the config but the behavior didn't change" is caused by this almost without exception.

LastExitStatus=0 doesn't necessarily mean "it exited normally"

Even if launchctl list com.shun.autoskills-sync outputs "LastExitStatus" = 0;, 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 is usually "just after the first load, and Sunday hasn't come yet."

npx's -y and autoskills' --yes skip different confirmations

npx -y skips npx's "do you want to download this package?" prompt. autoskills --yes skips 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} is parameter expansion that passes --yes when DRY is empty, but in the initial implementation it was expanded before the variable assignment, so an empty string was always being passed.

Omitting sort -u runs the same project twice

The .git discovery route and the manifest-file discovery route run independently. A project that has both .git and package.json yields the same path from both routes. Without sort -u, 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.

The intent behind grep -qxF's -F (fixed string) and -x (whole-line match)

In the gitignore append check, omitting -F turns the leading . of .agents/ into a regex wildcard. Even a line reading xagents/ would be judged "matched," and the intended .agents/ pattern would never be appended. Omitting -x causes a partial match against a comment line like # .agents/, misjudging it as "no append needed." Missing either one results in "believing something was appended when it actually isn't in effect."

With ProcessType at Adaptive (the default when omitted), power-saving mode can cancel the job

If you don't write ProcessType in the plist, or set it to Adaptive, the job can be deferred or interrupted under macOS power management. If the Sunday 06:10 trigger had passed while asleep, Background maintains that window, whereas with Adaptive it 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>.

The relationship between execute permission and the plist's launch method

If you make the plist's ProgramArguments the form ["/bin/zsh", "-lc", "<script path>"], execution goes through zsh, so the script's own execute permission (+x) isn't needed. If you use the form ["/path/to/script.sh"] (direct execution), +x is mandatory. Forgetting +x in the direct-execution form fails silently with exit code 126 (Permission denied). The current plist uses the /bin/zsh -lc form not just for login-shell compatibility, but also to sidestep the permission problem.


Best practices

Here are the operational patterns I got from actually running this, ordered by how reproducible they are.

1. Run --dry-run first, immediately after any change

bash ~/.claude/scripts/autoskills-sync.sh --dry-run
Enter fullscreen mode Exit fullscreen mode

When 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 appears at the end of the log after the dry run.

2. Consolidate stdout and stderr into the same file

Point the plist's StandardOutPath and StandardErrorPath at the same path, and have the script's log() function append to the same file with tee -a. 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, both paths point at ~/.claude/logs/com.shun.autoskills-sync.log.

3. Make exclusion rules a "denylist," not an "allowlist," and err on the safe side

A 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 and go.mod are scattered in surprising places. Narrowing discovery to maxdepth 2 under ~ and ~/dev while making the denylist (the is_excluded function) 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 first.

4. Write full paths in the plist's EnvironmentVariables

~ isn't expanded in a plist. If you're using nvm-managed Node.js, write a version-pinned full path into PATH. When you change the Node.js version with nvm use, 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.

5. Don't pollute the working directory — use subshell cd

out="$(cd "$d" && npx -y autoskills ${DRY:---yes} 2>&1)"
Enter fullscreen mode Exit fullscreen mode

Doing the cd inside $() confines the directory change to a subshell. The effect of cd isn't carried into the loop's next iteration. The same goes for the gitignore check's (cd "$d" && git rev-parse ...). When you're "looping over commands that depend on the current directory," this pattern is the simplest and most reliable.

6. Keep bash 3.2 compatibility with while IFS= read -r instead of mapfile

macOS's default /bin/bash is version 3.2. mapfile (readarray) is a bash 4.0-and-later feature. Substitute a while IFS= read -r loop, 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 in the name of "optimization" and breaks it. Disabling field splitting with IFS= and ignoring backslash escapes with -r lets you read paths containing spaces or parentheses accurately.

7. Put the empty-array guard ahead of set -u

if [ "${#CANDIDATES[@]}" -eq 0 ]; then
  log "==== no candidates found; nothing to sync ===="; exit 0
fi
Enter fullscreen mode Exit fullscreen mode

When set -u is active, expanding ${arr[@]} against an empty array raises an unbound variable error under bash 3.2. ${#arr[@]} returns 0 even for an empty array and doesn't error under set -u. 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.

8. Handle autoskills' output format changes with a two-stage grep

n="$(echo "$out" | grep -oE '([0-9]+) skills installed' | grep -oE '^[0-9]+' | head -1)"
[ -z "$n" ] && n="$(echo "$out" | grep -oE 'Skills to install \(([0-9]+)\)' | grep -oE '[0-9]+' | head -1)"
[ -z "$n" ] && n=0
Enter fullscreen mode Exit fullscreen mode

An 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 is there to protect against a multi-line match returning and causing an error in the subsequent numeric comparison.

9. Use -qxF as a three-character set for gitignore appending

grep -qxF "$pat" "$d/.gitignore" 2>/dev/null || echo "$pat" >> "$d/.gitignore"
Enter fullscreen mode Exit fullscreen mode

-q (quiet — return only whether there was a match), -x (whole-line match), and -F (fixed string) work as a set of three. Without -F, the leading . of .agents/ becomes a regex wildcard. Without -x, it partially matches a comment line like # .agents/. 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."

10. Include a count summary in the log's completion line

==== done: 2 projects / 20 skills, 1 excluded ====
Enter fullscreen mode Exit fullscreen mode

Because 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 gets the weekly check done in 30 seconds.

11. Set RunAtLoad to false and verify the first time with launchctl start

launchctl load ~/Library/LaunchAgents/com.shun.autoskills-sync.plist
launchctl start com.shun.autoskills-sync
tail -20 ~/.claude/logs/com.shun.autoskills-sync.log
Enter fullscreen mode Exit fullscreen mode

Loading the plist with RunAtLoad=false and triggering manually with launchctl start gives you both "verify behavior without waiting until Sunday" and "prevention of an unintended immediate run." If the done: line appears in the log during the first check, the configuration is working correctly.

12. Set ProcessType explicitly to Background

<key>ProcessType</key>
<string>Background</string>
Enter fullscreen mode Exit fullscreen mode

Omitting it (Adaptive) 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 explicitly is insurance in job scheduling.

13. When you change a plist, respect the unload → load order

launchctl unload ~/Library/LaunchAgents/com.shun.autoskills-sync.plist
launchctl load  ~/Library/LaunchAgents/com.shun.autoskills-sync.plist
Enter fullscreen mode Exit fullscreen mode

Re-running only load leaves 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.

14. When you change the Node.js version, update the plist too

When you switch Node.js versions with nvm, the version number inside the plist's EnvironmentVariables PATH needs 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, 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.

15. Understand the design intent behind deliberately omitting set -e

The reason there's no set -e at the top of the script is that when the is_excluded function returns return 1 (not excluded = false), the shell interprets it as "the command failed." With set -e active, there are cases where the whole script terminates the moment return 1 occurs during the condition evaluation of if is_excluded "$d"; then. When you make heavy use of functions containing conditional branches, an explicit || { log "error: ..."; exit 1; } misfires less than set -e. The current configuration, keeping only set -u and pipefail, is the result of that judgment.


Summary

Skills accumulating is meaningless if they never arrive — this simple problem is what produced the weekly auto-distribution mechanism.

The 88 lines of autoskills-sync.sh string 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.

Underneath 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 first, 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.

Growing 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.


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)