refactor(plugin)!: ship as a Claude Code plugin, one dir link per harness
Skills move to skills/<name>/SKILL.md and the repo gains both manifests, so it is now a marketplace hosting exactly one plugin whose root is the repo root (source: "."). Claude Code installs it from git; no symlinks involved. Why: install.sh wired the library into every consuming repo as an ABSOLUTE symlink to $CHECKOUT_DIR. The checkout moved three times, most recently when local-dev@1966d5a dropped the embedded .skills/ copy, and nothing migrated the links — 243 dangling links across 12 repos, hidden from git by the **/.claude/skills/ ignore rule. Worse, ~/dev spans two path roots (/Users/mathias on the iCloud-shared Macs, /home/mathias on koala), so an absolute link stored there is broken on one side by construction. Re-running the installer cannot fix that; only removing the mechanism can. install.sh keeps the harnesses with no plugin system and drops from 42 links per harness to one directory link, via the convergent ~/.agents/skills convention that Pi and OpenCode both read: ~/.agents/skills -> $CHECKOUT_DIR/skills ~/.config/crush/skills -> ~/.agents/skills ~/.config/opencode/skills -> ~/.agents/skills wire_claude_repo is deleted outright rather than gated behind a flag — on a mixed-root fleet it cannot be made correct. Antigravity and Vibe targets go too; both unused. CHECKOUT_DIR keeps its host-local default deliberately: pointing it into ~/dev would put a git working tree inside iCloud with two concurrent writers. Taskfile drops five near-identical wiring loops (they had already drifted) and delegates to install.sh. `task check` gains frontmatter validation, a stray-root-entry gate, and manifest/tag version parity — the loose secret-hygiene.md sat unloadable for months because nothing checked layout. Verified: claude plugin validate . passes clean; a probe skill loaded as skills:zzprobe through --plugin-dir; task check green at 42 skills. Refs: #23 Bump-Type: minor Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
This commit is contained in:
+89
-154
@@ -2,22 +2,19 @@ version: '3'
|
||||
|
||||
# Skills library Taskfile.
|
||||
#
|
||||
# Primary entry point for project + host installation. Consumer projects
|
||||
# do NOT depend on this file directly — they ship their own
|
||||
# `skills:install` target that delegates here via `install.sh`. This
|
||||
# Taskfile is invoked by maintainers of the skills repo itself, plus by
|
||||
# install.sh during the post-clone wire step.
|
||||
# This repo is two things at once:
|
||||
# 1. A Claude Code PLUGIN (skills + hooks + MCP servers), distributed through
|
||||
# the marketplace this same repo hosts at .claude-plugin/marketplace.json.
|
||||
# 2. A plain skills library for harnesses with no plugin system — Crush, Pi,
|
||||
# OpenCode — wired by install.sh as ONE directory symlink per harness.
|
||||
#
|
||||
# The wiring loops that used to live here were five near-identical copies that
|
||||
# drifted; install.sh owns wiring now and this file delegates to it.
|
||||
|
||||
vars:
|
||||
REPO_URL: 'https://git.d-ma.be/mathias/skills.git'
|
||||
CHECKOUT_DIR: '{{.HOME}}/.local/share/skills'
|
||||
CLAUDE_GLOBAL_DIR: '{{.HOME}}/.claude/skills'
|
||||
CRUSH_DIR: '{{.HOME}}/.config/crush/skills'
|
||||
ANTIGRAVITY_DIR: '{{.HOME}}/.gemini/antigravity/skills'
|
||||
VIBE_DIR: '{{.HOME}}/.vibe/skills'
|
||||
# Anything at the repo root that is NOT a skill directory. Both
|
||||
# Taskfile + install.sh share this exclusion regex.
|
||||
NON_SKILL_ENTRIES: '^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$'
|
||||
SKILLS_DIR: '{{.TASKFILE_DIR}}/skills'
|
||||
PLUGIN_MANIFEST: '{{.TASKFILE_DIR}}/.claude-plugin/plugin.json'
|
||||
|
||||
tasks:
|
||||
|
||||
@@ -28,134 +25,95 @@ tasks:
|
||||
list:
|
||||
desc: 'Print every skill in this repo'
|
||||
cmds:
|
||||
- 'ls -1 {{.TASKFILE_DIR}} | grep -Ev "^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$" || true'
|
||||
- 'ls -1 {{.SKILLS_DIR}}'
|
||||
silent: true
|
||||
|
||||
install:
|
||||
desc: 'Wire skills into every supported harness (Claude Code, Crush, Antigravity, Mistral Vibe).'
|
||||
desc: 'Wire THIS checkout into Crush / Pi / OpenCode. Claude Code uses the plugin instead.'
|
||||
cmds:
|
||||
- task: install:claude:global
|
||||
- task: install:claude:repo
|
||||
- task: install:crush
|
||||
- task: install:antigravity
|
||||
- task: install:vibe
|
||||
|
||||
install:claude:global:
|
||||
desc: 'Per-skill symlinks under ~/.claude/skills/<name> — visible in every Claude Code project on this host.'
|
||||
cmds:
|
||||
- mkdir -p {{.CLAUDE_GLOBAL_DIR}}
|
||||
- |
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
||||
target="{{.TASKFILE_DIR}}/${skill}"
|
||||
link="{{.CLAUDE_GLOBAL_DIR}}/${skill}"
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$link"
|
||||
fi
|
||||
ln -s "$target" "$link"
|
||||
echo "linked $link → $target"
|
||||
done
|
||||
|
||||
install:claude:repo:
|
||||
desc: 'Per-skill symlinks under $(pwd)/.claude/skills/<name> when invoked inside a git repo.'
|
||||
cmds:
|
||||
- |
|
||||
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
echo "not in a git repo — skipping per-repo wire"; exit 0
|
||||
fi
|
||||
repo=$(git rev-parse --show-toplevel)
|
||||
if [ "$repo" = "{{.TASKFILE_DIR}}" ]; then
|
||||
echo "running from skills repo itself — skipping per-repo wire"; exit 0
|
||||
fi
|
||||
target_dir="$repo/.claude/skills"
|
||||
mkdir -p "$target_dir"
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
||||
target="{{.TASKFILE_DIR}}/${skill}"
|
||||
link="${target_dir}/${skill}"
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$link"
|
||||
fi
|
||||
ln -s "$target" "$link"
|
||||
echo "linked $link → $target"
|
||||
done
|
||||
|
||||
install:crush:
|
||||
desc: 'Per-skill symlinks under ~/.config/crush/skills/<name> — visible in every Crush session on this host.'
|
||||
cmds:
|
||||
- mkdir -p {{.CRUSH_DIR}}
|
||||
- |
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
||||
target="{{.TASKFILE_DIR}}/${skill}"
|
||||
link="{{.CRUSH_DIR}}/${skill}"
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$link"
|
||||
fi
|
||||
ln -s "$target" "$link"
|
||||
echo "linked $link → $target"
|
||||
done
|
||||
|
||||
install:antigravity:
|
||||
desc: 'Per-skill symlinks under ~/.gemini/antigravity/skills/<name>. SKILL.md frontmatter (name + description) is already antigravity-compatible.'
|
||||
cmds:
|
||||
- mkdir -p {{.ANTIGRAVITY_DIR}}
|
||||
- |
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
||||
target="{{.TASKFILE_DIR}}/${skill}"
|
||||
link="{{.ANTIGRAVITY_DIR}}/${skill}"
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$link"
|
||||
fi
|
||||
ln -s "$target" "$link"
|
||||
echo "linked $link → $target"
|
||||
done
|
||||
|
||||
install:vibe:
|
||||
desc: 'Per-skill file symlinks under ~/.vibe/skills/<name>.md → <skill>/SKILL.md for Mistral Vibe (flat .md layout, not a directory).'
|
||||
cmds:
|
||||
- mkdir -p {{.VIBE_DIR}}
|
||||
- |
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
||||
target="{{.TASKFILE_DIR}}/${skill}/SKILL.md"
|
||||
[ -f "$target" ] || continue
|
||||
link="{{.VIBE_DIR}}/${skill}.md"
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$link"
|
||||
fi
|
||||
ln -s "$target" "$link"
|
||||
echo "linked $link → $target"
|
||||
done
|
||||
- SKILLS_SKIP_FETCH=1 SKILLS_CHECKOUT_DIR='{{.TASKFILE_DIR}}' bash '{{.TASKFILE_DIR}}/install.sh'
|
||||
|
||||
update:
|
||||
desc: 'git pull the canonical checkout then re-run install.'
|
||||
desc: 'Fast-forward this checkout and re-wire.'
|
||||
cmds:
|
||||
- |
|
||||
if [ ! -d "{{.CHECKOUT_DIR}}/.git" ]; then
|
||||
echo "no canonical checkout at {{.CHECKOUT_DIR}} — run install.sh first"; exit 1
|
||||
fi
|
||||
git -C "{{.CHECKOUT_DIR}}" pull --ff-only
|
||||
- git -C '{{.TASKFILE_DIR}}' pull --ff-only
|
||||
- task: install
|
||||
|
||||
doctor:
|
||||
desc: 'Report drift across every harness on this host. Non-zero exit means something is wrong.'
|
||||
cmds:
|
||||
- '{{.TASKFILE_DIR}}/bin/doctor'
|
||||
|
||||
validate:
|
||||
desc: 'Validate the plugin + marketplace manifests with the Claude Code CLI.'
|
||||
cmds:
|
||||
- claude plugin validate '{{.TASKFILE_DIR}}'
|
||||
|
||||
test:
|
||||
desc: 'Run the shell test suite (hooks + doctor).'
|
||||
cmds:
|
||||
- |
|
||||
fail=0
|
||||
for t in '{{.TASKFILE_DIR}}'/tests/*_test.sh; do
|
||||
[ -f "$t" ] || continue
|
||||
echo "--- $(basename "$t")"
|
||||
sh "$t" || fail=1
|
||||
done
|
||||
exit $fail
|
||||
|
||||
check:
|
||||
desc: 'Structure gate — layout, frontmatter, manifest/tag parity. Runs in CI.'
|
||||
cmds:
|
||||
- |
|
||||
fail=0
|
||||
|
||||
# 1. No stray entries at the repo root. A loose foo.md where
|
||||
# skills/foo/SKILL.md was meant loads in NO harness and errors in
|
||||
# none either — it just silently does nothing. (See #23.)
|
||||
for entry in "{{.TASKFILE_DIR}}"/*; do
|
||||
base=$(basename "$entry")
|
||||
case "$base" in
|
||||
install.sh|Taskfile.yml|README.md|SKILLS_INDEX.md|skills|hooks|bin|tests) continue ;;
|
||||
esac
|
||||
echo "ERROR: unexpected entry at repo root: $base" >&2
|
||||
fail=1
|
||||
done
|
||||
|
||||
# 2. Every skill is a directory with a SKILL.md carrying name + description.
|
||||
count=0
|
||||
for dir in "{{.SKILLS_DIR}}"/*; do
|
||||
name=$(basename "$dir")
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "ERROR: skills/$name is not a directory" >&2; fail=1; continue
|
||||
fi
|
||||
if [ ! -f "$dir/SKILL.md" ]; then
|
||||
echo "ERROR: skills/$name missing SKILL.md" >&2; fail=1; continue
|
||||
fi
|
||||
head -n 20 "$dir/SKILL.md" | grep -q '^name:' || { echo "ERROR: skills/$name SKILL.md has no 'name:' frontmatter" >&2; fail=1; }
|
||||
head -n 20 "$dir/SKILL.md" | grep -q '^description:' || { echo "ERROR: skills/$name SKILL.md has no 'description:' frontmatter" >&2; fail=1; }
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
# 3. Plugin manifest version must match the latest tag, or installers
|
||||
# never see the update (Claude Code gates updates on this field).
|
||||
if [ -f '{{.PLUGIN_MANIFEST}}' ]; then
|
||||
manifest_v=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' '{{.PLUGIN_MANIFEST}}' | head -1)
|
||||
tag_v=$(git -C '{{.TASKFILE_DIR}}' describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo '')
|
||||
if [ -n "$tag_v" ] && [ "$manifest_v" != "$tag_v" ]; then
|
||||
echo "ERROR: plugin.json version ($manifest_v) != latest tag ($tag_v)" >&2
|
||||
fail=1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1
|
||||
fi
|
||||
|
||||
if [ $fail -ne 0 ]; then
|
||||
echo "check FAILED" >&2; exit 1
|
||||
fi
|
||||
echo "check OK — $count skills, manifest matches tag"
|
||||
|
||||
release:
|
||||
desc: 'Tag HEAD per Bump-Type in the latest commit footer. Normally CI handles this — run locally only when CI is unavailable.'
|
||||
desc: 'Tag HEAD per Bump-Type in the latest commit footer. Normally CI handles this.'
|
||||
cmds:
|
||||
- |
|
||||
latest=$(git -C "{{.TASKFILE_DIR}}" describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||
@@ -172,26 +130,3 @@ tasks:
|
||||
echo "tagging $next (previous $latest, bump=${bump:-patch})"
|
||||
git -C "{{.TASKFILE_DIR}}" tag -a "$next" -m "release $next"
|
||||
echo "now: git push origin $next"
|
||||
|
||||
check:
|
||||
desc: 'Sanity check — every skill dir has a SKILL.md, no stray files at root.'
|
||||
cmds:
|
||||
- |
|
||||
missing=0
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$"); do
|
||||
dir="{{.TASKFILE_DIR}}/${skill}"
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "ERROR: $skill is not a directory" >&2
|
||||
missing=$((missing + 1))
|
||||
continue
|
||||
fi
|
||||
if [ ! -f "$dir/SKILL.md" ]; then
|
||||
echo "ERROR: $dir missing SKILL.md" >&2
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
done
|
||||
if [ $missing -gt 0 ]; then
|
||||
echo "$missing skill(s) failed structure check" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "all skills have SKILL.md"
|
||||
|
||||
Reference in New Issue
Block a user