version: '3' # Skills library Taskfile. # # 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' SKILLS_DIR: '{{.TASKFILE_DIR}}/skills' PLUGIN_MANIFEST: '{{.TASKFILE_DIR}}/.claude-plugin/plugin.json' tasks: default: cmds: - task: list list: desc: 'Print every skill in this repo' cmds: - 'ls -1 {{.SKILLS_DIR}}' silent: true install: desc: 'Wire THIS checkout into Crush / Pi / OpenCode. Claude Code uses the plugin instead.' cmds: - SKILLS_SKIP_FETCH=1 SKILLS_CHECKOUT_DIR='{{.TASKFILE_DIR}}' bash '{{.TASKFILE_DIR}}/install.sh' update: desc: 'Fast-forward this checkout and re-wire.' cmds: - 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 index: desc: 'Regenerate SKILLS_INDEX.md''s table from skill frontmatter.' cmds: - "'{{.TASKFILE_DIR}}/bin/generate-index' '{{.TASKFILE_DIR}}'" check: desc: 'Structure gate — layout, frontmatter, surfaces, generated index, 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|agents) 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. SKILLS_INDEX.md's table is generated from frontmatter, so the # assertion is that regenerating it changes nothing. This replaces # bin/check-index: a generated table cannot drift in either # direction, so there is no separate rule left to check. sh '{{.TASKFILE_DIR}}/bin/generate-index' --check '{{.TASKFILE_DIR}}' || fail=1 # 4. Every skill says which surfaces it ships to, and names only # surfaces that exist — a wrong value silently drops a skill from a # harness instead of erroring. sh '{{.TASKFILE_DIR}}/bin/check-surfaces' '{{.TASKFILE_DIR}}' || fail=1 # 5. A documented session_log call must be one the tool would accept: # session_id is its only required field, and nine skills omitted it. sh '{{.TASKFILE_DIR}}/bin/check-session-log' '{{.TASKFILE_DIR}}' || fail=1 # 6. Every skill says when it stops and hands back (#5). Skills whose # rule is not written yet are listed in bin/escalation-pending.txt, # which only shrinks. sh '{{.TASKFILE_DIR}}/bin/check-escalation' '{{.TASKFILE_DIR}}' || fail=1 # 7. 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 '') # Ordering, not equality. The fault this guards is the manifest # failing to MOVE — a release tag whose manifest still names the old # version, so no installed host ever sees the update. A manifest # ahead of the newest tag this checkout knows about is the normal # state between the tagger's bump commit and a tag fetch (#34). if [ -n "$tag_v" ] && [ "$manifest_v" != "$tag_v" ] \ && [ "$(sh '{{.TASKFILE_DIR}}/bin/newer-version' "$manifest_v" "$tag_v")" = "$tag_v" ]; then echo "ERROR: plugin.json version ($manifest_v) is behind the latest tag ($tag_v) — installed hosts never see the update" >&2 fail=1 fi else echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1 fi # Every ${VAR} a live .mcp.json server interpolates must be one the # session env-file defines. An undefined one is silent: the header # goes out empty and the server reports "requires authentication", # which reads as a revoked credential. Exit 2 means the check could # not run (no jq, or no env-file — CI has neither); that is reported # and not fatal, but it is never treated as a pass. if [ -x "{{.TASKFILE_DIR}}/bin/check-mcp-env" ]; then # `|| rc=$?` is load-bearing, not style. Task runs this block under # `sh -e`, so a bare invocation exiting non-zero kills the shell # before `case` is reached -- the tolerance below never ran, and the # gate died with `exit status 2` on the commit that added it. rc=0 "{{.TASKFILE_DIR}}/bin/check-mcp-env" "{{.TASKFILE_DIR}}/.mcp.json" "$HOME/.op-env" || rc=$? case $rc in 0) ;; 2) echo "NOTE: MCP env wiring unverified on this host" >&2 ;; *) fail=1 ;; esac 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.' cmds: - | latest=$(git -C "{{.TASKFILE_DIR}}" describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") major=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\1/') minor=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\2/') patch=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\3/') bump=$(git -C "{{.TASKFILE_DIR}}" log -1 --pretty=%B | grep -iE "^Bump-Type:" | head -1 | awk '{print tolower($2)}') case "$bump" in major) major=$((major + 1)); minor=0; patch=0 ;; minor) minor=$((minor + 1)); patch=0 ;; *) patch=$((patch + 1)) ;; esac next="v${major}.${minor}.${patch}" echo "tagging $next (previous $latest, bump=${bump:-patch})" git -C "{{.TASKFILE_DIR}}" tag -a "$next" -m "release $next" echo "now: git push origin $next"