feat: reproducible agent skill collection with install/update tooling

- skills-lock.json pins every skill (extracted from infinito-nexus-core)
- make install (global to $HOME) and make project TARGET=<repo> copy the
  rendered skills into .agents/skills + .claude/skills
- make update refreshes the lock via npx skills; make test validates the
  lock shape and shellchecks the scripts
- CI: test workflow on push/PR, daily update workflow opening a PR
- MIRRORS for code.infinito.nexus and git.veen.world
This commit is contained in:
2026-07-10 11:23:51 +02:00
parent 2970bcb888
commit 16c8b7f4af
12 changed files with 282 additions and 36 deletions

16
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: 🧪 Test
on:
push:
pull_request:
jobs:
test:
name: 🧪 Lock + scripts
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v7
- name: 🧪 Run tests
run: make test

38
.github/workflows/update.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: 🔄 Update
on:
schedule:
- cron: "17 4 * * *"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-skills:
name: 🧠 Update skills-lock.json
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v7
- name: 🔧 Set up Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
- name: 🔄 Update skills
run: make update
- name: 🧪 Run tests
run: make test
- name: 📂 Open PR if skills-lock.json changed
uses: peter-evans/create-pull-request@v7
with:
branch: update/skills
commit-message: "update(skills): update skills-lock.json to latest versions"
title: "update(skills): update agent skills to latest versions"
body: "Automated daily update of `skills-lock.json` via `make update`."
add-paths: skills-lock.json

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@ dist/
build/ build/
__pycache__/ __pycache__/
*.pyc *.pyc
.agents/
.claude/
node_modules/

3
MIRRORS Normal file
View File

@@ -0,0 +1,3 @@
git@github.com:kevinveenbirkenbach/skills.git
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/skills.git
ssh://git@git.veen.world:2201/kevinveenbirkenbach/skills.git

19
Makefile Normal file
View File

@@ -0,0 +1,19 @@
.PHONY: install project test update
# Install the skills globally into $HOME (.agents/skills + .claude/skills).
install:
@TARGET="$(HOME)" bash scripts/install.sh
# Copy the skills into a project: make project TARGET=/path/to/repo
project:
@test -n "$(TARGET)" || { echo "usage: make project TARGET=/path/to/repo"; exit 2; }
@TARGET="$(TARGET)" bash scripts/install.sh
# Validate skills-lock.json and shellcheck the scripts.
test:
@python3 -m unittest discover -s tests -v
@if command -v shellcheck >/dev/null; then shellcheck scripts/*.sh; else echo "shellcheck not installed - skipped"; fi
# Update all skills to their latest versions and refresh skills-lock.json.
update:
@bash scripts/update.sh

View File

@@ -1,6 +1,20 @@
# skills # skills 🧠
Homepage: https://github.com/kevinveenbirkenbach/skills Reproducible agent skill collection, pinned in `skills-lock.json`.
Works for Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline, and more.
## Author Homepage: [github.com/kevinveenbirkenbach/skills](https://github.com/kevinveenbirkenbach/skills)
Kevin Veen-Birkenbach <kevin@veen.world>
## Usage 🚀
- `make install` installs the skills globally into your home (`~/.agents/skills` and `~/.claude/skills`).
- `make project TARGET=/path/to/repo` copies the skills into a project (`<repo>/.agents/skills` and `<repo>/.claude/skills`).
- `make update` refreshes every skill to its latest version and rewrites `skills-lock.json`.
- `make test` validates `skills-lock.json` and shellchecks the scripts; CI runs it on every push and after every update.
Node.js is REQUIRED (`npx skills`); `make update` additionally needs `jq`.
Restart your agent after installing so it loads the new skills.
## Author 👤
Kevin Veen-Birkenbach [kevin@veen.world](mailto:kevin@veen.world)

View File

@@ -1,11 +0,0 @@
{
description = "skills";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; };
in {
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [ python312 python312Packages.pytest python312Packages.ruff ];
};
};
}

View File

@@ -1,21 +0,0 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "skills"
version = "0.1.0"
description = ""
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }]
license = { text = "All rights reserved by Kevin Veen-Birkenbach" }
urls = { Homepage = "https://github.com/kevinveenbirkenbach/skills" }
dependencies = []
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]

42
scripts/install.sh Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Render the skills from skills-lock.json and copy them into TARGET.
# TARGET defaults to $HOME (global install); pass a project root to
# equip that project. Skills land in <TARGET>/.agents/skills and are
# mirrored to <TARGET>/.claude/skills.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
TARGET="${TARGET:-${HOME}}"
log() { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; }
if ! command -v npx &>/dev/null; then
warn "skills: npx not found — install Node.js first: https://nodejs.org"
exit 1
fi
if [[ ! -d "${TARGET}" ]]; then
warn "skills: TARGET does not exist: ${TARGET}"
exit 2
fi
cd "${REPO_ROOT}"
log "skills: restoring from skills-lock.json..."
npx --yes skills experimental_install
src="${REPO_ROOT}/.agents/skills"
if [[ ! -d "${src}" ]]; then
warn "skills: ${src} not found after install."
exit 1
fi
for dst in "${TARGET}/.agents/skills" "${TARGET}/.claude/skills"; do
log "skills: copying -> ${dst}"
rm -rf "${dst}"
mkdir -p "${dst}"
cp -a "${src}/." "${dst}/"
done
log "skills: done. Restart your agent to load the skills."

35
scripts/update.sh Normal file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Update all skills to their latest versions and refresh skills-lock.json.
# Reads unique sources from skills-lock.json and re-runs skills add for each.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
log() { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; }
if ! command -v npx &>/dev/null; then
warn "skills: npx not found — install Node.js first: https://nodejs.org"
exit 1
fi
if ! command -v jq &>/dev/null; then
warn "skills: jq not found — cannot parse skills-lock.json."
exit 1
fi
cd "${REPO_ROOT}"
lockfile="skills-lock.json"
if [[ ! -f "${lockfile}" ]]; then
warn "skills: ${lockfile} not found in ${REPO_ROOT}."
exit 1
fi
log "skills: updating from ${lockfile}..."
jq -r '.skills[].source' "${lockfile}" | sort -u | while read -r source; do
log "skills: updating ${source}..."
npx --yes skills add --yes "${source}"
done
log "skills: update complete."

53
skills-lock.json Normal file
View File

@@ -0,0 +1,53 @@
{
"version": 1,
"skills": {
"cavecrew": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/cavecrew/SKILL.md",
"computedHash": "c5527c994fbd4c22b36714e3b124a0f167a533d114ab164fb1d35e2123533917"
},
"caveman": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman/SKILL.md",
"computedHash": "d18cdf73a5f5c496d681a43a6846336b110223bdffa6817b8992529c57f1a815"
},
"caveman-commit": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-commit/SKILL.md",
"computedHash": "790a4eeace0be35c6691faf923518ba5bd50f1f1305d1101d09dd4971be94e00"
},
"caveman-compress": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-compress/SKILL.md",
"computedHash": "84517cd4cf7a49d8d2bc1baf00f61bc359306c6ad9389756b6937eff958bd374"
},
"caveman-help": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-help/SKILL.md",
"computedHash": "dd85267e76baad76995157e7b9f762dfa557cd58951ee92af0c283f48aa26537"
},
"caveman-review": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-review/SKILL.md",
"computedHash": "fb7214a1c5793bae6ba8b1be4329e2e6f40dbec6dd911dfb335ad29f09c316a1"
},
"caveman-stats": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-stats/SKILL.md",
"computedHash": "47ce2de3d6cb39a75047b5c962e4eb3da15594e7397c94103e9a104d42626553"
},
"compress": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/compress/SKILL.md",
"computedHash": "c91bdf427a68b686ee9d6f63766c72d164b16915434736edfedf13e24dae0e8c"
}
}
}

55
tests/test_lock.py Normal file
View File

@@ -0,0 +1,55 @@
"""Validate skills-lock.json: shape, required fields, and hash format."""
from __future__ import annotations
import json
import re
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
LOCK_FILE = REPO_ROOT / "skills-lock.json"
HASH_RE = re.compile(r"^[0-9a-f]{64}$")
REQUIRED_FIELDS = ("source", "sourceType", "skillPath", "computedHash")
class TestSkillsLock(unittest.TestCase):
def setUp(self):
self.lock = json.loads(LOCK_FILE.read_text(encoding="utf-8"))
def test_version_is_supported(self):
self.assertEqual(self.lock.get("version"), 1)
def test_skills_present(self):
self.assertTrue(self.lock.get("skills"), "lock holds no skills")
def test_every_skill_is_complete(self):
for name, entry in self.lock["skills"].items():
with self.subTest(skill=name):
for field in REQUIRED_FIELDS:
self.assertTrue(
str(entry.get(field, "")).strip(),
f"{name}: missing or empty field '{field}'",
)
self.assertRegex(
entry["computedHash"],
HASH_RE,
f"{name}: computedHash is not a sha256 hex digest",
)
self.assertTrue(
entry["skillPath"].endswith("SKILL.md"),
f"{name}: skillPath must point to a SKILL.md",
)
def test_skills_sorted_ascending(self):
names = list(self.lock["skills"].keys())
self.assertEqual(
names,
sorted(names),
f"skills must be sorted ascending; expected {sorted(names)}",
)
if __name__ == "__main__":
unittest.main()