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:
16
.github/workflows/test.yml
vendored
Normal file
16
.github/workflows/test.yml
vendored
Normal 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
38
.github/workflows/update.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -3,3 +3,6 @@ dist/
|
||||
build/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.agents/
|
||||
.claude/
|
||||
node_modules/
|
||||
|
||||
3
MIRRORS
Normal file
3
MIRRORS
Normal 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
19
Makefile
Normal 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
|
||||
22
README.md
22
README.md
@@ -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
|
||||
Kevin Veen-Birkenbach <kevin@veen.world>
|
||||
Homepage: [github.com/kevinveenbirkenbach/skills](https://github.com/kevinveenbirkenbach/skills)
|
||||
|
||||
## 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)
|
||||
|
||||
11
flake.nix
11
flake.nix
@@ -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 ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
42
scripts/install.sh
Normal 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
35
scripts/update.sh
Normal 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
53
skills-lock.json
Normal 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
55
tests/test_lock.py
Normal 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()
|
||||
Reference in New Issue
Block a user