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*