diff --git a/.github/workflows/validate-skills.yml b/.github/workflows/validate-skills.yml new file mode 100644 index 0000000..e8e7c32 --- /dev/null +++ b/.github/workflows/validate-skills.yml @@ -0,0 +1,16 @@ +name: Validate Skills + +on: + pull_request: + push: + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Validate skill catalog + run: bash ./scripts/validate_skills.sh diff --git a/.gitignore b/.gitignore index e43b0f9..b24428d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +.worktrees/ diff --git a/README.md b/README.md index 024c5d4..112713d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Portable Elixir and Phoenix skills for Codex. This repository packages custom skills from a local Codex setup so they can be installed on any machine into `~/.codex/skills`. +Each install writes a manifest of the skills this repository manages. Future installs use that manifest to prune skills that were removed from the repo without touching unrelated skills in the target directory. + ## Included - Elixir/Phoenix skills under [`skills/`](skills/) @@ -44,6 +46,8 @@ git pull ./install.sh ``` +If a previously installed skill has been removed from this repository, a subsequent install prunes it automatically by consulting the stored manifest. + ## Uninstall ```bash @@ -62,6 +66,14 @@ Custom target: CODEX_HOME=/path/to/.codex ./uninstall.sh ``` +## Validate + +```bash +bash ./scripts/validate_skills.sh +``` + +This checks the skill catalog for broken local `references/...` links and verifies the install and uninstall scripts, including the guarantee that `install.sh --dry-run` leaves the filesystem untouched. + ## Acknowledgment Inspired by [oliver-kriska/claude-elixir-phoenix](https://github.com/oliver-kriska/claude-elixir-phoenix). This repository is a Codex-oriented adaptation and is not an official fork. diff --git a/install.sh b/install.sh index 69b0f5a..1d300d4 100755 --- a/install.sh +++ b/install.sh @@ -13,32 +13,51 @@ EOF DRY_RUN=0 -case "${1:-}" in - --dry-run) - DRY_RUN=1 - ;; - "" ) - ;; - -h|--help) - usage - exit 0 - ;; - *) - usage - exit 1 - ;; -esac +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + exit 1 + ;; + esac + + shift +done REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SOURCE_DIR="$REPO_ROOT/skills" CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" TARGET_DIR="$CODEX_HOME/skills" - -mkdir -p "$TARGET_DIR" +MANIFEST_PATH="$TARGET_DIR/.codex-elixir-phoenix-manifest" echo "Source: $SOURCE_DIR" echo "Target: $TARGET_DIR" +list_source_skills() { + find "$SOURCE_DIR" -mindepth 1 -maxdepth 1 -type d | sort +} + +skill_in_list() { + local needle="$1" + shift + local skill_name + + for skill_name in "$@"; do + if [[ "$skill_name" == "$needle" ]]; then + return 0 + fi + done + + return 1 +} + copy_skill() { local skill_name="$1" local source_path="$SOURCE_DIR/$skill_name" @@ -54,6 +73,45 @@ copy_skill() { echo "installed $skill_name" } +prune_removed_skills() { + local skill_name + local target_path + + if [[ ! -f "$MANIFEST_PATH" ]]; then + return + fi + + while IFS= read -r skill_name; do + [[ -z "$skill_name" ]] && continue + + if skill_in_list "$skill_name" "${SOURCE_SKILLS[@]}"; then + continue + fi + + target_path="$TARGET_DIR/$skill_name" + + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[dry-run] prune $target_path" + continue + fi + + if [[ -d "$target_path" ]]; then + rm -rf "$target_path" + echo "pruned $skill_name" + fi + done < "$MANIFEST_PATH" +} + +write_manifest() { + if [[ "$DRY_RUN" -eq 1 ]]; then + return + fi + + printf '%s\n' "${SOURCE_SKILLS[@]}" > "$MANIFEST_PATH" +} + +SOURCE_SKILLS=() + while IFS= read -r skill_path; do skill_name="$(basename "$skill_path")" @@ -61,8 +119,20 @@ while IFS= read -r skill_path; do continue fi + SOURCE_SKILLS+=("$skill_name") +done < <(list_source_skills) + +if [[ "$DRY_RUN" -ne 1 ]]; then + mkdir -p "$TARGET_DIR" +fi + +prune_removed_skills + +for skill_name in "${SOURCE_SKILLS[@]}"; do copy_skill "$skill_name" -done < <(find "$SOURCE_DIR" -mindepth 1 -maxdepth 1 -type d | sort) +done + +write_manifest if [[ "$DRY_RUN" -eq 1 ]]; then echo "Dry run complete." diff --git a/scripts/validate_skills.sh b/scripts/validate_skills.sh new file mode 100644 index 0000000..6ba974b --- /dev/null +++ b/scripts/validate_skills.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SKILLS_DIR="$REPO_ROOT/skills" +TEMP_DIRS=() +ERRORS=0 + +cleanup() { + local dir + + for dir in "${TEMP_DIRS[@]}"; do + if [[ -d "$dir" ]]; then + rm -rf "$dir" + fi + done +} + +trap cleanup EXIT + +make_temp_dir() { + local dir + + dir="$(mktemp -d "${TMPDIR:-/tmp}/codex-elixir-phoenix.XXXXXX")" + TEMP_DIRS+=("$dir") + printf '%s\n' "$dir" +} + +record_error() { + echo "ERROR: $*" >&2 + ERRORS=$((ERRORS + 1)) +} + +list_source_skills() { + find "$SKILLS_DIR" -mindepth 1 -maxdepth 1 -type d | sort +} + +validate_skill_references() { + local skill_dir + local skill_file + local ref + + while IFS= read -r skill_dir; do + skill_file="$skill_dir/SKILL.md" + + if [[ ! -f "$skill_file" ]]; then + record_error "$(basename "$skill_dir") is missing SKILL.md" + continue + fi + + while IFS= read -r ref; do + [[ -z "$ref" ]] && continue + + if [[ ! -f "$skill_dir/$ref" ]]; then + record_error "$(basename "$skill_dir") references missing file: $ref" + fi + done < <(rg -o 'references/[A-Za-z0-9._/-]+' "$skill_file" | sort -u || true) + done < <(list_source_skills) +} + +validate_dry_run_is_clean() { + local sandbox + + sandbox="$(make_temp_dir)" + CODEX_HOME="$sandbox" "$REPO_ROOT/install.sh" --dry-run >/dev/null + + if [[ -e "$sandbox/skills" ]]; then + record_error "install.sh --dry-run created $sandbox/skills" + fi +} + +validate_install_round_trip() { + local sandbox + local manifest_path + local skill_dir + local skill_name + + sandbox="$(make_temp_dir)" + manifest_path="$sandbox/skills/.codex-elixir-phoenix-manifest" + + CODEX_HOME="$sandbox" "$REPO_ROOT/install.sh" >/dev/null + + if [[ ! -f "$manifest_path" ]]; then + record_error "install.sh did not write the ownership manifest" + fi + + while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + + if [[ ! -d "$sandbox/skills/$skill_name" ]]; then + record_error "install.sh did not install $skill_name" + fi + done < <(list_source_skills) + + CODEX_HOME="$sandbox" "$REPO_ROOT/uninstall.sh" >/dev/null + + if [[ -f "$manifest_path" ]]; then + record_error "uninstall.sh did not remove the ownership manifest" + fi + + while IFS= read -r skill_dir; do + skill_name="$(basename "$skill_dir")" + + if [[ -e "$sandbox/skills/$skill_name" ]]; then + record_error "uninstall.sh did not remove $skill_name" + fi + done < <(list_source_skills) +} + +validate_skill_references +validate_dry_run_is_clean +validate_install_round_trip + +if [[ "$ERRORS" -gt 0 ]]; then + echo "Validation failed with $ERRORS error(s)." >&2 + exit 1 +fi + +echo "Skill catalog validation passed." diff --git a/skills/elixir-phoenix-compound/SKILL.md b/skills/elixir-phoenix-compound/SKILL.md index bc1d459..05077dd 100644 --- a/skills/elixir-phoenix-compound/SKILL.md +++ b/skills/elixir-phoenix-compound/SKILL.md @@ -50,8 +50,8 @@ existing** (same root cause, new symptom), or **Skip**. Extract from session context: module, symptoms, investigation steps, root cause, solution code, and prevention advice. -Validate frontmatter against `compound-docs/references/schema.md`, -then create file using `compound-docs/references/resolution-template.md`. +Open `elixir-phoenix-compound-docs` and use its schema and +resolution template references before writing the solution file. ### Step 4: Decision Menu @@ -86,4 +86,4 @@ When user says "that worked", "it's fixed", "problem solved", ## References - `references/compound-workflow.md` — Detailed step-by-step -- See also: `compound-docs` skill for schema and templates +- See also: `elixir-phoenix-compound-docs` for schema and templates diff --git a/uninstall.sh b/uninstall.sh index b95ac53..4006b17 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -13,30 +13,37 @@ EOF DRY_RUN=0 -case "${1:-}" in - --dry-run) - DRY_RUN=1 - ;; - "" ) - ;; - -h|--help) - usage - exit 0 - ;; - *) - usage - exit 1 - ;; -esac +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + exit 1 + ;; + esac + + shift +done REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SOURCE_DIR="$REPO_ROOT/skills" CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" TARGET_DIR="$CODEX_HOME/skills" +MANIFEST_PATH="$TARGET_DIR/.codex-elixir-phoenix-manifest" echo "Source: $SOURCE_DIR" echo "Target: $TARGET_DIR" +list_source_skills() { + find "$SOURCE_DIR" -mindepth 1 -maxdepth 1 -type d | sort +} + remove_skill() { local skill_name="$1" local target_path="$TARGET_DIR/$skill_name" @@ -54,15 +61,37 @@ remove_skill() { fi } -while IFS= read -r skill_path; do - skill_name="$(basename "$skill_path")" +OWNED_SKILLS=() - if [[ "$skill_name" == ".system" ]]; then - continue - fi +if [[ -f "$MANIFEST_PATH" ]]; then + while IFS= read -r skill_name; do + [[ -z "$skill_name" ]] && continue + OWNED_SKILLS+=("$skill_name") + done < "$MANIFEST_PATH" +else + while IFS= read -r skill_path; do + skill_name="$(basename "$skill_path")" + + if [[ "$skill_name" == ".system" ]]; then + continue + fi + OWNED_SKILLS+=("$skill_name") + done < <(list_source_skills) +fi + +for skill_name in "${OWNED_SKILLS[@]}"; do remove_skill "$skill_name" -done < <(find "$SOURCE_DIR" -mindepth 1 -maxdepth 1 -type d | sort) +done + +if [[ -f "$MANIFEST_PATH" ]]; then + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[dry-run] remove $MANIFEST_PATH" + else + rm -f "$MANIFEST_PATH" + echo "removed manifest" + fi +fi if [[ "$DRY_RUN" -eq 1 ]]; then echo "Dry run complete."