#!/usr/bin/env bash # # Regenerate skills-lock.json from skills.config.json. # # How it works: # 1. Read skills.config.json (list of {source, skills[]}). # 2. In a temp dir, run `npx skills add --skill ... --agent --copy --yes` # once per source. The `skills` CLI auto-writes/merges skills-lock.json there. # 3. Copy the resulting lock back to the repo root. # # Run from the repo root (or anywhere; paths are resolved relative to the script): # bash scripts/generate-lock.sh # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" CONFIG="$REPO_ROOT/skills.config.json" DEST_LOCK="$REPO_ROOT/skills-lock.json" if [[ ! -f "$CONFIG" ]]; then echo "error: $CONFIG not found." >&2 exit 1 fi for bin in node npx; do if ! command -v "$bin" >/dev/null 2>&1; then echo "error: $bin is required (install Node.js >= 20)." >&2 exit 1 fi done WORK_DIR="$(mktemp -d -t skills-lockgen.XXXXXX)" trap 'rm -rf "$WORK_DIR"' EXIT # Emit "agent\ninstallSource\tcanonicalSource\tskillA,skillB\n..." for the shell. # installSource is what we pass to `skills add` (resolved localPath if set, else # the canonical slug). canonicalSource is what the published lock must record. PLAN_FILE="$WORK_DIR/plan.txt" node --input-type=module -e " import fs from 'node:fs'; import path from 'node:path'; const cfg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const repoRoot = process.argv[3]; const agent = cfg.agent || 'claude-code'; const lines = [agent]; for (const entry of cfg.sources || []) { if (!entry?.source || !Array.isArray(entry.skills) || entry.skills.length === 0) continue; const install = entry.localPath ? path.resolve(repoRoot, entry.localPath) : entry.source; lines.push(install + '\t' + entry.source + '\t' + entry.skills.join(',')); } fs.writeFileSync(process.argv[2], lines.join('\n') + '\n'); " "$CONFIG" "$PLAN_FILE" "$REPO_ROOT" AGENT="$(head -n1 "$PLAN_FILE")" echo "==> Agent target: $AGENT" echo "==> Generating lock in $WORK_DIR" # Read plan from fd 3 so the loop body's stdin stays free for npx (the skills # CLI reads stdin even in --yes mode and would otherwise gobble loop lines). while IFS=$'\t' read -r INSTALL_SOURCE CANONICAL_SOURCE SKILLS <&3; do [[ -z "$INSTALL_SOURCE" ]] && continue if [[ "$INSTALL_SOURCE" == "$CANONICAL_SOURCE" ]]; then echo "==> $CANONICAL_SOURCE :: $SKILLS" else echo "==> $CANONICAL_SOURCE (from local $INSTALL_SOURCE) :: $SKILLS" fi SKILL_ARGS=() IFS=',' read -ra _NAMES <<< "$SKILLS" for n in "${_NAMES[@]}"; do [[ -z "$n" ]] && continue SKILL_ARGS+=(--skill "$n") done ( cd "$WORK_DIR" && npx --yes skills@latest add "$INSTALL_SOURCE" \ "${SKILL_ARGS[@]}" \ --agent "$AGENT" \ --copy \ --yes < /dev/null ) done 3< <(tail -n +2 "$PLAN_FILE") if [[ ! -f "$WORK_DIR/skills-lock.json" ]]; then echo "error: skills CLI did not produce $WORK_DIR/skills-lock.json." >&2 exit 1 fi # Rewrite any local-path sources in the lock back to their canonical slug, so # consumers of skills-lock.json (e.g. setup.sh in a dev container) fetch from # GitHub instead of a path that only exists on the generator's machine. node --input-type=module -e " import fs from 'node:fs'; import path from 'node:path'; const cfg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const repoRoot = process.argv[2]; const lock = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); const remap = new Map(); for (const entry of cfg.sources || []) { if (!entry?.source || !entry.localPath) continue; remap.set(path.resolve(repoRoot, entry.localPath), entry.source); } for (const skill of Object.values(lock.skills || {})) { if (!skill?.source) continue; const resolved = path.resolve(skill.source); if (remap.has(resolved)) { skill.source = remap.get(resolved); skill.sourceType = 'github'; } } fs.writeFileSync(process.argv[4], JSON.stringify(lock, null, 2) + '\n'); " "$CONFIG" "$REPO_ROOT" "$WORK_DIR/skills-lock.json" "$DEST_LOCK" echo "==> Wrote $DEST_LOCK" # Cross-check: every (source, skill) requested in config should be present in the lock. node --input-type=module -e " import fs from 'node:fs'; const cfg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const lock = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); const got = new Map(); for (const [name, entry] of Object.entries(lock.skills || {})) { if (!entry?.source) continue; if (!got.has(entry.source)) got.set(entry.source, new Set()); got.get(entry.source).add(name); } const missing = []; for (const entry of cfg.sources || []) { const have = got.get(entry.source) || new Set(); for (const s of entry.skills || []) { if (!have.has(s)) missing.push(entry.source + '::' + s); } } if (missing.length) { console.error('error: skills missing from generated lock (likely missing SKILL.md frontmatter upstream):'); for (const m of missing) console.error(' - ' + m); process.exit(1); } console.log('==> Verified: all ' + Object.keys(lock.skills).length + ' configured skills present in lock.'); " "$CONFIG" "$DEST_LOCK"