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:
2026-08-23 14:29:51 +02:00
co-authored by Claude Opus 5
parent 21be3ea245
commit 56a947b2a0
88 changed files with 182 additions and 253 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"name": "mathias",
"description": "Mathias's private plugin marketplace, hosted on gitea.d-ma.be. Self-hosted by design — nothing here transits GitHub.",
"owner": {
"name": "Mathias",
"url": "https://git.d-ma.be/mathias"
},
"plugins": [
{
"name": "skills",
"source": ".",
"description": "Engineering skills, the pre-task-ritual and secret-guard hooks, and the brain/gitea/infra MCP servers as one versioned unit."
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "skills",
"description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.",
"version": "0.3.3",
"author": {
"name": "Mathias",
"url": "https://git.d-ma.be/mathias"
},
"homepage": "https://git.d-ma.be/mathias/skills",
"repository": "https://git.d-ma.be/mathias/skills.git"
}
+89 -154
View File
@@ -2,22 +2,19 @@ version: '3'
# Skills library Taskfile. # Skills library Taskfile.
# #
# Primary entry point for project + host installation. Consumer projects # This repo is two things at once:
# do NOT depend on this file directly — they ship their own # 1. A Claude Code PLUGIN (skills + hooks + MCP servers), distributed through
# `skills:install` target that delegates here via `install.sh`. This # the marketplace this same repo hosts at .claude-plugin/marketplace.json.
# Taskfile is invoked by maintainers of the skills repo itself, plus by # 2. A plain skills library for harnesses with no plugin system — Crush, Pi,
# install.sh during the post-clone wire step. # 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: vars:
REPO_URL: 'https://git.d-ma.be/mathias/skills.git' REPO_URL: 'https://git.d-ma.be/mathias/skills.git'
CHECKOUT_DIR: '{{.HOME}}/.local/share/skills' SKILLS_DIR: '{{.TASKFILE_DIR}}/skills'
CLAUDE_GLOBAL_DIR: '{{.HOME}}/.claude/skills' PLUGIN_MANIFEST: '{{.TASKFILE_DIR}}/.claude-plugin/plugin.json'
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$)$'
tasks: tasks:
@@ -28,134 +25,95 @@ tasks:
list: list:
desc: 'Print every skill in this repo' desc: 'Print every skill in this repo'
cmds: 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 silent: true
install: 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: cmds:
- task: install:claude:global - SKILLS_SKIP_FETCH=1 SKILLS_CHECKOUT_DIR='{{.TASKFILE_DIR}}' bash '{{.TASKFILE_DIR}}/install.sh'
- 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
update: update:
desc: 'git pull the canonical checkout then re-run install.' desc: 'Fast-forward this checkout and re-wire.'
cmds: cmds:
- | - git -C '{{.TASKFILE_DIR}}' pull --ff-only
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
- task: install - 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: 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: cmds:
- | - |
latest=$(git -C "{{.TASKFILE_DIR}}" describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") 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})" echo "tagging $next (previous $latest, bump=${bump:-patch})"
git -C "{{.TASKFILE_DIR}}" tag -a "$next" -m "release $next" git -C "{{.TASKFILE_DIR}}" tag -a "$next" -m "release $next"
echo "now: git push origin $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"
+67 -99
View File
@@ -5,25 +5,55 @@
# curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash # curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
# #
# Pinned to a specific tag: # Pinned to a specific tag:
# SKILLS_REF=v0.1.0 bash install.sh # SKILLS_REF=v0.4.0 bash install.sh
# #
# Idempotent: re-running pulls the latest changes for the configured ref # Idempotent: re-running pulls the latest changes for the configured ref
# (default: main) and re-wires symlinks. Existing correct links are # (default: main) and re-wires the links.
# left alone; mismatched ones are replaced. #
# Claude Code is NOT wired here. It installs this repo as a plugin from the
# marketplace this repo also hosts:
#
# /plugin marketplace add https://git.d-ma.be/mathias/skills.git
# /plugin install skills@mathias
#
# That path carries the hooks and MCP server definitions too, which symlinks
# cannot. This script covers the harnesses with no plugin system: Crush, Pi
# and OpenCode.
#
# Wiring model — one directory link per harness, never one per skill:
#
# ~/.agents/skills -> $CHECKOUT_DIR/skills (canonical; Pi reads it directly)
# ~/.config/crush/skills -> ~/.agents/skills
# ~/.config/opencode/skills -> ~/.agents/skills
#
# Adding a skill to the repo therefore wires it everywhere with no re-run,
# and a broken link is one broken link rather than 42.
#
# CHECKOUT_DIR deliberately defaults OUTSIDE any file-synced or
# cross-platform-shared directory. Do not "fix" it to point into ~/dev: on a
# fleet whose workspace is shared between macOS (/Users/...) and Linux
# (/home/...), an absolute link stored there is broken on one side by
# construction. See mathias/skills#23.
set -euo pipefail set -euo pipefail
REPO_URL="${SKILLS_REPO_URL:-https://git.d-ma.be/mathias/skills.git}" REPO_URL="${SKILLS_REPO_URL:-https://git.d-ma.be/mathias/skills.git}"
REF="${SKILLS_REF:-main}" REF="${SKILLS_REF:-main}"
CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}" CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}"
CLAUDE_GLOBAL_DIR="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}" AGENTS_DIR="${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}" CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}"
ANTIGRAVITY_DIR="${ANTIGRAVITY_SKILLS_DIR:-$HOME/.gemini/antigravity/skills}" OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}"
VIBE_SKILLS_DIR="${VIBE_SKILLS_DIR:-$HOME/.vibe/skills}"
log() { printf '[skills] %s\n' "$*"; } log() { printf '[skills] %s\n' "$*"; }
ensure_checkout() { ensure_checkout() {
# Wiring an existing working checkout (task install) must not fetch or
# move HEAD — that would discard in-progress work.
if [ "${SKILLS_SKIP_FETCH:-}" = "1" ]; then
[ -d "$CHECKOUT_DIR/skills" ] || { log "no skills/ under $CHECKOUT_DIR"; exit 1; }
log "using $CHECKOUT_DIR as-is (SKILLS_SKIP_FETCH=1)"
return
fi
if [ -d "$CHECKOUT_DIR/.git" ]; then if [ -d "$CHECKOUT_DIR/.git" ]; then
log "updating $CHECKOUT_DIR" log "updating $CHECKOUT_DIR"
git -C "$CHECKOUT_DIR" fetch --tags --quiet git -C "$CHECKOUT_DIR" fetch --tags --quiet
@@ -40,109 +70,47 @@ ensure_checkout() {
fi fi
} }
list_skills() { count_skills() {
# Every top-level entry that isn't a known meta file. find "$CHECKOUT_DIR/skills" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
(cd "$CHECKOUT_DIR" && ls -1) | grep -Ev '^(Taskfile\.yml|install\.sh|README\.md|SKILLS_INDEX\.md|\.gitea|\.git)$' || true
} }
link_skill() { # link_dir <link_path> <target_path>
# link_skill <target_dir> <skill_name> # Replaces whatever is there — including a directory left behind by the old
local target_dir="$1" # per-skill wiring — with a single symlink.
local skill="$2" link_dir() {
local target="$CHECKOUT_DIR/$skill" local link="$1" target="$2"
local link="$target_dir/$skill" if [ -L "$link" ]; then
[ "$(readlink "$link")" = "$target" ] && return
if [ -L "$link" ] || [ -e "$link" ]; then rm -f "$link"
local current elif [ -d "$link" ]; then
current=$(readlink "$link" 2>/dev/null || true) # Refuse to discard a directory holding anything but symlinks: that
if [ "$current" = "$target" ]; then # would be someone's real skills, not our old wiring.
return if find "$link" -mindepth 1 -maxdepth 1 ! -type l | grep -q .; then
log "REFUSING to replace $link — it holds non-symlink entries; move them and re-run"
return 1
fi fi
rm -rf "$link" find "$link" -mindepth 1 -maxdepth 1 -type l -delete
rmdir "$link"
elif [ -e "$link" ]; then
rm -f "$link"
fi fi
mkdir -p "$(dirname "$link")"
ln -s "$target" "$link" ln -s "$target" "$link"
log "linked $link → $target" log "linked $link → $target"
} }
wire_claude_global() {
mkdir -p "$CLAUDE_GLOBAL_DIR"
while IFS= read -r skill; do
[ -n "$skill" ] || continue
link_skill "$CLAUDE_GLOBAL_DIR" "$skill"
done < <(list_skills)
}
wire_crush() {
# Crush reads skills from ~/.config/crush/skills/<name>/SKILL.md.
# Pre-creating the dir is cheap even when Crush isn't installed.
mkdir -p "$CRUSH_DIR"
while IFS= read -r skill; do
[ -n "$skill" ] || continue
link_skill "$CRUSH_DIR" "$skill"
done < <(list_skills)
}
wire_antigravity() {
# Antigravity (Google's VS Code extension) reads global skills from
# ~/.gemini/antigravity/skills/<name>/SKILL.md. Our SKILL.md files
# already carry the required `name` + `description` YAML frontmatter,
# so symlinks are sufficient — no manifest translation step.
mkdir -p "$ANTIGRAVITY_DIR"
while IFS= read -r skill; do
[ -n "$skill" ] || continue
link_skill "$ANTIGRAVITY_DIR" "$skill"
done < <(list_skills)
}
wire_claude_repo() {
# Only wire per-repo when invoked from inside a git repo and it isn't
# the skills repo itself.
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
return
fi
local repo
repo=$(git rev-parse --show-toplevel)
if [ "$repo" = "$CHECKOUT_DIR" ]; then
return
fi
local target_dir="$repo/.claude/skills"
mkdir -p "$target_dir"
while IFS= read -r skill; do
[ -n "$skill" ] || continue
link_skill "$target_dir" "$skill"
done < <(list_skills)
}
wire_vibe() {
# Mistral Vibe reads a flat ~/.vibe/skills/<name>.md — link the SKILL.md
# file directly (not the skill directory, as the other harnesses do).
mkdir -p "$VIBE_SKILLS_DIR"
while IFS= read -r skill; do
[ -n "$skill" ] || continue
local target="$CHECKOUT_DIR/$skill/SKILL.md"
[ -f "$target" ] || continue
local link="$VIBE_SKILLS_DIR/$skill.md"
if [ -L "$link" ] || [ -e "$link" ]; then
local current
current=$(readlink "$link" 2>/dev/null || true)
if [ "$current" = "$target" ]; then
continue
fi
rm -rf "$link"
fi
ln -s "$target" "$link"
log "linked $link → $target"
done < <(list_skills)
}
main() { main() {
ensure_checkout ensure_checkout
wire_claude_global
wire_claude_repo # Canonical location. Pi reads ~/.agents/skills directly, so it needs no
wire_crush # harness-specific link of its own.
wire_antigravity link_dir "$AGENTS_DIR" "$CHECKOUT_DIR/skills"
wire_vibe
log "done — $(list_skills | wc -l | tr -d ' ') skill(s) wired at ref=$REF" link_dir "$CRUSH_DIR" "$AGENTS_DIR"
link_dir "$OPENCODE_DIR" "$AGENTS_DIR"
log "done — $(count_skills) skill(s) at ref=$REF, 3 links total"
log "Claude Code: install the plugin instead — /plugin marketplace add $REPO_URL"
} }
main "$@" main "$@"