cd /news/developer-tools/four-failures-that-made-a-weekly-lau… · home topics developer-tools article
[ARTICLE · art-99168] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Four Failures That Made a Weekly launchd Job Actually Run

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.

read29 min views1 publishedAug 17, 2026

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.

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.

.claude/skills/auto/

(global).agents/

or .claude/skills/

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

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.

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

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>

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>

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.

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

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.

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|/[^/]+$||'

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

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.

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
}

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.

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

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.

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

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.

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 ====

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.

set -uo pipefail

— why these three flags line up this way It's the first line of the script.

set -uo pipefail

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

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

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

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.

At first I tried to build the collection with mapfile

.

mapfile -t CANDIDATES < <(find ...)

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.

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
)

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

set -u

trap

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

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.

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

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.

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>

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.

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
  for pat in ".agents/" ".claude/skills/" "skills-lock.json"; do
    grep -qxF "$pat" "$d/.gitignore" 2>/dev/null || echo "$pat" >> "$d/.gitignore"
  done
fi

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

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.

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

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

${#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.

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

[2026-XX-XX 06:10:33]   Caches: 3 skills

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 ;;

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.

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.

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

--dry-run

first, immediately after any change

bash ~/.claude/scripts/autoskills-sync.sh --dry-run

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.

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

.

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.

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

.

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

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.

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.

set -u

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

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.

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

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.

-qxF

as a three-character set for gitignore appending

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

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

==== done: 2 projects / 20 skills, 1 excluded ====

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.

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

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.

ProcessType

explicitly to Background

<key>ProcessType</key>
<string>Background</string>

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.

unload → load

order

launchctl unload ~/Library/LaunchAgents/com.shun.autoskills-sync.plist
launchctl load  ~/Library/LaunchAgents/com.shun.autoskills-sync.plist

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.

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.

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.

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*

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/four-failures-that-m…] indexed:0 read:29min 2026-08-17 ·