bin/check-mcp-env exits 2 for "could not run" -- no jq, or no env-file --
and the gate was written to report that as a NOTE rather than fail:
"$DIR/check-mcp-env" "$DIR/.mcp.json" "$HOME/.op-env"
case $? in 0) ;; 2) echo "NOTE: ..." ;; *) fail=1 ;; esac
That tolerance never executed. Task runs each cmds block under `sh -e`, so
a bare command exiting non-zero aborts the shell before `case $?` is
reached. main went red on the very commit that added the check (run #57),
with `task: Failed to run task "check": exit status 2`.
It passed locally because this host has both jq and ~/.op-env, so the
check exited 0 and the untested branch never ran. CI has neither. A
tolerated exit code that is only tolerated where it never fires is not
tolerated at all.
`|| rc=$?` puts the invocation in a compound command, which `set -e`
exempts, so `case` is reached. Both exit-2 paths verified: no env-file
(the new test's precondition) and no jq (PATH sandbox, same SKIP message
CI printed).
The test pins the tolerance itself rather than the checker: it points HOME
at an empty directory, which is exactly CI's condition, and asserts `task
check` exits 0 AND still says it could not verify -- silence would mean
skipped rather than tolerated.
Bump-Type: patch
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
8.0 KiB
YAML
184 lines
8.0 KiB
YAML
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) 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"
|