diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3696040 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml new file mode 100644 index 0000000..841d70f --- /dev/null +++ b/.github/workflows/update.yml @@ -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 diff --git a/.gitignore b/.gitignore index cb54ccd..2390cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ build/ __pycache__/ *.pyc +.agents/ +.claude/ +node_modules/ diff --git a/MIRRORS b/MIRRORS new file mode 100644 index 0000000..f49bb7c --- /dev/null +++ b/MIRRORS @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2ddbc6a --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 4ef3510..586dc94 100644 --- a/README.md +++ b/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 +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 (`/.agents/skills` and `/.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) diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 479ff3e..0000000 --- a/flake.nix +++ /dev/null @@ -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 ]; - }; - }; -} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index f5f6130..0000000 --- a/pyproject.toml +++ /dev/null @@ -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"] diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..0cf926f --- /dev/null +++ b/scripts/install.sh @@ -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 /.agents/skills and are +# mirrored to /.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." diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100644 index 0000000..fd58c99 --- /dev/null +++ b/scripts/update.sh @@ -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." diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..b103d18 --- /dev/null +++ b/skills-lock.json @@ -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" + } + } +} diff --git a/tests/test_lock.py b/tests/test_lock.py new file mode 100644 index 0000000..b089bf3 --- /dev/null +++ b/tests/test_lock.py @@ -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()